feat: 迁移前端资源文件并重构项目结构

refactor: 优化前端代码结构和资源管理

style: 调整前端代码格式和样式

chore: 更新.gitignore和构建配置

fix: 修复前端资源路径和引用问题

docs: 更新前端文档和注释

perf: 优化前端性能和加载速度

test: 更新前端测试用例

build: 调整前端构建配置

ci: 更新CI/CD配置
This commit is contained in:
zhangtao
2026-05-09 00:46:43 +08:00
parent 1bd125d99b
commit bf81480ada
967 changed files with 21018 additions and 83019 deletions
@@ -2,7 +2,7 @@
<!-- 悬浮按钮 -->
<div class="ai-assistant">
<!-- AI 助手图标按钮 -->
<el-button
<ElButton
v-if="!dialogVisible && !fabCollapsed"
class="ai-fab-button"
type="primary"
@@ -12,8 +12,8 @@
@contextmenu.prevent="fabCollapsed = true"
@click="handleOpen"
>
<div class="i-svg:ai ai-icon" />
</el-button>
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('ai')" class="ai-icon" />
</ElButton>
<!-- 收缩态:贴边小标签,避免遮挡表单控件 -->
<div
@@ -26,7 +26,7 @@
</div>
<!-- AI 对话框 -->
<el-dialog
<ElDialog
v-model="dialogVisible"
title="AI 智能助手"
width="600px"
@@ -36,14 +36,14 @@
>
<template #header>
<div class="dialog-header">
<div class="i-svg:ai header-icon" />
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('ai')" class="header-icon" />
<span class="title">AI 智能助手</span>
</div>
</template>
<!-- 命令输入 -->
<div class="command-input">
<el-input
<ElInput
v-model="command"
type="textarea"
:rows="3"
@@ -56,48 +56,48 @@
<!-- 快捷命令示例 -->
<div class="quick-commands">
<div class="section-title">💡 试试这些命令:</div>
<el-tag
<ElTag
v-for="example in examples"
:key="example"
class="command-tag"
@click="command = example"
>
{{ example }}
</el-tag>
</ElTag>
</div>
<!-- AI 响应结果 -->
<div v-if="response" class="ai-response">
<el-alert :title="response.explanation" type="success" :closable="false" show-icon />
<ElAlert :title="response.explanation" type="success" :closable="false" show-icon />
<!-- 将要执行的操作 -->
<div v-if="response.action" class="action-preview">
<div class="action-title">🎯 将要执行:</div>
<div class="action-content">
<div v-if="response.action.type === 'navigate'">
<el-icon><Position /></el-icon>
<ElIcon><Position /></ElIcon>
跳转到:
<strong>{{ response.action.pageName }}</strong>
<span v-if="response.action.query" class="query-info">
并搜索:
<el-tag type="warning" size="small">{{ response.action.query }}</el-tag>
<ElTag type="warning" size="small">{{ response.action.query }}</ElTag>
</span>
</div>
<div v-if="response.action.type === 'navigate-and-execute'">
<el-icon><Position /></el-icon>
<ElIcon><Position /></ElIcon>
跳转至:
<strong>{{ response.action.pageName }}</strong>
<span v-if="response.action.query" class="query-info">
并搜索:
<el-tag type="warning" size="small">{{ response.action.query }}</el-tag>
<ElTag type="warning" size="small">{{ response.action.query }}</ElTag>
</span>
<el-divider direction="vertical" />
<el-icon><Tools /></el-icon>
<ElDivider direction="vertical" />
<ElIcon><Tools /></ElIcon>
执行:
<strong>{{ response.action.functionCall.name }}</strong>
</div>
<div v-if="response.action.type === 'execute'">
<el-icon><Tools /></el-icon>
<ElIcon><Tools /></ElIcon>
执行:
<strong>{{ response.action.functionName }}</strong>
</div>
@@ -107,22 +107,24 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleExecute">
<el-icon><MagicStick /></el-icon>
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" :loading="loading" @click="handleExecute">
<ElIcon><MagicStick /></ElIcon>
执行命令
</el-button>
</ElButton>
</div>
</template>
</el-dialog>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
import { nextTick, onBeforeUnmount, onMounted, watch, ref, computed } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useSettingsStore } from "@/store";
import { useSettingsStore } from "@stores";
import { AiChatAPI, ChatSession, ChatSessionDetail } from "@/api/module_ai/chat";
type ToolFunctionCall = {
@@ -10,7 +10,7 @@ defineOptions({
inheritAttrs: false,
});
import { isExternal } from "@/utils/index";
import { isExternal } from "@utils/index";
const props = defineProps({
to: {
@@ -1,6 +1,6 @@
<template>
<el-breadcrumb class="flex-y-center">
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
<ElBreadcrumb class="flex-y-center">
<ElBreadcrumbItem v-for="(item, index) in breadcrumbs" :key="item.path">
<span
v-if="item.redirect === 'noredirect' || index === breadcrumbs.length - 1"
class="color-gray-400"
@@ -10,15 +10,15 @@
<a v-else @click.prevent="handleLink(item)">
{{ translateRouteTitle(item.meta.title) }}
</a>
</el-breadcrumb-item>
</el-breadcrumb>
</ElBreadcrumbItem>
</ElBreadcrumb>
</template>
<script setup lang="ts">
import { RouteLocationMatched } from "vue-router";
import { compile } from "path-to-regexp";
import router from "@/router";
import { translateRouteTitle } from "@/utils/i18n";
import { router } from "@/router";
import { translateRouteTitle } from "@utils/i18n";
const currentRoute = useRoute();
const pathCompile = (path: string) => {
@@ -33,7 +33,7 @@ function getBreadcrumb() {
let matched = currentRoute.matched.filter((item) => item.meta && item.meta.title);
const first = matched[0];
if (!isDashboard(first)) {
matched = [{ path: "/home", meta: { title: "首页" } } as any].concat(matched);
matched = [{ path: "/home", meta: { title: "menus.home.title" } } as any].concat(matched);
}
breadcrumbs.value = matched.filter((item) => {
return item.meta && item.meta.title && item.meta.breadcrumb !== false;
@@ -45,7 +45,8 @@ function isDashboard(route: RouteLocationMatched) {
if (!name) {
return false;
}
return name.toString().trim().toLocaleLowerCase() === "Dashboard".toLocaleLowerCase();
const n = name.toString().trim().toLowerCase();
return n === "home" || n === "workplace" || n === "dashboard" || n.startsWith("dashboard");
}
function handleLink(item: any) {
@@ -0,0 +1,111 @@
<template>
<div>
<ElForm @submit.prevent="addComment" class="w-full mx-auto mb-10">
<ElFormItem prop="author" class="mt-5">
<ElInput
v-model="newComment.author"
placeholder="你的名称"
class="block w-full"
clearable
/>
</ElFormItem>
<ElFormItem prop="content">
<ElInput
v-model="newComment.content"
placeholder="简单说两句..."
type="textarea"
:rows="5"
clearable
/>
</ElFormItem>
<ElFormItem>
<div class="flex justify-end w-full">
<ElButton type="primary" @click="addComment">发布</ElButton>
</div>
</ElFormItem>
</ElForm>
<ul>
<div class="pb-5 text-lg font-medium">评论 {{ comments.length }}</div>
<CommentItem
v-for="comment in comments.slice().reverse()"
:key="comment.id"
:comment="comment"
:show-reply-form="showReplyForm"
@toggle-reply="toggleReply"
@add-reply="addReply"
class="pb-2.5 mb-5 border-b border-g-400"
/>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import CommentItem from "./widget/CommentItem.vue";
import { commentList, Comment } from "@/mock/temp/commentDetail";
const comments = commentList;
const newComment = ref<Partial<Comment>>({
author: "",
content: "",
});
const showReplyForm = ref<number | null>(null);
const addComment = () => {
if (!newComment.value.author?.trim() || !newComment.value.content?.trim()) {
ElMessage.warning("请填写完整的评论信息");
return;
}
comments.value.push({
id: Date.now(),
author: newComment.value.author.trim(),
content: newComment.value.content.trim(),
timestamp: new Date().toISOString(),
replies: [],
});
newComment.value.author = "";
newComment.value.content = "";
ElMessage.success("评论发布成功");
};
const addReply = (commentId: number, replyAuthor: string, replyContent: string) => {
if (!replyAuthor?.trim() || !replyContent?.trim()) {
ElMessage.warning("请填写完整的回复信息");
return;
}
const comment = findComment(comments.value, commentId);
if (comment) {
comment.replies.push({
id: Date.now(),
author: replyAuthor.trim(),
content: replyContent.trim(),
timestamp: new Date().toISOString(),
replies: [],
});
showReplyForm.value = null;
ElMessage.success("回复发布成功");
}
};
const toggleReply = (commentId: number) => {
showReplyForm.value = showReplyForm.value === commentId ? null : commentId;
};
const findComment = (comments: Comment[], commentId: number): Comment | undefined => {
for (const comment of comments) {
if (comment.id === commentId) {
return comment;
}
const found = findComment(comment.replies, commentId);
if (found) {
return found;
}
}
return undefined;
};
</script>
@@ -0,0 +1,121 @@
<template>
<li>
<div>
<div class="flex-c">
<div
class="size-5 mr-2.5 text-xs font-medium text-white rounded-full flex-cc"
:style="{ background: randomColor() }"
>
{{ comment.author.substring(0, 1) }}
</div>
<strong class="block text-sm font-medium">{{ comment.author }}</strong>
</div>
<span class="block mt-2.5 text-sm text-g-700">{{ comment.content }}</span>
<div class="flex-c mt-2.5">
<span class="text-xs text-g-700">{{ formatDate(comment.timestamp) }}</span>
<div
class="ml-5 text-xs text-g-700 c-p select-none hover:text-theme"
@click="toggleReply(comment.id)"
>
回复
</div>
</div>
</div>
<ul class="pl-2.5" v-if="comment.replies.length > 0">
<CommentItem
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
:show-reply-form="showReplyForm"
@toggle-reply="toggleReply"
@add-reply="addReply"
class="mt-5"
/>
</ul>
<ElForm v-if="showReplyForm === comment.id" @submit.prevent="handleSubmit" class="mt-4">
<ElFormItem prop="author">
<ElInput v-model="replyAuthor" placeholder="你的名称" clearable />
</ElFormItem>
<ElFormItem prop="content">
<ElInput
v-model="replyContent"
placeholder="你的回复..."
type="textarea"
:rows="3"
clearable
/>
</ElFormItem>
<ElFormItem>
<div class="flex justify-end gap-2 w-full">
<ElButton @click="toggleReply(comment.id)">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">发布</ElButton>
</div>
</ElFormItem>
</ElForm>
</li>
</template>
<script setup lang="ts">
import AppConfig from "@/config";
import { ref } from "vue";
interface Comment {
id: number;
author: string;
content: string;
timestamp: string;
replies: Comment[];
}
const props = defineProps<{
comment: Comment;
showReplyForm: number | null;
}>();
const emit = defineEmits<{
(event: "toggle-reply", commentId: number): void;
(event: "add-reply", commentId: number, replyAuthor: string, replyContent: string): void;
}>();
const replyAuthor = ref("");
const replyContent = ref("");
const toggleReply = (commentId: number) => {
emit("toggle-reply", commentId);
};
const addReply = (commentId: number, author: string, content: string) => {
emit("add-reply", commentId, author, content);
replyAuthor.value = "";
replyContent.value = "";
};
const handleSubmit = () => {
if (!replyAuthor.value.trim() || !replyContent.value.trim()) {
return;
}
emit("add-reply", props.comment.id, replyAuthor.value, replyContent.value);
replyAuthor.value = "";
replyContent.value = "";
};
const formatDate = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
let lastColor: string | null = null;
const randomColor = () => {
let newColor: string;
do {
const index = Math.floor(Math.random() * AppConfig.systemMainColor.length);
newColor = AppConfig.systemMainColor[index];
} while (newColor === lastColor);
lastColor = newColor;
return newColor;
};
</script>
@@ -1,92 +0,0 @@
<!-- 列表页左侧工具栏:1) configButtons 与 PageContent 配置驱动一致 2) perm 预设「新增/批删/更多」 3) 默认插槽完全自定义 -->
<template>
<div class="data-table__toolbar--left">
<template v-if="configButtons && configButtons.length">
<template v-for="(btn, index) in configButtons" :key="index">
<el-button
v-hasPerm="btn.perm ?? '*:*:*'"
v-bind="btn.attrs"
:disabled="btn.name === 'delete' && removeIds.length === 0"
@click="$emit('toolbar', btn.name)"
>
{{ btn.text }}
</el-button>
</template>
</template>
<slot v-else>
<el-button
v-if="permCreate"
v-hasPerm="permCreate"
type="success"
icon="plus"
@click="$emit('add')"
>
新增
</el-button>
<el-button
v-if="permDelete"
v-hasPerm="permDelete"
type="danger"
icon="delete"
:loading="deleteLoading"
:disabled="removeIds.length === 0"
@click="$emit('delete')"
>
批量删除
</el-button>
<el-dropdown v-if="permPatch" v-hasPerm="permPatch" trigger="click">
<el-button
type="default"
:disabled="removeIds.length === 0 || moreDisabled"
icon="ArrowDown"
>
更多
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="Check" @click="$emit('more', '0')">批量启用</el-dropdown-item>
<el-dropdown-item icon="CircleClose" @click="$emit('more', '1')">
批量停用
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</slot>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { CrudToolbarConfigButton } from "./types";
const props = withDefaults(
defineProps<{
/** 与 PageContent `toolbarLeftBtn` 一致时走配置驱动(与 handleToolbar 对齐) */
configButtons?: CrudToolbarConfigButton[];
/** 勾选行主键,用于禁用批删 / 更多(插槽完全自定义时可不传) */
removeIds?: Array<string | number>;
/** 新增按钮权限,不传则不显示(configButtons 未传时) */
permCreate?: string | string[];
/** 批量删除权限,不传则不显示 */
permDelete?: string | string[];
/** 「更多」下拉权限,不传则不显示 */
permPatch?: string | string[];
/** 批量删除中(按钮 loading,并禁用「更多」) */
deleteLoading?: boolean;
}>(),
{
removeIds: () => [],
deleteLoading: false,
}
);
defineEmits<{
/** 配置模式:与 PageContent handleToolbar 一致 */
toolbar: [name: string];
add: [];
delete: [];
more: [value: string];
}>();
const moreDisabled = computed(() => props.removeIds.length === 0 || props.deleteLoading);
</script>
@@ -1,55 +0,0 @@
<!-- PageContent 右侧圆形工具条:与业务页 #toolbar 中重复的 v-for 逻辑一致,含 Tooltip / 列筛选 -->
<template>
<slot name="prepend" />
<template v-for="(btn, index) in buttons" :key="index">
<el-popover v-if="btn.name === 'filter'" placement="bottom" trigger="click">
<template #reference>
<el-button v-bind="btn.attrs" />
</template>
<el-scrollbar max-height="350px">
<template v-for="c in cols" :key="c.prop">
<el-checkbox v-if="c.prop" v-model="c.show" :label="c.label" />
</template>
</el-scrollbar>
</el-popover>
<el-tooltip v-else-if="tooltipContent(btn.name)" :content="tooltipContent(btn.name)!">
<el-button v-hasPerm="btn.perm ?? '*:*:*'" v-bind="btn.attrs" @click="onToolbar(btn.name)" />
</el-tooltip>
<el-button
v-else
v-hasPerm="btn.perm ?? '*:*:*'"
v-bind="btn.attrs"
@click="onToolbar(btn.name)"
/>
</template>
</template>
<script setup lang="ts">
import type { ButtonProps } from "element-plus";
import type { CSSProperties } from "vue";
/** 与 PageContent createToolbar 产出的右侧按钮结构一致 */
export type CrudToolbarRightButton = {
name: string;
text?: string;
attrs?: Partial<ButtonProps> & { style?: CSSProperties };
perm?: string | string[] | null;
};
defineProps<{
buttons: CrudToolbarRightButton[];
cols: Array<{ prop?: string; label?: string; show?: boolean }>;
onToolbar: (name: string) => void;
}>();
const TOOLTIPS: Record<string, string> = {
import: "导入",
export: "导出",
filter: "筛选",
refresh: "刷新",
};
function tooltipContent(name: string): string | undefined {
return TOOLTIPS[name];
}
</script>
@@ -1,997 +0,0 @@
<template>
<el-card
class="data-table flex-1 min-h-0"
:shadow="config.cardShadow ?? 'never'"
:class="contentConfig.cardClass"
:body-style="cardBodyStyle"
>
<template v-if="slots.header" #header>
<slot name="header" />
</template>
<!-- 表格工具栏:#toolbar 可完全自定义;默认左右分栏 -->
<div
v-if="
config.showToolbar !== false &&
(slots.toolbar || toolbarLeftBtn.length > 0 || toolbarRightBtn.length > 0)
"
class="data-table__toolbar"
>
<slot
name="toolbar"
:toolbar-left="toolbarLeftBtn"
:toolbar-right="toolbarRightBtn"
:on-toolbar="handleToolbar"
:remove-ids="removeIds"
:cols="cols"
>
<CrudToolbarLeft
v-if="toolbarLeftBtn.length > 0"
:remove-ids="removeIds"
:config-buttons="toolbarLeftBtn"
@toolbar="handleToolbar"
/>
<div class="data-table__toolbar--right">
<CrudToolbarRight :buttons="toolbarRightBtn" :cols="cols" :on-toolbar="handleToolbar" />
</div>
</slot>
</div>
<!-- 列表:默认内置 el-table;传入 #table 插槽可完全自定义(普通表 / 树表等) -->
<div class="data-table__content">
<slot
name="table"
:data="pageData"
:loading="loading"
:table-ref="tableRef"
:on-selection-change="handleSelectionChange"
:pagination="pagination"
>
<el-table
ref="tableRef"
v-loading="loading"
v-bind="contentConfig.table"
:data="pageData"
:row-key="pk"
class="flex-1"
@selection-change="handleSelectionChange"
@filter-change="handleFilterChange"
>
<template v-for="col in cols" :key="col.prop">
<el-table-column v-if="col.show" v-bind="col">
<template #default="scope">
<!-- 显示图片 -->
<template v-if="col.templet === 'image'">
<template v-if="col.prop">
<template v-if="Array.isArray(scope.row[col.prop])">
<template v-for="(item, index) in scope.row[col.prop]" :key="item">
<el-image
:src="item"
:preview-src-list="scope.row[col.prop]"
:initial-index="Number(index)"
:preview-teleported="true"
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
/>
</template>
</template>
<template v-else>
<el-image
:src="scope.row[col.prop]"
:preview-src-list="[scope.row[col.prop]]"
:preview-teleported="true"
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
/>
</template>
</template>
</template>
<!-- 根据行的selectList属性返回对应列表值 -->
<template v-else-if="col.templet === 'list'">
<template v-if="col.prop">
{{ (col.selectList ?? {})[scope.row[col.prop]] }}
</template>
</template>
<!-- 格式化显示链接 -->
<template v-else-if="col.templet === 'url'">
<template v-if="col.prop">
<el-link type="primary" :href="scope.row[col.prop]" target="_blank">
{{ scope.row[col.prop] }}
</el-link>
</template>
</template>
<!-- 生成开关组件 -->
<template v-else-if="col.templet === 'switch'">
<template v-if="col.prop">
<!-- pageData.length>0: 解决el-switch组件会在表格初始化的时候触发一次change事件 -->
<el-switch
v-model="scope.row[col.prop]"
:active-value="col.activeValue ?? 1"
:inactive-value="col.inactiveValue ?? 0"
:inline-prompt="true"
:active-text="col.activeText ?? ''"
:inactive-text="col.inactiveText ?? ''"
:validate-event="false"
:disabled="col.disabled"
@change="
pageData.length > 0 &&
handleModify(col.prop, scope.row[col.prop], scope.row)
"
/>
</template>
</template>
<!-- 生成输入框组件 -->
<template v-else-if="col.templet === 'input'">
<template v-if="col.prop">
<el-input
v-model="scope.row[col.prop]"
:type="col.inputType ?? 'text'"
:disabled="col.disabled"
@blur="handleModify(col.prop, scope.row[col.prop], scope.row)"
/>
</template>
</template>
<!-- 格式化为价格 -->
<template v-else-if="col.templet === 'price'">
<template v-if="col.prop">
{{ `${col.priceFormat ?? "¥"}${scope.row[col.prop]}` }}
</template>
</template>
<!-- 格式化为百分比 -->
<template v-else-if="col.templet === 'percent'">
<template v-if="col.prop">{{ scope.row[col.prop] }}%</template>
</template>
<!-- 显示图标 -->
<template v-else-if="col.templet === 'icon'">
<template v-if="col.prop">
<template v-if="scope.row[col.prop].startsWith('el-icon-')">
<el-icon>
<component :is="scope.row[col.prop].replace('el-icon-', '')" />
</el-icon>
</template>
<template v-else>
<div class="i-svg:{{ scope.row[col.prop] }}" />
</template>
</template>
</template>
<!-- 格式化时间 -->
<template v-else-if="col.templet === 'date'">
<template v-if="col.prop">
{{
scope.row[col.prop]
? useDateFormat(
scope.row[col.prop],
col.dateFormat ?? "YYYY-MM-DD HH:mm:ss"
).value
: ""
}}
</template>
</template>
<!-- 列操作栏 -->
<template v-else-if="col.templet === 'tool'">
<template v-for="(btn, index) in tableToolbarBtn" :key="index">
<el-button
v-if="btn.render === undefined || btn.render(scope.row)"
v-hasPerm="btn.perm ?? '*:*:*'"
v-bind="btn.attrs"
@click="
handleOperate({
name: btn.name,
row: scope.row,
column: scope.column,
$index: scope.$index,
})
"
>
{{ btn.text }}
</el-button>
</template>
</template>
<!-- 自定义 -->
<template v-else-if="col.templet === 'custom'">
<slot :name="col.slotName ?? col.prop" :prop="col.prop" v-bind="scope" />
</template>
</template>
</el-table-column>
</template>
</el-table>
</slot>
</div>
<template v-if="showPagination" #footer>
<el-scrollbar :class="['h-8!', { 'flex-x-end': contentConfig?.pagePosition === 'right' }]">
<el-pagination
v-bind="pagination"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-scrollbar>
</template>
<!-- 导出弹窗 -->
<EnhancedDialog
v-model="exportsModalVisible"
title="导出数据"
width="600px"
dialog-class="curd-embed-dialog"
modal-class="curd-embed-dialog"
@close="handleCloseExportsModal"
>
<!-- 滚动 -->
<el-scrollbar max-height="60vh">
<!-- 表单 -->
<el-form
ref="exportsFormRef"
style="padding-right: var(--el-dialog-padding-primary)"
:model="exportsFormData"
:rules="exportsFormRules"
>
<el-form-item label="文件名" prop="filename">
<el-input v-model="exportsFormData.filename" clearable />
</el-form-item>
<el-form-item label="工作表名" prop="sheetname">
<el-input v-model="exportsFormData.sheetname" clearable />
</el-form-item>
<el-form-item label="数据源" prop="origin">
<el-select v-model="exportsFormData.origin">
<el-option label="当前数据 (当前页的数据)" :value="ExportsOriginEnum.CURRENT" />
<el-option
label="选中数据 (所有选中的数据)"
:value="ExportsOriginEnum.SELECTED"
:disabled="selectionData.length <= 0"
/>
<el-option
label="全量数据 (所有分页的数据)"
:value="ExportsOriginEnum.REMOTE"
:disabled="contentConfig.exportsAction === undefined"
/>
</el-select>
</el-form-item>
<el-form-item label="字段" prop="fields">
<el-checkbox-group v-model="exportsFormData.fields">
<template v-for="col in cols" :key="col.prop">
<el-checkbox v-if="col.prop" :value="col.prop" :label="col.label" />
</template>
</el-checkbox-group>
</el-form-item>
</el-form>
</el-scrollbar>
<!-- 弹窗底部操作按钮 -->
<template #footer>
<div style="padding-right: var(--el-dialog-padding-primary)">
<el-button type="primary" @click="handleExportsSubmit">确 定</el-button>
<el-button @click="handleCloseExportsModal">取 消</el-button>
</div>
</template>
</EnhancedDialog>
<!-- 导入弹窗 -->
<EnhancedDialog
v-model="importModalVisible"
title="导入数据"
width="600px"
dialog-class="curd-embed-dialog"
modal-class="curd-embed-dialog"
@close="handleCloseImportModal"
>
<!-- 滚动 -->
<el-scrollbar max-height="60vh">
<!-- 表单 -->
<el-form
ref="importFormRef"
style="padding-right: var(--el-dialog-padding-primary)"
:model="importFormData"
:rules="importFormRules"
>
<el-form-item label="文件名" prop="files">
<el-upload
ref="uploadRef"
v-model:file-list="importFormData.files"
class="w-full"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
:drag="true"
:limit="1"
:auto-upload="false"
:on-exceed="handleFileExceed"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
<span>将文件拖到此处,或</span>
<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
*.xlsx / *.xls
<el-link
v-if="contentConfig.importTemplate"
type="primary"
icon="download"
underline="never"
@click="handleDownloadTemplate"
>
下载模板
</el-link>
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
</el-scrollbar>
<!-- 弹窗底部操作按钮 -->
<template #footer>
<div style="padding-right: var(--el-dialog-padding-primary)">
<el-button
type="primary"
:disabled="importFormData.files.length === 0"
@click="handleImportSubmit"
>
确 定
</el-button>
<el-button @click="handleCloseImportModal">取 消</el-button>
</div>
</template>
</EnhancedDialog>
</el-card>
</template>
<script setup lang="ts">
import { useDateFormat, useThrottleFn } from "@vueuse/core";
import {
ElMessage,
genFileId,
type FormInstance,
type FormRules,
type UploadInstance,
type UploadRawFile,
type UploadUserFile,
type TableInstance,
} from "element-plus";
import ExcelJS from "exceljs";
import { reactive, ref, computed, nextTick, useSlots } from "vue";
import CrudToolbarLeft from "./CrudToolbarLeft.vue";
import CrudToolbarRight from "./CrudToolbarRight.vue";
import EnhancedDialog from "./EnhancedDialog.vue";
import type { IContentConfig, IObject, IOperateData } from "./types";
import type { IToolsButton } from "./types";
const slots = useSlots();
const cardBodyStyle = {
display: "flex",
flex: 1,
flexDirection: "column" as const,
minHeight: 0,
overflow: "hidden",
padding: "16px 20px",
};
// 定义接收的属性
const props = defineProps<{ contentConfig: IContentConfig }>();
// 定义自定义事件
const emit = defineEmits<{
addClick: [];
exportClick: [];
toolbarClick: [name: string];
editClick: [row: IObject];
filterChange: [data: IObject];
operateClick: [data: IOperateData];
}>();
// 表格工具栏按钮配置
const config = computed(() => props.contentConfig);
const buttonConfig = reactive<Record<string, IObject>>({
add: { text: "新增", attrs: { icon: "plus", type: "success" }, perm: "create" },
delete: { text: "删除", attrs: { icon: "delete", type: "danger" }, perm: "delete" },
patch: { text: "批量修改", attrs: { icon: "edit", type: "warning" }, perm: "patch" },
import: { text: "导入", attrs: { icon: "upload", type: "info" }, perm: "import" },
export: { text: "导出", attrs: { icon: "download", type: "warning" }, perm: "export" },
refresh: { text: "刷新", attrs: { icon: "refresh", type: "success" }, perm: "*:*:*" },
filter: { text: "筛选列", attrs: { icon: "operation", type: "danger" }, perm: "*:*:*" },
view: { text: "详情", attrs: { icon: "view", type: "primary" }, perm: "detail" },
edit: { text: "编辑", attrs: { icon: "edit", type: "primary" }, perm: "update" },
});
// 主键
const pk = props.contentConfig.pk ?? "id";
// 权限名称前缀
const authPrefix = computed(() => props.contentConfig.permPrefix);
// 获取按钮权限标识
function getButtonPerm(action: string): string | null {
// 如果action已经包含完整路径(包含冒号),则直接使用
if (action.includes(":")) {
return action;
}
// 否则使用权限前缀组合
return authPrefix.value ? `${authPrefix.value}:${action}` : null;
}
// 检查是否有权限
// function hasButtonPerm(action: string): boolean {
// const perm = getButtonPerm(action);
// // 如果没有设置权限标识,则默认具有权限
// if (!perm) return true;
// return hasAuth(perm);
// }
// 创建工具栏按钮
function createToolbar(toolbar: Array<string | IToolsButton>, attr = {}) {
return toolbar.map((item) => {
const isString = typeof item === "string";
const name = isString ? item : item?.name || "";
const base = (isString ? buttonConfig[item] : buttonConfig[name]) as IObject | undefined;
return {
name,
text: isString ? buttonConfig[item].text : (item?.text ?? base?.text),
// 对象写法(如 { name: 'refresh', perm: 'refresh' })需合并 buttonConfig 默认 attrs,否则无 icon/type
attrs: {
...attr,
...(isString ? buttonConfig[item].attrs : { ...base?.attrs, ...item?.attrs }),
},
render: isString ? undefined : (item?.render ?? undefined),
perm: isString
? getButtonPerm(buttonConfig[item].perm)
: item?.perm
? getButtonPerm(item.perm as string)
: "*:*:*",
};
});
}
// 左侧工具栏按钮
const toolbarLeftBtn = computed(() => {
if (!config.value.toolbar || config.value.toolbar.length === 0) return [];
return createToolbar(config.value.toolbar, {});
});
const hideColumnFilter = computed(() => {
if (config.value.hideColumnFilter === true) return true;
if (config.value.hideColumnFilter === false) return false;
return Boolean(slots.table);
});
// 右侧工具栏按钮
const toolbarRightBtn = computed(() => {
if (!config.value.defaultToolbar || config.value.defaultToolbar.length === 0) return [];
const raw = createToolbar(config.value.defaultToolbar, { circle: true });
if (hideColumnFilter.value) {
return raw.filter((b) => b.name !== "filter");
}
return raw;
});
// 表格操作工具栏(优先使用 templet 为 tool 的列,避免仅数据列时取错最后一列)
const toolColumn = computed(
() => config.value.cols.find((c) => c.templet === "tool") ?? config.value.cols.at(-1)
);
const tableToolbar = computed(() => toolColumn.value?.operat ?? ["edit", "delete"]);
const tableToolbarBtn = computed(() =>
createToolbar(tableToolbar.value, { link: true, size: "small" })
);
// 表格列
const cols = ref(
props.contentConfig.cols.map((col) => {
if (col.initFn) {
col.initFn(col);
}
if (col.show === undefined) {
col.show = true;
}
if (col.prop !== undefined && col.columnKey === undefined && col["column-key"] === undefined) {
col.columnKey = col.prop;
}
if (
col.type === "selection" &&
col.reserveSelection === undefined &&
col["reserve-selection"] === undefined
) {
// 配合表格row-key实现跨页多选
col.reserveSelection = true;
}
return col;
})
);
// 加载状态
const loading = ref(false);
// 列表数据
const pageData = ref<IObject[]>([]);
// 显示分页
const showPagination = props.contentConfig.pagination !== false;
// 分页配置
const defaultPagination = {
background: true,
layout: "total, sizes, prev, pager, next, jumper",
pageSize: 20,
pageSizes: [10, 20, 30, 50],
total: 0,
currentPage: 1,
};
const pagination = reactive(
typeof props.contentConfig.pagination === "object"
? { ...defaultPagination, ...props.contentConfig.pagination }
: defaultPagination
);
// 分页相关的请求参数
const request = props.contentConfig.request ?? {
page_no: 1,
page_size: 10,
};
const tableRef = ref<TableInstance>();
// 行选中
const selectionData = ref<IObject[]>([]);
// 删除ID集合 用于批量删除
const removeIds = ref<(number | string)[]>([]);
function handleSelectionChange(selection: any[]) {
selectionData.value = selection;
removeIds.value = selection.map((item) => item[pk]);
}
// 获取行选中
function getSelectionData() {
return selectionData.value;
}
// 刷新
function handleRefresh(isRestart = false) {
fetchPageData(lastFormData, isRestart);
}
// 删除
function handleDelete(id?: number | string) {
let ids = "";
if (id !== undefined && id !== null && id !== "") {
ids = String(id);
} else if (removeIds.value.length) {
ids = removeIds.value.map(String).join(",");
}
if (!ids) {
ElMessage.warning("请勾选删除项");
return;
}
const dc = props.contentConfig.deleteConfirm;
ElMessageBox.confirm(dc?.message ?? "确认删除?", dc?.title ?? "警告", {
confirmButtonText: dc?.confirmButtonText ?? "确定",
cancelButtonText: dc?.cancelButtonText ?? "取消",
type: dc?.type ?? "warning",
})
.then(function () {
if (props.contentConfig.deleteAction) {
props.contentConfig
.deleteAction(ids)
.then(() => {
removeIds.value = [];
//清空选中项
tableRef.value?.clearSelection();
handleRefresh(true);
})
.catch(() => {});
} else {
ElMessage.error("未配置deleteAction");
}
})
.catch(() => {});
}
// 导出表单
const fields: string[] = [];
cols.value.forEach((item) => {
if (item.prop !== undefined) {
fields.push(item.prop);
}
});
const enum ExportsOriginEnum {
CURRENT = "current",
SELECTED = "selected",
REMOTE = "remote",
}
const exportsModalVisible = ref(false);
const exportsFormRef = ref<FormInstance>();
const exportsFormData = reactive({
filename: "",
sheetname: "",
fields,
origin: ExportsOriginEnum.CURRENT,
});
const exportsFormRules: FormRules = {
fields: [{ required: true, message: "请选择字段" }],
origin: [{ required: true, message: "请选择数据源" }],
};
// 打开导出弹窗
function handleOpenExportsModal() {
exportsModalVisible.value = true;
}
// 导出确认
const handleExportsSubmit = useThrottleFn(() => {
exportsFormRef.value?.validate((valid: boolean) => {
if (valid) {
handleExports();
handleCloseExportsModal();
}
});
}, 3000);
// 关闭导出弹窗
function handleCloseExportsModal() {
exportsModalVisible.value = false;
exportsFormRef.value?.resetFields();
nextTick(() => {
exportsFormRef.value?.clearValidate();
});
}
// 导出
function handleExports() {
const filename = exportsFormData.filename
? exportsFormData.filename
: props.contentConfig.permPrefix || "export";
const sheetname = exportsFormData.sheetname ? exportsFormData.sheetname : "sheet";
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(sheetname);
const columns: Partial<ExcelJS.Column>[] = [];
cols.value.forEach((col) => {
if (col.label && col.prop && exportsFormData.fields.includes(col.prop)) {
columns.push({ header: col.label, key: col.prop });
}
});
worksheet.columns = columns;
if (exportsFormData.origin === ExportsOriginEnum.REMOTE) {
if (props.contentConfig.exportsAction) {
props.contentConfig.exportsAction(lastFormData).then((res) => {
worksheet.addRows(res);
workbook.xlsx
.writeBuffer()
.then((buffer) => {
saveXlsx(buffer, filename as string);
})
.catch((error) => console.log(error));
});
} else {
ElMessage.error("未配置exportsAction");
}
} else {
worksheet.addRows(
exportsFormData.origin === ExportsOriginEnum.SELECTED ? selectionData.value : pageData.value
);
workbook.xlsx
.writeBuffer()
.then((buffer) => {
saveXlsx(buffer, filename as string);
})
.catch((error) => console.log(error));
}
}
// 导入表单
let isFileImport = false;
const uploadRef = ref<UploadInstance>();
const importModalVisible = ref(false);
const importFormRef = ref<FormInstance>();
const importFormData = reactive<{
files: UploadUserFile[];
}>({
files: [],
});
const importFormRules: FormRules = {
files: [{ required: true, message: "请选择文件" }],
};
// 打开导入弹窗
function handleOpenImportModal(isFile: boolean = false) {
importModalVisible.value = true;
isFileImport = isFile;
}
// 覆盖前一个文件
function handleFileExceed(files: File[]) {
uploadRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadRef.value!.handleStart(file);
}
// 下载导入模板
function handleDownloadTemplate() {
const importTemplate = props.contentConfig.importTemplate;
if (typeof importTemplate === "string") {
window.open(importTemplate);
} else if (typeof importTemplate === "function") {
importTemplate().then((response) => {
const fileData = response.data;
const fileName = decodeURI(
response.headers["content-disposition"].split(";")[1].split("=")[1]
);
saveXlsx(fileData, fileName);
});
} else {
ElMessage.error("未配置importTemplate");
}
}
// 导入确认
const handleImportSubmit = useThrottleFn(() => {
importFormRef.value?.validate((valid: boolean) => {
if (valid) {
if (isFileImport) {
handleImport();
} else {
handleImports();
}
}
});
}, 3000);
// 关闭导入弹窗
function handleCloseImportModal() {
importModalVisible.value = false;
importFormRef.value?.resetFields();
nextTick(() => {
importFormRef.value?.clearValidate();
});
}
// 文件导入
function handleImport() {
const importAction = props.contentConfig.importAction;
if (importAction === undefined) {
ElMessage.error("未配置importAction");
return;
}
importAction(importFormData.files[0].raw as File).then(() => {
ElMessage.success("导入数据成功");
handleCloseImportModal();
handleRefresh(true);
});
}
// 导入
function handleImports() {
const importsAction = props.contentConfig.importsAction;
if (importsAction === undefined) {
ElMessage.error("未配置importsAction");
return;
}
// 获取选择的文件
const file = importFormData.files[0].raw as File;
// 创建Workbook实例
const workbook = new ExcelJS.Workbook();
// 使用FileReader对象来读取文件内容
const fileReader = new FileReader();
// 二进制字符串的形式加载文件
fileReader.readAsArrayBuffer(file);
fileReader.onload = (ev) => {
if (ev.target !== null && ev.target.result !== null) {
const result = ev.target.result as ArrayBuffer;
// 从 buffer中加载数据解析
workbook.xlsx
.load(result)
.then((workbook) => {
// 解析后的数据
const data = [];
// 获取第一个worksheet内容
const worksheet = workbook.getWorksheet(1);
if (worksheet) {
// 获取第一行的标题
const fields: any[] = [];
worksheet.getRow(1).eachCell((cell) => {
fields.push(cell.value);
});
// 遍历工作表的每一行(从第二行开始,因为第一行通常是标题行)
for (let rowNumber = 2; rowNumber <= worksheet.rowCount; rowNumber++) {
const rowData: IObject = {};
const row = worksheet.getRow(rowNumber);
// 遍历当前行的每个单元格
row.eachCell((cell, colNumber) => {
// 获取标题对应的键,并将当前单元格的值存储到相应的属性名中
rowData[fields[colNumber - 1]] = cell.value;
});
// 将当前行的数据对象添加到数组中
data.push(rowData);
}
}
if (data.length === 0) {
ElMessage.error("未解析到数据");
return;
}
importsAction(data).then(() => {
ElMessage.success("导入数据成功");
handleCloseImportModal();
handleRefresh(true);
});
})
.catch((error) => console.log(error));
} else {
ElMessage.error("读取文件失败");
}
};
}
// 操作栏
function handleToolbar(name: string) {
switch (name) {
case "refresh":
handleRefresh();
break;
case "export":
handleOpenExportsModal();
break;
case "imports":
handleOpenImportModal();
break;
case "add":
emit("addClick");
break;
case "delete":
handleDelete();
break;
case "patch":
emit("toolbarClick", name);
break;
case "import":
handleOpenImportModal(true);
break;
default:
emit("toolbarClick", name);
break;
}
}
// 操作列
function handleOperate(data: IOperateData) {
switch (data.name) {
case "delete":
if (props.contentConfig?.deleteAction) {
handleDelete(data.row[pk]);
} else {
emit("operateClick", data);
}
break;
default:
emit("operateClick", data);
break;
}
}
// 属性修改
function handleModify(field: string, value: boolean | string | number, row: Record<string, any>) {
if (props.contentConfig.modifyAction) {
props.contentConfig.modifyAction({
[pk]: row[pk],
field,
value,
});
} else {
ElMessage.error("未配置modifyAction");
}
}
// 分页切换
function handleSizeChange(value: number) {
pagination.pageSize = value;
handleRefresh();
}
function handleCurrentChange(value: number) {
pagination.currentPage = value;
handleRefresh();
}
// 远程数据筛选
let filterParams: IObject = {};
function handleFilterChange(newFilters: any) {
const filters: IObject = {};
for (const key in newFilters) {
const col = cols.value.find((col) => {
return col.columnKey === key || col["column-key"] === key;
});
if (col && col.filterJoin !== undefined) {
filters[key] = newFilters[key].join(col.filterJoin);
} else {
filters[key] = newFilters[key];
}
}
filterParams = { ...filterParams, ...filters };
emit("filterChange", filterParams);
}
// 获取筛选条件
function getFilterParams() {
return filterParams;
}
// 获取分页数据
let lastFormData = {};
function getIndexActionErrorMessage(err: unknown): string {
if (err && typeof err === "object" && "response" in err) {
const d = (err as { response?: { data?: { msg?: string; message?: string } } }).response?.data;
if (d?.msg) return String(d.msg);
if (d?.message) return String(d.message);
}
if (err instanceof Error && err.message) return err.message;
return "数据加载失败";
}
function fetchPageData(formData: IObject = {}, isRestart = false) {
loading.value = true;
// 上一次搜索条件
lastFormData = formData;
// 重置页码
if (isRestart) {
pagination.currentPage = 1;
}
props.contentConfig
.indexAction(
showPagination
? {
[request.page_no]: pagination.currentPage,
[request.page_size]: pagination.pageSize,
...formData,
}
: formData
)
.then((data) => {
if (showPagination) {
if (props.contentConfig.parseData) {
data = props.contentConfig.parseData(data);
}
pagination.total = data.total;
pageData.value = data.list;
} else {
pageData.value = data;
}
})
.catch((err: unknown) => {
console.error(err);
ElMessage.error(getIndexActionErrorMessage(err));
})
.finally(() => {
loading.value = false;
});
}
if (props.contentConfig.initialFetch !== false) {
fetchPageData();
}
// 导出Excel
function exportPageData(formData: IObject = {}) {
if (props.contentConfig.exportAction) {
props.contentConfig.exportAction(formData).then((response) => {
const fileData = response.data;
const fileName = decodeURI(
response.headers["content-disposition"].split(";")[1].split("=")[1]
);
saveXlsx(fileData, fileName);
});
} else {
ElMessage.error("未配置exportAction");
}
}
// 浏览器保存文件
function saveXlsx(fileData: any, fileName: string) {
const fileType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
const blob = new Blob([fileData], { type: fileType });
const downloadUrl = window.URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = downloadUrl;
downloadLink.download = fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
window.URL.revokeObjectURL(downloadUrl);
}
// 暴露的属性和方法
defineExpose({
fetchPageData,
exportPageData,
getFilterParams,
getSelectionData,
handleRefresh,
handleToolbar,
handleDelete,
pageData,
pagination,
tableRef,
});
</script>
<style lang="scss" scoped>
:deep(.curd-embed-dialog) {
padding-right: 0;
}
</style>
@@ -1,284 +0,0 @@
<template>
<div>
<!-- drawer -->
<template v-if="modalConfig.component === 'drawer'">
<el-drawer
v-model="modalVisible"
v-bind="{ destroyOnClose: true, ...modalConfig.drawer }"
@close="handleClose"
>
<el-form ref="formRef" v-bind="modalConfig.form" :model="formData" :rules="formRules">
<el-row :gutter="20">
<template v-for="item in formItems" :key="item.prop">
<el-col v-show="!item.hidden" v-bind="item.col">
<el-form-item :label="item.label" :prop="item.prop">
<!-- Label -->
<template #label>
<span>
{{ item?.label || "" }}
<el-tooltip v-if="item?.tips" v-bind="getTooltipProps(item.tips)">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
<span v-if="modalConfig.colon" class="ml-0.5">:</span>
</span>
</template>
<!-- components -->
<template v-if="item.type === 'custom'">
<slot
:name="item.slotName ?? item.prop"
:prop="item.prop"
:form-data="formData"
:attrs="item.attrs"
></slot>
</template>
<component
:is="componentMap.get(item.type)"
v-else
v-model.trim="formData[item.prop]"
v-bind="{ style: { width: '100%' }, ...item.attrs }"
>
<template v-if="['select', 'radio', 'checkbox'].includes(item.type)">
<component
:is="childrenMap.get(item.type)"
v-for="opt in item.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
></component>
</template>
<template v-if="item?.slotName && $slots[item.slotName]" #[item.slotName]>
<slot :name="item.slotName" />
</template>
</component>
</el-form-item>
</el-col>
</template>
</el-row>
</el-form>
<template #footer>
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
</template>
</el-drawer>
</template>
<!-- dialog -->
<template v-else>
<EnhancedDialog
v-model="modalVisible"
:title="String(modalConfig.dialog?.title ?? '')"
:width="modalConfig.dialog?.width ?? '600px'"
:draggable="modalConfig.dialog?.draggable !== false"
v-bind="dialogRestAttrs"
@close="handleClose"
>
<el-form ref="formRef" v-bind="modalConfig.form" :model="formData" :rules="formRules">
<el-scrollbar max-height="70vh" :view-style="{ overflowX: 'hidden' }">
<el-row :gutter="20">
<template v-for="item in formItems" :key="item.prop">
<el-col v-show="!item.hidden" v-bind="item.col">
<el-form-item :label="item.label" :prop="item.prop">
<template #label>
<span>
{{ item?.label || "" }}
<el-tooltip v-if="item?.tips" v-bind="getTooltipProps(item.tips)">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
<span v-if="modalConfig.colon" class="ml-0.5">:</span>
</span>
</template>
<template v-if="item.type === 'custom'">
<slot
:name="item.slotName ?? item.prop"
:prop="item.prop"
:form-data="formData"
:attrs="item.attrs"
></slot>
</template>
<component
:is="componentMap.get(item.type)"
v-else
v-model.trim="formData[item.prop]"
v-bind="{ style: { width: '100%' }, ...item.attrs }"
>
<template v-if="['select', 'radio', 'checkbox'].includes(item.type)">
<component
:is="childrenMap.get(item.type)"
v-for="opt in item.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
></component>
</template>
<template v-if="item?.slotName && $slots[item.slotName]" #[item.slotName]>
<slot :name="item.slotName" />
</template>
</component>
</el-form-item>
</el-col>
</template>
</el-row>
<slot name="bottom" :form-data="formData"></slot>
</el-scrollbar>
</el-form>
<template #footer>
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
</template>
</EnhancedDialog>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, markRaw, onMounted, reactive, ref } from "vue";
import { useThrottleFn } from "@vueuse/core";
import type { FormInstance, FormRules } from "element-plus";
import type { IComponentType, IModalConfig, IObject } from "./types";
import InputTag from "@/components/InputTag/index.vue";
import IconSelect from "@/components/IconSelect/index.vue";
import EnhancedDialog from "./EnhancedDialog.vue";
defineSlots<{ [key: string]: (_args: any) => any }>();
// 定义接收的属性
const props = defineProps<{ modalConfig: IModalConfig }>();
// 自定义事件
const emit = defineEmits<{ submitClick: []; customSubmit: [queryParams: IObject] }>();
// 组件映射表
const componentMap = new Map<IComponentType, any>([
// @ts-ignore
["input", markRaw(ElInput)], // @ts-ignore
["select", markRaw(ElSelect)], // @ts-ignore
["switch", markRaw(ElSwitch)], // @ts-ignore
["cascader", markRaw(ElCascader)], // @ts-ignore
["input-number", markRaw(ElInputNumber)], // @ts-ignore
["input-tag", markRaw(InputTag)], // @ts-ignore
["time-picker", markRaw(ElTimePicker)], // @ts-ignore
["time-select", markRaw(ElTimeSelect)], // @ts-ignore
["date-picker", markRaw(ElDatePicker)], // @ts-ignore
["tree-select", markRaw(ElTreeSelect)], // @ts-ignore"
["custom-tag", markRaw(InputTag)], // @ts-ignore
["text", markRaw(ElText)], // @ts-ignore
["radio", markRaw(ElRadioGroup)], // @ts-ignore"
["checkbox", markRaw(ElCheckboxGroup)], // @ts-ignore"
["icon-select", markRaw(IconSelect)], // @ts-ignore"
["custom", ""],
]);
const childrenMap = new Map<IComponentType, any>([
// @ts-ignore
["select", markRaw(ElOption)], // @ts-ignore
["radio", markRaw(ElRadio)], // @ts-ignore"
["checkbox", markRaw(ElCheckbox)],
]);
const pk = props.modalConfig.pk ?? "id"; // 主键名,用于表单数据处理
const dialogRestAttrs = computed(() => {
const d = props.modalConfig.dialog ?? {};
const { title: _t, width: _w, draggable: _d, ...rest } = d;
return { destroyOnClose: true, ...rest };
});
const modalVisible = ref(false); // 弹窗显示状态
const formRef = ref<FormInstance>(); // 表单实例
const formItems = reactive(props.modalConfig.formItems ?? []); // 表单配置项
const formData = reactive<IObject>({}); // 表单数据
const formRules: FormRules = {}; // 表单验证规则
const formDisable = ref(false); // 表单禁用状态
// 获取tooltip提示框属性
const getTooltipProps = (tips: string | IObject) => {
return typeof tips === "string" ? { content: tips } : tips;
};
// 隐藏弹窗
const handleClose = () => {
modalVisible.value = false;
formRef.value?.resetFields();
};
// 设置表单值
const setFormData = (data: IObject) => {
for (const key in formData) {
if (Object.prototype.hasOwnProperty.call(formData, key) && key in data) {
formData[key] = data[key];
}
}
if (Object.prototype.hasOwnProperty.call(data, pk)) {
formData[pk] = data[pk];
}
};
// 表单提交
const handleSubmit = useThrottleFn(() => {
formRef.value?.validate((valid: boolean) => {
if (!valid) return;
if (typeof props.modalConfig.beforeSubmit === "function") {
props.modalConfig.beforeSubmit(formData);
}
if (!props.modalConfig?.formAction) {
emit("customSubmit", formData);
handleClose();
return;
}
props.modalConfig.formAction(formData).then(() => {
if (props.modalConfig.component === "drawer") {
ElMessage.success(`${props.modalConfig.drawer?.title}成功`);
} else {
ElMessage.success(`${props.modalConfig.dialog?.title}成功`);
}
emit("submitClick");
handleClose();
});
});
}, 3000);
onMounted(() => {
formItems.forEach((item) => {
if (item.initFn) {
item.initFn(item);
}
formRules[item.prop] = item?.rules ?? [];
props.modalConfig.form = { labelWidth: "auto", ...props.modalConfig?.form };
if (["input-tag", "custom-tag", "cascader"].includes(item.type)) {
formData[item.prop] = Array.isArray(item.initialValue) ? item.initialValue : [];
} else if (item.type === "input-number") {
formData[item.prop] = item.initialValue ?? null;
} else {
formData[item.prop] = item.initialValue ?? "";
}
});
});
// 暴露的属性和方法
defineExpose({
setFormData,
// 展示/因此 modal
setModalVisible: (visible: boolean = true) => (modalVisible.value = visible),
// 获取表单数据
getFormData: (key: string) => formData[key] ?? formData,
// 设置表单项值
setFormItemData: (key: string, value: any) => (formData[key] = value),
// 禁用表单
handleDisabled: (disable: boolean) => {
formDisable.value = disable;
props.modalConfig.form = {
...props.modalConfig.form,
disabled: disable,
};
},
});
</script>
<style lang="scss" scoped>
:deep(.el-input-number .el-input__inner) {
text-align: left;
}
:deep(.el-input-number.is-without-controls .el-input__wrapper) {
padding-right: 11px !important ;
padding-left: 11px !important;
}
</style>
@@ -1,344 +0,0 @@
<template>
<div>
<el-card v-bind="cardAttrs">
<!-- 搜索表单区域 -->
<el-form
:key="'page-search-expand-' + isExpand"
ref="queryFormRef"
label-suffix=":"
v-bind="formAttrs"
:model="queryParams"
:class="isGrid"
@submit.prevent="handleQuery"
>
<template v-for="(item, index) in formItems" :key="item.prop">
<el-form-item
v-show="isExpand ? true : index < showNumber"
:label="item?.label"
:prop="item.prop"
>
<!-- Label -->
<template #label>
<span class="flex-y-center">
{{ item?.label || "" }}
<el-tooltip v-if="item?.tips" v-bind="getTooltipProps(item.tips)">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
<span v-if="searchConfig.colon" class="ml-0.5">:</span>
</span>
</template>
<ElCascader
v-if="item.type === 'cascader'"
v-model="queryParams[item.prop]"
v-bind="{ style: { width: '100%' }, ...item.attrs }"
v-on="item.events || {}"
/>
<component
:is="getCustomComponent(item.type) || componentMap.get(item.type)"
v-else
v-model="queryParams[item.prop]"
v-bind="{ style: { width: '100%' }, ...item.attrs }"
v-on="item.events || {}"
>
<template v-if="item.type === 'select'">
<template v-for="opt in item.options" :key="opt.value">
<el-option :label="opt.label" :value="opt.value" />
</template>
</template>
<!-- 自定义组件的插槽支持 -->
<template v-if="getCustomComponent(item.type) && item.slotName">
<template v-for="slot in Object.keys($slots)" :key="slot">
<slot :name="slot"></slot>
</template>
</template>
</component>
</el-form-item>
</template>
<el-form-item :class="{ 'col-[auto/-1] justify-self-end': searchConfig?.grid === 'right' }">
<!-- 自定义按钮组 -->
<template v-if="searchConfig?.customButtons && searchConfig.customButtons.length > 0">
<template v-for="button in searchConfig.customButtons" :key="button.key">
<el-button
v-if="!button.perm || hasPermission(button.perm)"
v-bind="button.attrs"
@click="handleCustomButtonClick(button)"
>
{{ button.text }}
</el-button>
</template>
</template>
<!-- 默认搜索/重置按钮 -->
<template v-else>
<!-- 搜索按钮 -->
<el-button
v-if="
!searchConfig?.showSearchButton ||
hasPermission(searchConfig.searchButtonPerm || [])
"
icon="search"
type="primary"
@click="handleQuery"
>
搜索
</el-button>
<!-- 重置按钮 -->
<el-button
v-if="
!searchConfig?.showResetButton || hasPermission(searchConfig.resetButtonPerm || [])
"
icon="refresh"
@click="handleReset"
>
重置
</el-button>
</template>
<!-- 展开/收起 -->
<template v-if="isExpandable && formItems.length > showNumber">
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
{{ isExpand ? "收起" : "展开" }}
<component :is="isExpand ? ArrowUp : ArrowDown" class="w-4 h-4 ml-2" />
</el-link>
</template>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import type { IObject, IForm, ISearchConfig, ISearchComponent } from "./types";
import { ArrowUp, ArrowDown, QuestionFilled } from "@element-plus/icons-vue";
import type { FormInstance } from "element-plus";
import InputTag from "@/components/InputTag/index.vue";
import { getCurrentInstance, nextTick, watch } from "vue";
import {
ElInput,
ElSelect,
ElCascader,
ElInputNumber,
ElDatePicker,
ElTimePicker,
ElTimeSelect,
ElTreeSelect,
ElInputTag,
ElRadioGroup,
ElCheckboxGroup,
ElSwitch,
ElRate,
ElSlider,
} from "element-plus";
// 定义接收的属性
const props = defineProps<{ searchConfig: ISearchConfig }>();
// 自定义事件
const emit = defineEmits<{
queryClick: [queryParams: IObject];
resetClick: [queryParams: IObject];
dateRangeChange: [prop: string, range: [Date, Date] | [string, string] | [null, null]];
customButtonClick: [buttonKey: string, queryParams: IObject];
}>();
// 时间范围处理相关
const dateRangeRefs = ref<Record<string, any>>({});
// 组件映射表
const componentMap = new Map<ISearchComponent, any>([
// @ts-ignore
["input", markRaw(ElInput)], // @ts-ignore
["select", markRaw(ElSelect)], // @ts-ignore
["cascader", markRaw(ElCascader)], // @ts-ignore
["input-number", markRaw(ElInputNumber)], // @ts-ignore
["date-picker", markRaw(ElDatePicker)], // @ts-ignore
["time-picker", markRaw(ElTimePicker)], // @ts-ignore
["time-select", markRaw(ElTimeSelect)], // @ts-ignore
["tree-select", markRaw(ElTreeSelect)], // @ts-ignore
["input-tag", markRaw(ElInputTag)], // @ts-ignore
["custom-tag", markRaw(InputTag)], // @ts-ignore
["radio", markRaw(ElRadioGroup)], // @ts-ignore
["checkbox", markRaw(ElCheckboxGroup)], // @ts-ignore
["switch", markRaw(ElSwitch)], // @ts-ignore
["rate", markRaw(ElRate)], // @ts-ignore
["slider", markRaw(ElSlider)], // @ts-ignore
]);
// 自定义组件映射(从searchConfig中获取)
const getCustomComponent = (componentName: string) => {
return props.searchConfig?.customComponents?.[componentName] || null;
};
// 存储表单实例
const queryFormRef = ref<FormInstance>();
// 存储查询参数
const queryParams = reactive<IObject>({});
// 响应式的formItems
const formItems = reactive(props.searchConfig?.formItems ?? []);
// 是否可展开/收缩
const isExpandable = ref(props.searchConfig?.isExpandable ?? true);
// 是否已展开
const isExpand = ref(false);
// 表单项展示数量,若可展开,超出展示数量的表单项隐藏
const showNumber = computed(() =>
isExpandable.value ? (props.searchConfig?.showNumber ?? 3) : formItems.length
);
// 卡片组件自定义属性(阴影、自定义边距样式等)
const cardAttrs = computed<IObject>(() => {
return {
class: "search-container",
shadow: "never",
style: { marginBottom: "0" },
...props.searchConfig?.cardAttrs,
};
});
// 表单组件自定义属性(label位置、宽度、对齐方式等)
const formAttrs = computed<IForm>(() => {
const custom = props.searchConfig?.form ?? {};
if (props.searchConfig?.grid) {
return { ...custom, inline: false };
}
return { inline: true, ...custom };
});
/** 默认 flex(scoped);grid 时见 .curd-page-search--grid */
const isGrid = computed(() =>
props.searchConfig?.grid ? "curd-page-search--grid" : "curd-page-search--flex"
);
/** 展开/收起后:表单已随 :key 重建,再触发 resize 让日期范围等组件量宽 */
watch(isExpand, () => {
nextTick(() => {
window.dispatchEvent(new Event("resize"));
});
});
// 获取tooltip提示框属性
const getTooltipProps = (tips: string | IObject) => {
return typeof tips === "string" ? { content: tips } : tips;
};
/** 查询前对 input 做 trim;去掉空条件,避免 GET 出现 name=&created_id= 等导致后端 Optional[int] 解析失败 */
function buildQueryPayload() {
const q = { ...queryParams };
for (const item of formItems) {
if (item.type === "input" && typeof q[item.prop] === "string") {
q[item.prop] = q[item.prop].trim();
}
}
const out: IObject = {};
for (const key of Object.keys(q)) {
const v = q[key];
if (v === "" || v === null || v === undefined) continue;
if (Array.isArray(v) && v.length === 0) continue;
out[key] = v;
}
return out;
}
// 查询/重置操作
const handleQuery = () => emit("queryClick", buildQueryPayload());
const handleReset = () => {
queryFormRef.value?.resetFields();
// 重置所有时间范围组件
Object.values(dateRangeRefs.value).forEach((ref) => {
if (ref && typeof ref.reset === "function") {
ref.reset();
}
});
emit("resetClick", buildQueryPayload());
};
// 处理时间范围变化
const handleDateRangeChange = (
prop: string,
range: [Date, Date] | [string, string] | [null, null]
) => {
emit("dateRangeChange", prop, range);
};
// 处理自定义按钮点击
const handleCustomButtonClick = (button: any) => {
// 如果配置了自定义处理器,优先使用
if (button.handler && typeof button.handler === "function") {
button.handler(queryParams, getCurrentInstance());
}
// 触发自定义事件
emit("customButtonClick", button.key, queryParams);
};
// 权限控制检查
const hasPermission = (perm: string | string[]): boolean => {
if (!perm || !props.searchConfig?.permPrefix) return true;
// 这里应该根据实际的权限系统来实现
// 暂时返回 true,实际项目中需要对接权限系统
return true;
};
onMounted(() => {
formItems.forEach((item) => {
if (item?.initFn) {
item.initFn(item);
}
if (getCustomComponent(item.type ?? "")) {
queryParams[item.prop] = item.initialValue ?? null;
} else if (["input-tag", "custom-tag", "cascader"].includes(item?.type ?? "")) {
queryParams[item.prop] = Array.isArray(item.initialValue) ? item.initialValue : [];
} else if (
item.type === "date-picker" &&
String(item.attrs?.type ?? "")
.toLowerCase()
.includes("range")
) {
queryParams[item.prop] = item.initialValue ?? [];
} else if (item.type === "select") {
queryParams[item.prop] = item.initialValue !== undefined ? item.initialValue : null;
} else if (item.type === "input-number") {
queryParams[item.prop] = item.initialValue ?? null;
} else {
queryParams[item.prop] = item.initialValue ?? "";
}
});
});
// 暴露的属性和方法
defineExpose({
// 获取分页数据(与「搜索」提交时一致,含 input trim)
getQueryParams: () => buildQueryPayload(),
});
</script>
<style lang="scss" scoped>
:deep(.el-input-number .el-input__inner) {
text-align: left;
}
.el-form-item {
margin-right: 0;
margin-bottom: 0;
}
/* 卡片参与 flex 布局链时允许高度随内容收回,避免展开后再收起仍「撑住」 */
.search-container :deep(.el-card__body) {
min-height: 0;
}
/* 默认行内搜索:明确左起排布,避免展开宽表单项后 flex 残留 justify/end 对齐 */
.curd-page-search--flex {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-content: flex-start;
align-items: center;
justify-content: flex-start;
width: 100%;
min-height: 0;
}
.curd-page-search--grid {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
align-items: center;
width: 100%;
}
</style>
@@ -1,90 +0,0 @@
import type { IObject, IComponentType, ISearchComponent } from "./types";
import { markRaw } from "vue";
import InputTag from "@/components/InputTag/index.vue";
import IconSelect from "@/components/IconSelect/index.vue";
import DatePicker from "@/components/DatePicker/index.vue";
/**
* 获取提示属性
* @param tips 提示内容
* @returns 提示属性对象
*/
export const getTooltipProps = (tips: string | IObject) => {
return typeof tips === "string" ? { content: tips } : tips;
};
/**
* 模态框组件映射表
*/
export const modalComponentMap = new Map<IComponentType, any>([
// @ts-ignore
["input", markRaw(ElInput)],
// @ts-ignore
["select", markRaw(ElSelect)],
// @ts-ignore
["switch", markRaw(ElSwitch)],
// @ts-ignore
["cascader", markRaw(ElCascader)],
// @ts-ignore
["input-number", markRaw(ElInputNumber)],
// @ts-ignore
["input-tag", markRaw(InputTag)],
// @ts-ignore
["time-picker", markRaw(ElTimePicker)],
// @ts-ignore
["time-select", markRaw(ElTimeSelect)],
// @ts-ignore
["date-picker", markRaw(ElDatePicker)],
// @ts-ignore
["tree-select", markRaw(ElTreeSelect)],
// @ts-ignore
["custom-tag", markRaw(InputTag)],
// @ts-ignore
["text", markRaw(ElText)],
// @ts-ignore
["radio", markRaw(ElRadioGroup)],
// @ts-ignore
["checkbox", markRaw(ElCheckboxGroup)],
// @ts-ignore
["icon-select", markRaw(IconSelect)],
// @ts-ignore
["custom", ""],
]);
/**
* 搜索组件映射表
*/
export const searchComponentMap = new Map<ISearchComponent, any>([
// @ts-ignore
["input", markRaw(ElInput)],
// @ts-ignore
["select", markRaw(ElSelect)],
// @ts-ignore
["cascader", markRaw(ElCascader)],
// @ts-ignore
["input-number", markRaw(ElInputNumber)],
// @ts-ignore
["date-picker", markRaw(DatePicker)],
// @ts-ignore
["time-picker", markRaw(ElTimePicker)],
// @ts-ignore
["time-select", markRaw(ElTimeSelect)],
// @ts-ignore
["tree-select", markRaw(ElTreeSelect)],
// @ts-ignore
["input-tag", markRaw(ElInputTag)],
// @ts-ignore
["custom-tag", markRaw(InputTag)],
]);
/**
* 子组件映射表
*/
export const childrenMap = new Map<IComponentType, any>([
// @ts-ignore
["select", markRaw(ElOption)],
// @ts-ignore
["radio", markRaw(ElRadio)],
// @ts-ignore
["checkbox", markRaw(ElCheckbox)],
]);
@@ -1,53 +0,0 @@
import { ElMessage } from "element-plus";
import { ref } from "vue";
import type { IObject, PageContentInstance, PageSearchInstance } from "./types";
/**
* 仅列表查询区 + 表格区时复用:searchRef / contentRef 与查询、重置拉数逻辑。
* 合并 `PageSearch` 查询参数与 `PageContent.getFilterParams()`(表头筛选等),与 usePage 中同名逻辑一致。
* 业务页可替换手写 `handleQueryClick` / `handleResetClick` + 双 ref,参见 `module_example/demo`。
*/
export function useCrudList() {
const searchRef = ref<PageSearchInstance>();
const contentRef = ref<PageContentInstance>();
function handleQueryClick(queryParams: IObject) {
try {
const filterParams = contentRef.value?.getFilterParams() || {};
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
} catch (error) {
console.error("查询数据失败:", error);
ElMessage.error("查询数据失败: " + (error instanceof Error ? error.message : String(error)));
}
}
function handleResetClick(queryParams: IObject) {
try {
const filterParams = contentRef.value?.getFilterParams() || {};
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
} catch (error) {
console.error("重置数据失败:", error);
ElMessage.error("重置数据失败: " + (error instanceof Error ? error.message : String(error)));
}
}
/** 弹窗提交后等与「查询」同参刷新:合并搜索条件 + 表头筛选 */
function refreshList() {
try {
const q = searchRef.value?.getQueryParams() ?? {};
const f = contentRef.value?.getFilterParams() ?? {};
contentRef.value?.fetchPageData({ ...q, ...f }, true);
} catch (error) {
console.error("刷新列表失败:", error);
ElMessage.error("刷新列表失败: " + (error instanceof Error ? error.message : String(error)));
}
}
return {
searchRef,
contentRef,
handleQueryClick,
handleResetClick,
refreshList,
};
}
-184
View File
@@ -1,184 +0,0 @@
import { ref, Ref } from "vue";
import type { IObject, PageContentInstance, PageModalInstance } from "./types";
import { useCrudList } from "./useCrudList";
/**
* CURD页面组合式函数
* @returns 包含各种引用和处理函数的对象
*/
function usePage() {
const { searchRef, contentRef, handleQueryClick, handleResetClick } = useCrudList();
const addModalRef = ref<PageModalInstance>();
const editModalRef = ref<PageModalInstance>();
const viewModalRef = ref<PageModalInstance>();
/**
* 处理新增点击事件
* @param RefImpl 可选的模态框引用
*/
function handleAddClick(RefImpl?: Ref<PageModalInstance>) {
try {
if (RefImpl) {
RefImpl.value?.setModalVisible();
RefImpl.value?.handleDisabled(false);
} else {
addModalRef.value?.setModalVisible();
addModalRef.value?.handleDisabled(false);
}
} catch (error) {
console.error("打开新增模态框失败:", error);
ElMessage.error(
"打开新增模态框失败: " + (error instanceof Error ? error.message : String(error))
);
}
}
/**
* 处理编辑点击事件
* @param row 行数据
* @param callback 回调函数
* @param RefImpl 可选的模态框引用
*/
async function handleEditClick(
row: IObject,
callback?: (result?: IObject) => IObject | Promise<IObject>,
RefImpl?: Ref<PageModalInstance>
) {
try {
if (RefImpl) {
RefImpl.value?.setModalVisible();
RefImpl.value?.handleDisabled(false);
const from = await (callback?.(row) ?? Promise.resolve(row));
RefImpl.value?.setFormData(from ? from : row);
} else {
editModalRef.value?.setModalVisible();
editModalRef.value?.handleDisabled(false);
const from = await (callback?.(row) ?? Promise.resolve(row));
editModalRef.value?.setFormData(from ? from : row);
}
} catch (error) {
console.error("打开编辑模态框失败:", error);
ElMessage.error(
"打开编辑模态框失败: " + (error instanceof Error ? error.message : String(error))
);
}
}
/**
* 处理查看点击事件
* @param row 行数据
* @param callback 回调函数
* @param RefImpl 可选的模态框引用
*/
async function handleViewClick(
row: IObject,
callback?: (result?: IObject) => IObject | Promise<IObject>,
RefImpl?: Ref<PageModalInstance>
) {
try {
if (RefImpl) {
RefImpl.value?.setModalVisible();
RefImpl.value?.handleDisabled(true);
const from = await (callback?.(row) ?? Promise.resolve(row));
RefImpl.value?.setFormData(from ? from : row);
} else {
viewModalRef.value?.setModalVisible();
viewModalRef.value?.handleDisabled(true);
const from = await (callback?.(row) ?? Promise.resolve(row));
viewModalRef.value?.setFormData(from ? from : row);
}
} catch (error) {
console.error("打开查看模态框失败:", error);
ElMessage.error(
"打开查看模态框失败: " + (error instanceof Error ? error.message : String(error))
);
}
}
/**
* 处理表单提交点击事件
*/
function handleSubmitClick() {
try {
//根据检索条件刷新列表数据
const queryParams = searchRef.value?.getQueryParams() || {};
contentRef.value?.fetchPageData(queryParams, true);
} catch (error) {
console.error("提交表单失败:", error);
ElMessage.error("提交表单失败: " + (error instanceof Error ? error.message : String(error)));
}
}
/**
* 处理导出点击事件
*/
function handleExportClick() {
try {
// 根据检索条件导出数据
const queryParams = searchRef.value?.getQueryParams() || {};
contentRef.value?.exportPageData(queryParams);
} catch (error) {
console.error("导出数据失败:", error);
ElMessage.error("导出数据失败: " + (error instanceof Error ? error.message : String(error)));
}
}
/**
* 处理筛选改变事件
* @param filterParams 筛选参数
*/
function handleFilterChange(filterParams: IObject) {
try {
const queryParams = searchRef.value?.getQueryParams() || {};
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
} catch (error) {
console.error("筛选数据失败:", error);
ElMessage.error("筛选数据失败: " + (error instanceof Error ? error.message : String(error)));
}
}
/**
* 处理更多操作事件
* @param name 操作名称
* @param selectedRows 选中的行数据
* @param callback 回调函数
*/
async function handleMoreOperation(
name: string,
selectedRows: IObject[],
callback?: (name: string, selectedRows: IObject[]) => Promise<void>
) {
try {
if (callback) {
await callback(name, selectedRows);
}
// 默认刷新数据
handleSubmitClick();
} catch (error) {
console.error("处理更多操作失败:", error);
ElMessage.error(
"处理更多操作失败: " + (error instanceof Error ? error.message : String(error))
);
}
}
return {
searchRef,
contentRef,
addModalRef,
editModalRef,
viewModalRef,
handleQueryClick,
handleResetClick,
handleAddClick,
handleEditClick,
handleViewClick,
handleSubmitClick,
handleExportClick,
handleFilterChange,
handleMoreOperation,
};
}
export default usePage;
export { useCrudList } from "./useCrudList";
@@ -0,0 +1,248 @@
<template>
<div class="home-calendar">
<ElCalendar v-model="currentDate">
<template #date-cell="{ data }">
<div
class="home-calendar__cell relative flex h-full min-h-14 max-h-20 flex-col overflow-hidden p-0.5 c-p"
:class="{ 'is-selected': data.isSelected }"
@click="handleCellClick(data.day)"
>
<p class="absolute right-0.5 top-0.5 text-[11px] leading-none opacity-80">
{{ formatDate(data.day) }}
</p>
<div class="mt-4 flex max-h-12 w-full flex-col gap-px overflow-y-auto pr-0.5">
<div
v-for="event in getEvents(data.day)"
:key="`${event.date}-${event.content}`"
@click.stop="handleEventClick(event)"
>
<div
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap rounded px-1 py-px text-[10px] leading-snug font-medium hover:opacity-80"
:class="[event.bgClass, event.textClass]"
>
{{ event.content }}
</div>
</div>
</div>
</div>
</template>
</ElCalendar>
<ElDialog v-model="dialogVisible" :title="dialogTitle" width="600px" @closed="resetForm">
<ElForm :model="eventForm" label-width="80px">
<ElFormItem label="活动标题" required>
<ElInput v-model="eventForm.content" placeholder="请输入活动标题" />
</ElFormItem>
<ElFormItem label="事件颜色">
<ElRadioGroup v-model="eventForm.type">
<ElRadio v-for="et in eventTypes" :key="et.value" :value="et.value">
{{ et.label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="开始日期" required>
<ElDatePicker
v-model="eventForm.date"
style="width: 100%"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</ElFormItem>
<ElFormItem label="结束日期">
<ElDatePicker
v-model="eventForm.endDate"
style="width: 100%"
type="date"
placeholder="选择结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:min-date="eventForm.date"
/>
</ElFormItem>
</ElForm>
<template #footer>
<span class="dialog-footer">
<ElButton v-if="isEditing" type="danger" @click="handleDeleteEvent">删除</ElButton>
<ElButton type="primary" @click="handleSaveEvent">
{{ isEditing ? "更新" : "添加" }}
</ElButton>
</span>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { dayjs } from "element-plus";
defineOptions({ name: "HomeCalendar" });
interface CalendarEvent {
date: string;
endDate?: string;
content: string;
type?: "primary" | "success" | "warning" | "danger";
bgClass?: string;
textClass?: string;
}
const eventTypes = [
{ label: "基本", value: "primary" },
{ label: "成功", value: "success" },
{ label: "警告", value: "warning" },
{ label: "危险", value: "danger" },
] as const;
const d = (dayOfMonth: number) => dayjs().date(dayOfMonth).format("YYYY-MM-DD");
const currentDate = ref(new Date());
const dialogVisible = ref(false);
const dialogTitle = ref("添加事件");
const editingEventIndex = ref<number>(-1);
const events = ref<CalendarEvent[]>([
{ date: d(3), content: "产品需求评审", type: "primary" },
{ date: d(5), endDate: d(7), content: "项目周报会议(跨日期)", type: "primary" },
{ date: d(10), content: "瑜伽课程", type: "success" },
{ date: d(15), content: "团队建设活动", type: "primary" },
{ date: d(20), content: "代码评审", type: "danger" },
{ date: d(20), content: "团队午餐", type: "primary" },
{ date: d(20), content: "项目进度汇报", type: "warning" },
{ date: d(Math.min(28, dayjs().daysInMonth())), content: "月度总结会", type: "warning" },
]);
const eventForm = ref<CalendarEvent>({
date: "",
endDate: "",
content: "",
type: "primary",
});
const isEditing = computed(() => editingEventIndex.value >= 0);
const formatDate = (date: string) => date.split("-")[2];
const getEventClasses = (type: CalendarEvent["type"] = "primary") => {
const classMap = {
primary: { bgClass: "bg-theme/12", textClass: "text-theme" },
success: { bgClass: "bg-success/12", textClass: "text-success" },
warning: { bgClass: "bg-warning/12", textClass: "text-warning" },
danger: { bgClass: "bg-danger/12", textClass: "text-danger" },
};
return classMap[type];
};
const getEvents = (day: string) => {
return events.value
.filter((event) => {
const eventDate = new Date(event.date);
const cellDate = new Date(day);
const endDate = event.endDate ? new Date(event.endDate) : new Date(event.date);
return cellDate >= eventDate && cellDate <= endDate;
})
.map((event) => {
const { bgClass, textClass } = getEventClasses(event.type);
return { ...event, bgClass, textClass };
});
};
const resetForm = () => {
eventForm.value = {
date: "",
endDate: "",
content: "",
type: "primary",
};
editingEventIndex.value = -1;
};
const handleCellClick = (day: string) => {
dialogTitle.value = "添加事件";
eventForm.value = {
date: day,
content: "",
type: "primary",
};
editingEventIndex.value = -1;
dialogVisible.value = true;
};
const handleEventClick = (event: CalendarEvent) => {
dialogTitle.value = "编辑事件";
eventForm.value = { ...event };
editingEventIndex.value = events.value.findIndex(
(e) => e.date === event.date && e.content === event.content
);
dialogVisible.value = true;
};
const handleSaveEvent = () => {
if (!eventForm.value.content || !eventForm.value.date) return;
if (isEditing.value) {
events.value[editingEventIndex.value] = { ...eventForm.value };
} else {
events.value.push({ ...eventForm.value });
}
dialogVisible.value = false;
resetForm();
};
const handleDeleteEvent = () => {
if (isEditing.value) {
events.value.splice(editingEventIndex.value, 1);
dialogVisible.value = false;
resetForm();
}
};
</script>
<style scoped>
.home-calendar {
width: 100%;
min-width: 0;
}
:deep(.el-calendar__header) {
padding: 6px 4px;
border-bottom: none;
}
:deep(.el-calendar__title) {
font-size: 14px;
font-weight: 600;
}
:deep(.el-calendar__header .el-button) {
padding: 4px 8px;
}
:deep(.el-calendar__body) {
padding: 2px 0 4px;
}
:deep(.el-calendar-table thead th) {
padding: 4px 0;
font-size: 11px;
font-weight: 500;
}
:deep(.is-selected) {
background-color: var(--el-color-warning-light-9) !important;
}
:deep(.el-calendar-day) {
height: auto;
min-height: 3rem;
padding: 0;
}
:deep(.el-calendar-day:hover) {
background-color: transparent !important;
}
:deep(.el-dialog__body) {
padding-top: 20px;
}
</style>
@@ -14,6 +14,7 @@ defineProps({
<style scoped lang="scss">
.el {
transition: 0.3s var(--el-transition-function-ease-in-out-bezier);
&:hover {
background-color: var(--el-fill-color);
}
@@ -1,10 +1,10 @@
<!-- 复制组件 -->
<template>
<el-button link :style="style" @click="handleClipboard">
<ElButton link :style="style" @click="handleClipboard">
<slot>
<el-icon><DocumentCopy color="var(--el-color-primary)" /></el-icon>
<ElIcon><DocumentCopy color="var(--el-color-primary)" /></ElIcon>
</slot>
</el-button>
</ElButton>
</template>
<script setup lang="ts">
@@ -0,0 +1,343 @@
<!-- 基础横幅组件 -->
<template>
<div
class="art-card basic-banner"
:class="[{ 'has-decoration': decoration }, boxStyle]"
:style="{ height }"
@click="emit('click')"
>
<!-- 流星效果 -->
<div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors">
<span
v-for="(meteor, index) in meteors"
:key="index"
class="meteor"
:style="{
top: '-60px',
left: `${meteor.x}%`,
animationDuration: `${meteor.speed}s`,
animationDelay: `${meteor.delay}s`,
}"
></span>
</div>
<div class="basic-banner__content">
<!-- title slot -->
<slot name="title">
<p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{ title }}</p>
</slot>
<!-- subtitle slot -->
<slot name="subtitle">
<p v-if="subtitle" class="basic-banner__subtitle" :style="{ color: subtitleColor }">
{{ subtitle }}
</p>
</slot>
<!-- button slot -->
<slot name="button">
<div
v-if="buttonConfig?.show"
class="basic-banner__button"
:style="{
backgroundColor: buttonColor,
color: buttonTextColor,
borderRadius: buttonRadius,
}"
@click.stop="emit('buttonClick')"
>
{{ buttonConfig?.text }}
</div>
</slot>
<!-- default slot -->
<slot></slot>
<!-- background image -->
<img
v-if="imageConfig.src"
class="basic-banner__background-image"
:src="imageConfig.src"
:style="{ width: imageConfig.width, bottom: imageConfig.bottom, right: imageConfig.right }"
loading="lazy"
alt="背景图片"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import { useSettingsStore } from "@stores/modules/setting.store";
const settingStore = useSettingsStore();
const { isDark } = storeToRefs(settingStore);
defineOptions({ name: "ArtBasicBanner" });
// 流星对象接口定义
interface Meteor {
/** 流星的水平位置(百分比) */
x: number;
/** 流星划过的速度 */
speed: number;
/** 流星出现的延迟时间 */
delay: number;
}
// 按钮配置接口定义
interface ButtonConfig {
/** 是否启用按钮 */
show: boolean;
/** 按钮文本 */
text: string;
/** 按钮背景色 */
color?: string;
/** 按钮文字颜色 */
textColor?: string;
/** 按钮圆角大小 */
radius?: string;
}
// 流星效果配置接口定义
interface MeteorConfig {
/** 是否启用流星效果 */
enabled: boolean;
/** 流星数量 */
count?: number;
}
// 背景图片配置接口定义
interface ImageConfig {
/** 图片源地址 */
src: string;
/** 图片宽度 */
width?: string;
/** 距底部距离 */
bottom?: string;
/** 距右侧距离 */
right?: string; // 距右侧距离
}
// 组件属性接口定义
interface Props {
/** 横幅高度 */
height?: string;
/** 标题文本 */
title?: string;
/** 副标题文本 */
subtitle?: string;
/** 盒子样式 */
boxStyle?: string;
/** 是否显示装饰效果 */
decoration?: boolean;
/** 按钮配置 */
buttonConfig?: ButtonConfig;
/** 流星配置 */
meteorConfig?: MeteorConfig;
/** 图片配置 */
imageConfig?: ImageConfig;
/** 标题颜色 */
titleColor?: string;
/** 副标题颜色 */
subtitleColor?: string;
}
// 组件属性默认值设置
const props = withDefaults(defineProps<Props>(), {
height: "11rem",
titleColor: "white",
subtitleColor: "white",
boxStyle: "!bg-theme/60",
decoration: true,
buttonConfig: () => ({
show: true,
text: "查看",
color: "#fff",
textColor: "#333",
radius: "6px",
}),
meteorConfig: () => ({ enabled: false, count: 10 }),
imageConfig: () => ({ src: "", width: "12rem", bottom: "-3rem", right: "0" }),
});
// 定义组件事件
const emit = defineEmits<{
(e: "click"): void; // 整体点击事件
(e: "buttonClick"): void; // 按钮点击事件
}>();
// 计算按钮样式属性
const buttonColor = computed(() => props.buttonConfig?.color ?? "#fff");
const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? "#333");
const buttonRadius = computed(() => props.buttonConfig?.radius ?? "6px");
// 流星数据初始化
const meteors = ref<Meteor[]>([]);
onMounted(() => {
if (props.meteorConfig?.enabled) {
meteors.value = generateMeteors(props.meteorConfig?.count ?? 10);
}
});
/**
* 生成流星数据数组
* @param count 流星数量
* @returns 流星数据数组
*/
function generateMeteors(count: number): Meteor[] {
// 计算每个流星的区域宽度
const segmentWidth = 100 / count;
return Array.from({ length: count }, (_, index) => {
// 计算流星起始位置
const segmentStart = index * segmentWidth;
// 在区域内随机生成x坐标
const x = segmentStart + Math.random() * segmentWidth;
// 随机决定流星速度快慢
const isSlow = Math.random() > 0.5;
return {
x,
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2,
delay: Math.random() * 5,
};
});
}
</script>
<style lang="scss" scoped>
.basic-banner {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 2rem;
overflow: hidden;
color: white;
border-radius: calc(var(--custom-radius) + 2px) !important;
&__content {
position: relative;
z-index: 1;
}
&__title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 600;
}
&__subtitle {
position: relative;
z-index: 10;
margin: 0 0 1.5rem;
font-size: 0.9rem;
opacity: 0.9;
}
&__button {
box-sizing: border-box;
display: inline-block;
min-width: 80px;
height: var(--el-component-custom-height);
padding: 0 12px;
font-size: 14px;
line-height: var(--el-component-custom-height);
text-align: center;
cursor: pointer;
user-select: none;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
}
&__background-image {
position: absolute;
right: 0;
bottom: -3rem;
z-index: 0;
width: 12rem;
}
&.has-decoration::after {
position: absolute;
right: -10%;
bottom: -20%;
width: 60%;
height: 140%;
content: "";
background: rgb(255 255 255 / 10%);
border-radius: 30%;
transform: rotate(-20deg);
}
&__meteors {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
.meteor {
position: absolute;
width: 2px;
height: 60px;
background: linear-gradient(
to top,
rgb(255 255 255 / 40%),
rgb(255 255 255 / 10%),
transparent
);
opacity: 0;
transform-origin: top left;
animation-name: meteor-fall;
animation-timing-function: linear;
animation-iteration-count: infinite;
&::before {
position: absolute;
right: 0;
bottom: 0;
width: 2px;
height: 2px;
content: "";
background: rgb(255 255 255 / 50%);
}
}
}
}
@keyframes meteor-fall {
0% {
opacity: 1;
transform: translate(0, -60px) rotate(-45deg);
}
100% {
opacity: 0;
transform: translate(400px, 340px) rotate(-45deg);
}
}
@media (width <= 640px) {
.basic-banner {
box-sizing: border-box;
justify-content: flex-start;
padding: 16px;
&__title {
font-size: 1.4rem;
}
&__background-image {
display: none;
}
&.has-decoration::after {
display: none;
}
}
}
</style>
@@ -0,0 +1,114 @@
<!-- 卡片横幅组件 -->
<template>
<div class="art-card-sm flex-c flex-col pb-6" :style="{ height: height }">
<div class="flex-c flex-col gap-4 text-center">
<div class="w-45">
<img :src="image" :alt="title" class="w-full h-full object-contain" />
</div>
<div class="box-border px-4">
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
<p class="m-0 text-sm text-g-600">{{ description }}</p>
</div>
<div class="flex-c gap-3">
<div
v-if="cancelButton?.show"
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
:style="{
backgroundColor: cancelButton?.color,
color: cancelButton?.textColor,
}"
@click="handleCancel"
>
{{ cancelButton?.text }}
</div>
<div
v-if="button?.show"
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
:style="{ backgroundColor: button?.color, color: button?.textColor }"
@click="handleClick"
>
{{ button?.text }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// 导入默认图标
import defaultIcon from "@imgs/3d/icon1.webp";
defineOptions({ name: "ArtCardBanner" });
// 定义卡片横幅组件的属性接口
interface CardBannerProps {
/** 高度 */
height?: string;
/** 图片路径 */
image?: string;
/** 标题文本 */
title: string;
/** 描述文本 */
description: string;
/** 主按钮配置 */
button?: {
/** 是否显示 */
show?: boolean;
/** 按钮文本 */
text?: string;
/** 背景颜色 */
color?: string;
/** 文字颜色 */
textColor?: string;
};
/** 取消按钮配置 */
cancelButton?: {
/** 是否显示 */
show?: boolean;
/** 按钮文本 */
text?: string;
/** 背景颜色 */
color?: string;
/** 文字颜色 */
textColor?: string;
};
}
// 定义组件属性默认值
withDefaults(defineProps<CardBannerProps>(), {
height: "24rem",
image: defaultIcon,
title: "",
description: "",
// 主按钮默认配置
button: () => ({
show: true,
text: "查看详情",
color: "var(--theme-color)",
textColor: "#fff",
}),
// 取消按钮默认配置
cancelButton: () => ({
show: false,
text: "取消",
color: "#f5f5f5",
textColor: "#666",
}),
});
// 定义组件事件
const emit = defineEmits<{
(e: "click"): void; // 主按钮点击事件
(e: "cancel"): void; // 取消按钮点击事件
}>();
// 主按钮点击处理函数
const handleClick = () => {
emit("click");
};
// 取消按钮点击处理函数
const handleCancel = () => {
emit("cancel");
};
</script>
@@ -0,0 +1,41 @@
<!-- 返回顶部按钮 -->
<template>
<Transition
enter-active-class="tad-300 ease-out"
leave-active-class="tad-200 ease-in"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<div
v-show="showButton"
class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200"
@click="scrollToTop"
>
<ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
</div>
</Transition>
</template>
<script setup lang="ts">
import { useCommon } from "@/hooks/core/useCommon";
defineOptions({ name: "ArtBackToTop" });
const { scrollToTop } = useCommon();
const showButton = ref(false);
const scrollThreshold = 300;
onMounted(() => {
const scrollContainer =
document.getElementById("app-scroll-main") ?? document.getElementById("app-main");
if (scrollContainer) {
const { y } = useScroll(scrollContainer);
watch(y, (newY: number) => {
showButton.value = newY > scrollThreshold;
});
}
});
</script>
@@ -0,0 +1,53 @@
<!-- 系统 logo:优先使用接口 sys_web_logo,缺省用内置图 -->
<template>
<div class="flex-cc">
<img
:style="logoStyle"
:src="resolvedSrc"
alt="logo"
class="h-full w-full object-contain"
@error="onImgError"
/>
</div>
</template>
<script setup lang="ts">
import defaultLogoUrl from "@icons/vite.svg";
defineOptions({ name: "ArtLogo" });
interface Props {
/** logo 大小 */
size?: number | string;
/** 自定义地址(如配置接口 sys_web_logo);不传则用默认资源 */
src?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: 36,
src: undefined,
});
const fallbackTriggered = ref(false);
const resolvedSrc = computed(() => {
if (fallbackTriggered.value) return defaultLogoUrl;
const custom = props.src?.trim();
return custom || defaultLogoUrl;
});
function onImgError() {
if (!fallbackTriggered.value) {
fallbackTriggered.value = true;
}
}
const logoStyle = computed(() => ({ width: `${props.size}px`, height: `${props.size}px` }));
watch(
() => props.src,
() => {
fallbackTriggered.value = false;
}
);
</script>
@@ -0,0 +1,138 @@
<!-- 图标组件 -->
<template>
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" class="art-svg-icon inline" />
</template>
<script setup lang="ts">
import { Icon } from "@iconify/vue";
defineOptions({ name: "ArtSvgIcon", inheritAttrs: false });
interface Props {
/** Iconify icon name */
icon?: string;
}
defineProps<Props>();
const attrs = useAttrs();
const bindAttrs = computed<{ class: string; style: string }>(() => ({
class: (attrs.class as string) || "",
style: (attrs.style as string) || "",
}));
</script>
<!-- <template>
<div class="space-y-5 mb-5">
<div class="text-2xl font-medium mt-5 max-sm:text-2xl max-sm:mt-3">图标</div>
<div class="text-g-800">
v.3.0 版本图标库升级为 iconify,可在
<a href="https://icones.js.org/" target="_blank" class="text-theme hover:underline">
Iconify
</a>
中查找,支持多种图标库,如 Remix Icon, Solar, Tabler Icons 等。
</div>
<div class="text-g-800">
为确保系统图标风格统一,项目全部采用 Remix Icon 图标库,可在
<a
href="https://icones.js.org/collection/ri"
target="_blank"
class="text-theme hover:underline"
>
Iconify
</a>
或
<a href="https://remixicon.com/" target="_blank" class="text-theme hover:underline">
Remix Icon 官网
</a>
搜索使用。
</div> -->
<!-- Iconify 图标 -->
<!-- <div class="art-card-sm p-5">
<div class="text-lg font-semibold mb-4">Iconify</div>
<div class="flex items-center gap-6">
<ArtSvgIcon icon="ri:github-fill" class="text-2xl" />
<ArtSvgIcon icon="ri:copilot-line" class="text-2xl text-theme" />
<ArtSvgIcon icon="ri:edge-line" class="text-2xl text-secondary" />
<ArtSvgIcon icon="ri:planet-line" class="text-2xl text-warning" />
<ArtSvgIcon icon="ri:windows-line" class="text-2xl text-info" />
<ArtSvgIcon icon="ri:thumb-up-line" class="text-2xl text-danger" />
<ArtSvgIcon icon="ri:gift-2-line" class="text-2xl text-success" />
<ArtSvgIcon icon="ri:apple-line" class="text-2xl text-secondary" />
</div>
</div> -->
<!-- Svg Icons -->
<!-- <div class="art-card-sm p-5">
<div class="text-lg font-semibold mb-4">Svg Icons</div>
<div class="flex items-center gap-6">
<ArtSvgIcon icon="svg-spinners:3-dots-fade" class="text-2xl text-red-400" />
<ArtSvgIcon icon="svg-spinners:3-dots-bounce" class="text-2xl text-blue-400" />
<ArtSvgIcon icon="svg-spinners:3-dots-move" class="text-2xl text-orange-400" />
<ArtSvgIcon icon="svg-spinners:3-dots-rotate" class="text-2xl text-purple-400" />
<ArtSvgIcon icon="svg-spinners:blocks-shuffle-2" class="text-2xl text-pink-300" />
<ArtSvgIcon icon="svg-spinners:clock" class="text-2xl text-yellow-500" />
<ArtSvgIcon icon="svg-spinners:tadpole" class="text-2xl text-orange-500" />
<ArtSvgIcon icon="svg-spinners:blocks-wave" class="text-2xl text-blue-500" />
</div>
</div> -->
<!-- Svg Icons -->
<!-- <div class="art-card-sm p-5">
<div class="text-lg font-semibold mb-4">Material Line Icons</div>
<div class="flex items-center gap-6">
<ArtSvgIcon icon="line-md:phone-call-twotone-loop" class="text-2xl text-blue-500" />
<ArtSvgIcon icon="line-md:switch-off" class="text-2xl text-green-500" />
<ArtSvgIcon icon="line-md:sun-rising-filled-loop" class="text-2xl text-yellow-400" />
<ArtSvgIcon icon="line-md:volume-high-filled" class="text-2xl text-purple-500" />
<ArtSvgIcon icon="line-md:github-twotone" class="text-2xl text-gray-700" />
<ArtSvgIcon icon="line-md:telegram" class="text-2xl text-sky-500" />
<ArtSvgIcon icon="line-md:reddit-loop" class="text-2xl text-orange-400" />
<ArtSvgIcon
icon="line-md:coffee-half-empty-filled-loop"
class="text-2xl text-emerald-500"
/>
</div>
</div> -->
<!-- 使用示例 -->
<!-- <div class="art-card-sm p-5">
<div class="text-lg font-semibold mb-4">使用示例</div>
<div class="space-y-4">
<div>
<div class="text-sm text-g-600 mb-2">基础使用</div>
<div class="bg-g-200 dark:bg-g-300/30 p-4 rounded font-mono text-sm text-g-800">
&lt;ArtSvgIcon icon="ri:home-line" /&gt;
</div>
</div>
<div>
<div class="text-sm text-g-600 mb-2">自定义大小</div>
<div class="bg-g-200 dark:bg-g-300/30 p-4 rounded font-mono text-sm text-g-800">
&lt;ArtSvgIcon icon="ri:user-line" class="text-2xl" /&gt;
</div>
</div>
<div>
<div class="text-sm text-g-600 mb-2">自定义颜色</div>
<div class="bg-g-200 dark:bg-g-300/30 p-4 rounded font-mono text-sm text-g-800">
&lt;ArtSvgIcon icon="ri:heart-fill" class="text-red-500" /&gt;
</div>
</div>
<div>
<div class="text-sm text-g-600 mb-2">组合使用</div>
<div class="bg-g-200 dark:bg-g-300/30 p-4 rounded font-mono text-sm text-g-800">
&lt;ArtSvgIcon icon="ri:star-fill" class="text-4xl text-yellow-500" /&gt;
</div>
</div>
</div>
</div>
</div>
</template> -->
<!-- <script setup lang="ts">
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
defineOptions({ name: "IconPage" });
</script> -->
@@ -0,0 +1,103 @@
<!-- 柱状图卡片 -->
<template>
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
<div class="mb-5 flex-b items-start px-5 pt-5">
<div>
<p class="m-0 text-2xl font-medium leading-tight text-g-900">
{{ value }}
</p>
<p class="mt-1 text-sm text-g-600">{{ label }}</p>
</div>
<div
class="text-sm font-medium text-danger"
:class="[percentage > 0 ? 'text-success' : '', isMiniChart ? 'absolute bottom-5' : '']"
>
{{ percentage > 0 ? "+" : "" }}{{ percentage }}%
</div>
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600">
{{ date }}
</div>
</div>
<div
ref="chartRef"
class="absolute bottom-0 left-0 right-0 mx-auto"
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
></div>
</div>
</template>
<script setup lang="ts">
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import { type EChartsOption } from "@/plugins/echarts";
defineOptions({ name: "ArtBarChartCard" });
interface Props {
/** 数值 */
value: number;
/** 标签 */
label: string;
/** 百分比 +(绿色)-(红色) */
percentage: number;
/** 日期 */
date?: string;
/** 高度 */
height?: number;
/** 颜色 */
color?: string;
/** 图表数据 */
chartData: number[];
/** 柱状图宽度 */
barWidth?: string;
/** 是否为迷你图表 */
isMiniChart?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
height: 11,
barWidth: "26%",
});
// 使用新的图表组件抽象
const { chartRef } = useChartComponent({
props: {
height: `${props.height}rem`,
loading: false,
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0),
},
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
watchSources: [() => props.chartData, () => props.color, () => props.barWidth],
generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor;
return {
grid: {
top: 0,
right: 0,
bottom: 15,
left: 0,
},
xAxis: {
type: "category",
show: false,
},
yAxis: {
type: "value",
show: false,
},
series: [
{
data: props.chartData,
type: "bar",
barWidth: props.barWidth,
itemStyle: {
color: computedColor,
borderRadius: 2,
},
},
],
};
},
});
</script>
@@ -0,0 +1,75 @@
<!-- 数据列表卡片 -->
<template>
<div class="art-card p-5">
<div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p>
</div>
<ElScrollbar :style="{ height: maxHeight }">
<div v-for="(item, index) in list" :key="index" class="flex-c py-3">
<div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class">
<ArtSvgIcon :icon="item.icon" class="text-xl" />
</div>
<div class="flex-1">
<div class="mb-1 text-sm">{{ item.title }}</div>
<div class="text-xs text-g-500">{{ item.status }}</div>
</div>
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
</div>
</ElScrollbar>
<ElButton
class="mt-[25px] w-full text-center"
v-if="showMoreButton"
v-ripple
@click="handleMore"
>
查看更多
</ElButton>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "ArtDataListCard" });
interface Props {
/** 数据列表 */
list: Activity[];
/** 标题 */
title: string;
/** 副标题 */
subtitle?: string;
/** 最大显示数量 */
maxCount?: number;
/** 是否显示更多按钮 */
showMoreButton?: boolean;
}
interface Activity {
/** 标题 */
title: string;
/** 状态 */
status: string;
/** 时间 */
time: string;
/** 样式类名 */
class: string;
/** 图标 */
icon: string;
}
const ITEM_HEIGHT = 66;
const DEFAULT_MAX_COUNT = 5;
const props = withDefaults(defineProps<Props>(), {
maxCount: DEFAULT_MAX_COUNT,
});
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`);
const emit = defineEmits<{
/** 点击更多按钮事件 */
(e: "more"): void;
}>();
const handleMore = () => emit("more");
</script>
@@ -0,0 +1,124 @@
<!-- 环型图卡片 -->
<template>
<div class="art-card overflow-hidden" :style="{ height: `${height}rem` }">
<div class="flex box-border h-full p-5 pr-2">
<div class="flex w-full items-start gap-5">
<div class="flex-b h-full flex-1 flex-col">
<p class="m-0 text-xl font-medium leading-tight text-g-900">
{{ title }}
</p>
<div>
<p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900">
{{ formatNumber(value) }}
</p>
<div
class="mt-1.5 text-xs font-medium"
:class="percentage > 0 ? 'text-success' : 'text-danger'"
>
{{ percentage > 0 ? "+" : "" }}{{ percentage }}%
<span v-if="percentageLabel">{{ percentageLabel }}</span>
</div>
</div>
<div class="mt-2 flex gap-4 text-xs text-g-600">
<div v-if="currentValue" class="flex-cc">
<div class="size-2 bg-theme/100 rounded mr-2"></div>
{{ currentValue }}
</div>
<div v-if="previousValue" class="flex-cc">
<div class="size-2 bg-g-400 rounded mr-2"></div>
{{ previousValue }}
</div>
</div>
</div>
<div class="flex-c h-full max-w-40 flex-1">
<div ref="chartRef" class="h-30 w-full"></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { type EChartsOption } from "@/plugins/echarts";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
defineOptions({ name: "ArtDonutChartCard" });
interface Props {
/** 数值 */
value: number;
/** 标题 */
title: string;
/** 百分比 */
percentage: number;
/** 百分比标签 */
percentageLabel?: string;
/** 当前年份 */
currentValue?: string;
/** 去年年份 */
previousValue?: string;
/** 高度 */
height?: number;
/** 颜色 */
color?: string;
/** 半径 */
radius?: [string, string];
/** 数据 */
data: [number, number];
}
const props = withDefaults(defineProps<Props>(), {
height: 9,
radius: () => ["70%", "90%"],
data: () => [0, 0],
});
const formatNumber = (num: number) => {
return num.toLocaleString();
};
// 使用新的图表组件抽象
const { chartRef } = useChartComponent({
props: {
height: `${props.height}rem`,
loading: false,
isEmpty: props.data.every((val) => val === 0),
},
checkEmpty: () => props.data.every((val) => val === 0),
watchSources: [
() => props.data,
() => props.color,
() => props.radius,
() => props.currentValue,
() => props.previousValue,
],
generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor;
return {
series: [
{
type: "pie",
radius: props.radius,
avoidLabelOverlap: false,
label: {
show: false,
},
data: [
{
value: props.data[0],
name: props.currentValue,
itemStyle: { color: computedColor },
},
{
value: props.data[1],
name: props.previousValue,
itemStyle: { color: "#e6e8f7" },
},
],
},
],
};
},
});
</script>
@@ -0,0 +1,89 @@
<!-- 图片卡片 -->
<template>
<div class="w-full c-p" @click="handleClick">
<div class="art-card overflow-hidden">
<div class="relative w-full aspect-[16/10] overflow-hidden">
<ElImage
:src="props.imageUrl"
fit="cover"
loading="lazy"
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
>
<template #placeholder>
<div class="flex-cc w-full h-full bg-[#f5f7fa]">
<ElIcon><Picture /></ElIcon>
</div>
</template>
</ElImage>
<div
class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded"
v-if="props.readTime"
>
{{ props.readTime }} 阅读
</div>
</div>
<div class="p-4">
<div
class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded"
v-if="props.category"
>
{{ props.category }}
</div>
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
<div class="flex-c gap-4 text-xs text-g-600">
<span class="flex-c gap-1" v-if="props.views">
<ElIcon class="text-base"><View /></ElIcon>
{{ props.views }}
</span>
<span class="flex-c gap-1" v-if="props.comments">
<ElIcon class="text-base"><ChatLineRound /></ElIcon>
{{ props.comments }}
</span>
<span>{{ props.date }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Picture, View, ChatLineRound } from "@element-plus/icons-vue";
defineOptions({ name: "ArtImageCard" });
interface Props {
/** 图片地址 */
imageUrl: string;
/** 标题 */
title: string;
/** 分类 */
category?: string;
/** 阅读时间 */
readTime?: string;
/** 浏览量 */
views?: number;
/** 评论数 */
comments?: number;
/** 日期 */
date?: string;
}
const props = withDefaults(defineProps<Props>(), {
imageUrl: "",
title: "",
category: "",
readTime: "",
views: 0,
comments: 0,
date: "",
});
const emit = defineEmits<{
(e: "click", card: Props): void;
}>();
const handleClick = () => {
emit("click", props);
};
</script>
@@ -0,0 +1,126 @@
<!-- 折线图卡片 -->
<template>
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
<div class="mb-2.5 flex-b items-start p-5">
<div>
<p class="text-2xl font-medium leading-none">
{{ value }}
</p>
<p class="mt-1 text-sm text-g-500">{{ label }}</p>
</div>
<div
class="text-sm font-medium"
:class="[
percentage > 0 ? 'text-success' : 'text-danger',
isMiniChart ? 'absolute bottom-5' : '',
]"
>
{{ percentage > 0 ? "+" : "" }}{{ percentage }}%
</div>
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500">
{{ date }}
</div>
</div>
<div
ref="chartRef"
class="absolute bottom-0 left-0 right-0 box-border w-full"
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
></div>
</div>
</template>
<script setup lang="ts">
import { graphic, type EChartsOption } from "@/plugins/echarts";
import { getCssVar, hexToRgba } from "@utils/ui";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
defineOptions({ name: "ArtLineChartCard" });
interface Props {
/** 数值 */
value: number;
/** 标签 */
label: string;
/** 百分比 */
percentage: number;
/** 日期 */
date?: string;
/** 高度 */
height?: number;
/** 颜色 */
color?: string;
/** 是否显示区域颜色 */
showAreaColor?: boolean;
/** 图表数据 */
chartData: number[];
/** 是否为迷你图表 */
isMiniChart?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
height: 11,
});
// 使用新的图表组件抽象
const { chartRef } = useChartComponent({
props: {
height: `${props.height}rem`,
loading: false,
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0),
},
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
watchSources: [() => props.chartData, () => props.color, () => props.showAreaColor],
generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor;
return {
grid: {
top: 0,
right: 0,
bottom: 0,
left: 0,
},
xAxis: {
type: "category",
show: false,
boundaryGap: false,
},
yAxis: {
type: "value",
show: false,
},
series: [
{
data: props.chartData,
type: "line",
smooth: true,
showSymbol: false,
lineStyle: {
width: 3,
color: computedColor,
},
areaStyle: props.showAreaColor
? {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: props.color
? hexToRgba(props.color, 0.2).rgba
: hexToRgba(getCssVar("--el-color-primary"), 0.2).rgba,
},
{
offset: 1,
color: props.color
? hexToRgba(props.color, 0.01).rgba
: hexToRgba(getCssVar("--el-color-primary"), 0.01).rgba,
},
]),
}
: undefined,
},
],
};
},
});
</script>
@@ -0,0 +1,86 @@
<!-- 进度条卡片 -->
<template>
<div class="art-card h-32 flex flex-col justify-center px-5">
<div class="mb-3.5 flex-c" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }">
<div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
<ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon>
</div>
<div>
<ArtCountTo
class="mb-1 block text-2xl font-semibold"
:target="percentage"
:duration="2000"
suffix="%"
:style="{ textAlign: icon ? 'right' : 'left' }"
/>
<p class="text-sm text-g-500">{{ title }}</p>
</div>
</div>
<ElProgress
:percentage="currentPercentage"
:stroke-width="strokeWidth"
:show-text="false"
:color="color"
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]"
/>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "ArtProgressCard" });
interface Props {
/** 进度百分比 */
percentage: number;
/** 标题 */
title: string;
/** 颜色 */
color?: string;
/** 图标 */
icon?: string;
/** 图标样式 */
iconStyle?: string;
/** 进度条宽度 */
strokeWidth?: number;
}
const props = withDefaults(defineProps<Props>(), {
strokeWidth: 5,
color: "#67C23A",
});
const animationDuration = 500;
const currentPercentage = ref(0);
const animateProgress = () => {
const startTime = Date.now();
const startValue = currentPercentage.value;
const endValue = props.percentage;
const animate = () => {
const currentTime = Date.now();
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / animationDuration, 1);
currentPercentage.value = startValue + (endValue - startValue) * progress;
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
};
onMounted(() => {
animateProgress();
});
// 当 percentage 属性变化时重新执行动画
watch(
() => props.percentage,
() => {
animateProgress();
}
);
</script>
@@ -0,0 +1,68 @@
<!-- 统计卡片 -->
<template>
<div
class="art-card h-32 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5"
:class="boxStyle"
>
<div v-if="icon" class="mr-4 size-11 flex-cc rounded-lg text-xl text-white" :class="iconStyle">
<ArtSvgIcon :icon="icon"></ArtSvgIcon>
</div>
<div class="flex-1">
<p class="m-0 text-lg font-medium" :style="{ color: textColor }" v-if="title">
{{ title }}
</p>
<ArtCountTo
class="m-0 text-2xl font-medium"
v-if="count !== undefined"
:target="count"
:duration="2000"
:decimals="decimals"
:separator="separator"
/>
<p
class="mt-1 text-sm text-g-500 opacity-90"
:style="{ color: textColor }"
v-if="description"
>
{{ description }}
</p>
</div>
<div v-if="showArrow">
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-xl text-g-500" />
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "ArtStatsCard" });
interface StatsCardProps {
/** 盒子样式 */
boxStyle?: string;
/** 图标 */
icon?: string;
/** 图标样式 */
iconStyle?: string;
/** 标题 */
title?: string;
/** 数值 */
count?: number;
/** 小数位 */
decimals?: number;
/** 分隔符 */
separator?: string;
/** 描述 */
description: string;
/** 文本颜色 */
textColor?: string;
/** 是否显示箭头 */
showArrow?: boolean;
}
withDefaults(defineProps<StatsCardProps>(), {
iconSize: 30,
iconBgRadius: 50,
decimals: 0,
separator: ",",
});
</script>
@@ -0,0 +1,69 @@
<!-- 时间轴列表卡片 -->
<template>
<div class="art-card p-5">
<div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p>
</div>
<ElScrollbar :style="{ height: maxHeight }">
<ElTimeline class="!pl-0.5">
<ElTimelineItem
v-for="item in list"
:key="item.time"
:timestamp="item.time"
:placement="TIMELINE_PLACEMENT"
:color="item.status"
:center="true"
>
<div class="flex-c gap-3">
<div class="flex-c gap-2">
<span class="text-sm">{{ item.content }}</span>
<span v-if="item.code" class="text-sm text-theme">#{{ item.code }}</span>
</div>
</div>
</ElTimelineItem>
</ElTimeline>
</ElScrollbar>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "ArtTimelineListCard" });
// 常量配置
const ITEM_HEIGHT = 65;
const TIMELINE_PLACEMENT = "top";
const DEFAULT_MAX_COUNT = 5;
interface TimelineItem {
/** 时间 */
time: string;
/** 状态颜色 */
status: string;
/** 内容 */
content: string;
/** 代码标识 */
code?: string;
}
interface Props {
/** 时间轴列表数据 */
list: TimelineItem[];
/** 标题 */
title: string;
/** 副标题 */
subtitle?: string;
/** 最大显示数量 */
maxCount?: number;
}
// Props 定义和验证
const props = withDefaults(defineProps<Props>(), {
title: "",
subtitle: "",
maxCount: DEFAULT_MAX_COUNT,
});
// 计算最大高度
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`);
</script>
@@ -0,0 +1,203 @@
<!-- 柱状图 -->
<template>
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"></div>
</template>
<script setup lang="ts">
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import { getCssVar } from "@utils/ui";
import { graphic, type EChartsOption } from "@/plugins/echarts";
import type { BarChartProps, BarDataItem } from "@/types/component/chart";
defineOptions({ name: "ArtBarChart" });
const props = withDefaults(defineProps<BarChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
borderRadius: 4,
// 数据配置
data: () => [0, 0, 0, 0, 0, 0, 0],
xAxisData: () => [],
barWidth: "40%",
stack: false,
// 轴线显示配置
showAxisLabel: true,
showAxisLine: true,
showSplitLine: true,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: "bottom",
});
// 判断是否为多数据
const isMultipleData = computed(() => {
return (
Array.isArray(props.data) &&
props.data.length > 0 &&
typeof props.data[0] === "object" &&
"name" in props.data[0]
);
});
// 获取颜色配置
const getColor = (customColor?: string, index?: number) => {
if (customColor) return customColor;
if (index !== undefined) {
return props.colors![index % props.colors!.length];
}
// 默认渐变色
return new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: getCssVar("--el-color-primary-light-4"),
},
{
offset: 1,
color: getCssVar("--el-color-primary"),
},
]);
};
// 创建渐变色
const createGradientColor = (color: string) => {
return new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color,
},
{
offset: 1,
color,
},
]);
};
// 获取基础样式配置
const getBaseItemStyle = (
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
) => ({
borderRadius: props.borderRadius,
color: typeof color === "string" ? createGradientColor(color) : color,
});
// 创建系列配置
const createSeriesItem = (config: {
name?: string;
data: number[];
color?: string | InstanceType<typeof graphic.LinearGradient>;
barWidth?: string | number;
stack?: string;
}) => {
const animationConfig = getAnimationConfig();
return {
name: config.name,
data: config.data,
type: "bar" as const,
stack: config.stack,
itemStyle: getBaseItemStyle(config.color),
barWidth: config.barWidth || props.barWidth,
...animationConfig,
};
};
// 使用新的图表组件抽象
const {
chartRef,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getAnimationConfig,
getTooltipStyle,
getLegendStyle,
getGridWithLegend,
} = useChartComponent({
props,
checkEmpty: () => {
// 检查单数据情况
if (Array.isArray(props.data) && typeof props.data[0] === "number") {
const singleData = props.data as number[];
return !singleData.length || singleData.every((val) => val === 0);
}
// 检查多数据情况
if (Array.isArray(props.data) && typeof props.data[0] === "object") {
const multiData = props.data as BarDataItem[];
return (
!multiData.length ||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
);
}
return true;
},
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
generateOptions: (): EChartsOption => {
const options: EChartsOption = {
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
top: 15,
right: 0,
left: 0,
}),
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
xAxis: {
type: "category",
data: props.xAxisData,
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel),
},
yAxis: {
type: "value",
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
splitLine: getSplitLineStyle(props.showSplitLine),
},
};
// 添加图例配置
if (props.showLegend && isMultipleData.value) {
options.legend = getLegendStyle(props.legendPosition);
}
// 生成系列数据
if (isMultipleData.value) {
const multiData = props.data as BarDataItem[];
options.series = multiData.map((item, index) => {
const computedColor = getColor(props.colors[index], index);
return createSeriesItem({
name: item.name,
data: item.data,
color: computedColor,
barWidth: item.barWidth,
stack: props.stack ? item.stack || "total" : undefined,
});
});
} else {
// 单数据情况
const singleData = props.data as number[];
const computedColor = getColor();
options.series = [
createSeriesItem({
data: singleData,
color: computedColor,
}),
];
}
return options;
},
});
</script>
@@ -0,0 +1,194 @@
<!-- 双向堆叠柱状图 -->
<template>
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"></div>
</template>
<script setup lang="ts">
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { EChartsOption, BarSeriesOption } from "@/plugins/echarts";
import type { BidirectionalBarChartProps } from "@/types/component/chart";
defineOptions({ name: "ArtDualBarCompareChart" });
const props = withDefaults(defineProps<BidirectionalBarChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
positiveData: () => [],
negativeData: () => [],
xAxisData: () => [],
positiveName: "正向数据",
negativeName: "负向数据",
barWidth: 16,
yAxisMin: -100,
yAxisMax: 100,
// 样式配置
showDataLabel: false,
positiveBorderRadius: () => [10, 10, 0, 0],
negativeBorderRadius: () => [0, 0, 10, 10],
// 轴线显示配置
showAxisLabel: true,
showAxisLine: false,
showSplitLine: false,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: "bottom",
});
// 创建系列配置的辅助函数
const createSeriesConfig = (config: {
name: string;
data: number[];
borderRadius: number | number[];
labelPosition: "top" | "bottom";
colorIndex: number;
formatter?: (params: unknown) => string;
}): BarSeriesOption => {
const { fontColor } = useChartOps();
const animationConfig = getAnimationConfig();
return {
name: config.name,
type: "bar",
stack: "total",
barWidth: props.barWidth,
barGap: "-100%",
data: config.data,
itemStyle: {
borderRadius: config.borderRadius,
color: props.colors[config.colorIndex],
},
label: {
show: props.showDataLabel,
position: config.labelPosition,
formatter:
config.formatter ||
((params: unknown) => String((params as Record<string, unknown>).value)),
color: fontColor,
fontSize: 12,
},
...animationConfig,
};
};
// 使用图表组件抽象
const {
chartRef,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getAnimationConfig,
getTooltipStyle,
getLegendStyle,
getGridWithLegend,
} = useChartComponent({
props,
checkEmpty: () => {
return (
props.isEmpty ||
!props.positiveData.length ||
!props.negativeData.length ||
(props.positiveData.every((val) => val === 0) && props.negativeData.every((val) => val === 0))
);
},
watchSources: [
() => props.positiveData,
() => props.negativeData,
() => props.xAxisData,
() => props.colors,
],
generateOptions: (): EChartsOption => {
// 处理负向数据,确保为负值
const processedNegativeData = props.negativeData.map((val) => (val > 0 ? -val : val));
// 优化的Grid配置
const gridConfig = {
top: props.showLegend ? 50 : 20,
right: 0,
left: 0,
bottom: 0, // 增加底部间距
containLabel: true,
};
const options: EChartsOption = {
backgroundColor: "transparent",
animation: true,
animationDuration: 1000,
animationEasing: "cubicOut",
grid: getGridWithLegend(props.showLegend, props.legendPosition, gridConfig),
// 优化的提示框配置
tooltip: props.showTooltip
? {
...getTooltipStyle(),
trigger: "axis",
axisPointer: {
type: "none", // 去除指示线
},
}
: undefined,
// 图例配置
legend: props.showLegend
? {
...getLegendStyle(props.legendPosition),
data: [props.negativeName, props.positiveName],
}
: undefined,
// X轴配置
xAxis: {
type: "category",
data: props.xAxisData,
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel),
boundaryGap: true,
},
// Y轴配置
yAxis: {
type: "value",
min: props.yAxisMin,
max: props.yAxisMax,
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
splitLine: getSplitLineStyle(props.showSplitLine),
},
// 系列配置
series: [
// 负向数据系列
createSeriesConfig({
name: props.negativeName,
data: processedNegativeData,
borderRadius: props.negativeBorderRadius,
labelPosition: "bottom",
colorIndex: 1,
formatter: (params: unknown) =>
String(Math.abs((params as Record<string, unknown>).value as number)),
}),
// 正向数据系列
createSeriesConfig({
name: props.positiveName,
data: props.positiveData,
borderRadius: props.positiveBorderRadius,
labelPosition: "top",
colorIndex: 0,
}),
],
};
return options;
},
});
</script>
@@ -0,0 +1,208 @@
<!-- 水平柱状图 -->
<template>
<div
ref="chartRef"
class="relative w-full"
:style="{ height: props.height }"
v-loading="props.loading"
></div>
</template>
<script setup lang="ts">
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import { getCssVar } from "@utils/ui";
import { graphic, type EChartsOption } from "@/plugins/echarts";
import type { BarChartProps, BarDataItem } from "@/types/component/chart";
defineOptions({ name: "ArtHBarChart" });
const props = withDefaults(defineProps<BarChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
data: () => [0, 0, 0, 0, 0, 0, 0],
xAxisData: () => [],
barWidth: "36%",
stack: false,
// 轴线显示配置
showAxisLabel: true,
showAxisLine: true,
showSplitLine: true,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: "bottom",
});
// 判断是否为多数据
const isMultipleData = computed(() => {
return (
Array.isArray(props.data) &&
props.data.length > 0 &&
typeof props.data[0] === "object" &&
"name" in props.data[0]
);
});
// 获取颜色配置
const getColor = (customColor?: string, index?: number) => {
if (customColor) return customColor;
if (index !== undefined) {
return props.colors![index % props.colors!.length];
}
// 默认渐变色
return new graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: getCssVar("--el-color-primary"),
},
{
offset: 1,
color: getCssVar("--el-color-primary-light-4"),
},
]);
};
// 创建渐变色
const createGradientColor = (color: string) => {
return new graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color,
},
{
offset: 1,
color,
},
]);
};
// 获取基础样式配置
const getBaseItemStyle = (
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
) => ({
borderRadius: 4,
color: typeof color === "string" ? createGradientColor(color) : color,
});
// 创建系列配置
const createSeriesItem = (config: {
name?: string;
data: number[];
color?: string | InstanceType<typeof graphic.LinearGradient>;
barWidth?: string | number;
stack?: string;
}) => {
const animationConfig = getAnimationConfig();
return {
name: config.name,
data: config.data,
type: "bar" as const,
stack: config.stack,
itemStyle: getBaseItemStyle(config.color),
barWidth: config.barWidth || props.barWidth,
...animationConfig,
};
};
// 使用新的图表组件抽象
const {
chartRef,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getAnimationConfig,
getTooltipStyle,
getLegendStyle,
getGridWithLegend,
} = useChartComponent({
props,
checkEmpty: () => {
// 检查单数据情况
if (Array.isArray(props.data) && typeof props.data[0] === "number") {
const singleData = props.data as number[];
return !singleData.length || singleData.every((val) => val === 0);
}
// 检查多数据情况
if (Array.isArray(props.data) && typeof props.data[0] === "object") {
const multiData = props.data as BarDataItem[];
return (
!multiData.length ||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
);
}
return true;
},
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
generateOptions: (): EChartsOption => {
const options: EChartsOption = {
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
top: 15,
right: 0,
left: 0,
}),
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
xAxis: {
type: "value",
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel),
splitLine: getSplitLineStyle(props.showSplitLine),
},
yAxis: {
type: "category",
data: props.xAxisData,
axisTick: getAxisTickStyle(),
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
},
};
// 添加图例配置
if (props.showLegend && isMultipleData.value) {
options.legend = getLegendStyle(props.legendPosition);
}
// 生成系列数据
if (isMultipleData.value) {
const multiData = props.data as BarDataItem[];
options.series = multiData.map((item, index) => {
const computedColor = getColor(props.colors[index], index);
return createSeriesItem({
name: item.name,
data: item.data,
color: computedColor,
barWidth: item.barWidth,
stack: props.stack ? item.stack || "total" : undefined,
});
});
} else {
// 单数据情况
const singleData = props.data as number[];
const computedColor = getColor();
options.series = [
createSeriesItem({
data: singleData,
color: computedColor,
}),
];
}
return options;
},
});
</script>
@@ -0,0 +1,152 @@
<!-- k线图表 -->
<template>
<div
ref="chartRef"
class="relative w-full"
:style="{ height: props.height }"
v-loading="props.loading"
></div>
</template>
<script setup lang="ts">
import type { EChartsOption } from "@/plugins/echarts";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { KLineChartProps } from "@/types/component/chart";
defineOptions({ name: "ArtKLineChart" });
const props = withDefaults(defineProps<KLineChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
data: () => [],
showDataZoom: false,
dataZoomStart: 0,
dataZoomEnd: 100,
});
// 获取实际使用的颜色
const getActualColors = () => {
const defaultUpColor = "#4C87F3";
const defaultDownColor = "#8BD8FC";
return {
upColor: props.colors?.[0] || defaultUpColor,
downColor: props.colors?.[1] || defaultDownColor,
};
};
// 使用新的图表组件抽象
const {
chartRef,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getAnimationConfig,
getTooltipStyle,
} = useChartComponent({
props,
checkEmpty: () => {
return (
!props.data?.length ||
props.data.every(
(item) => item.open === 0 && item.close === 0 && item.high === 0 && item.low === 0
)
);
},
watchSources: [
() => props.data,
() => props.colors,
() => props.showDataZoom,
() => props.dataZoomStart,
() => props.dataZoomEnd,
],
generateOptions: (): EChartsOption => {
const { upColor, downColor } = getActualColors();
return {
grid: {
top: 20,
right: 20,
bottom: props.showDataZoom ? 80 : 20,
left: 20,
containLabel: true,
},
tooltip: getTooltipStyle("axis", {
axisPointer: {
type: "cross",
},
formatter: (params: Array<{ name: string; data: number[] }>) => {
const param = params[0];
const data = param.data;
return `
<div style="padding: 5px;">
<div><strong>时间:</strong>${param.name}</div>
<div><strong>开盘:</strong>${data[0]}</div>
<div><strong>收盘:</strong>${data[1]}</div>
<div><strong>最低:</strong>${data[2]}</div>
<div><strong>最高:</strong>${data[3]}</div>
</div>
`;
},
}),
xAxis: {
type: "category",
data: props.data.map((item) => item.time),
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(true),
axisLabel: getAxisLabelStyle(true),
},
yAxis: {
type: "value",
scale: true,
axisLabel: getAxisLabelStyle(true),
axisLine: getAxisLineStyle(true),
splitLine: getSplitLineStyle(true),
},
series: [
{
type: "candlestick",
data: props.data.map((item) => [item.open, item.close, item.low, item.high]),
itemStyle: {
color: upColor,
color0: downColor,
borderColor: upColor,
borderColor0: downColor,
borderWidth: 1,
},
emphasis: {
itemStyle: {
borderWidth: 2,
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, 0.3)",
},
},
...getAnimationConfig(),
},
],
dataZoom: props.showDataZoom
? [
{
type: "inside",
start: props.dataZoomStart,
end: props.dataZoomEnd,
},
{
show: true,
type: "slider",
top: "90%",
start: props.dataZoomStart,
end: props.dataZoomEnd,
},
]
: undefined,
};
},
});
</script>
@@ -0,0 +1,370 @@
<!-- 折线图,支持多组数据,支持阶梯式动画效果 -->
<template>
<div
ref="chartRef"
class="relative w-[calc(100%+10px)]"
:style="{ height: props.height }"
v-loading="props.loading"
></div>
</template>
<script setup lang="ts">
import { graphic, type EChartsOption } from "@/plugins/echarts";
import { getCssVar, hexToRgba } from "@utils/ui";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { LineChartProps, LineDataItem } from "@/types/component/chart";
defineOptions({ name: "ArtLineChart" });
const props = withDefaults(defineProps<LineChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
data: () => [0, 0, 0, 0, 0, 0, 0],
xAxisData: () => [],
lineWidth: 2.5,
showAreaColor: false,
smooth: true,
symbol: "none",
symbolSize: 6,
animationDelay: 200,
// 轴线显示配置
showAxisLabel: true,
showAxisLine: true,
showSplitLine: true,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: "bottom",
});
// 动画状态管理
const isAnimating = ref(false);
const animationTimers = ref<number[]>([]);
const animatedData = ref<number[] | LineDataItem[]>([]);
// 清理所有定时器
const clearAnimationTimers = () => {
animationTimers.value.forEach((timer) => clearTimeout(timer));
animationTimers.value = [];
};
// 判断是否为多数据(使用 VueUse 的 computedEager 优化)
const isMultipleData = computed(() => {
return (
Array.isArray(props.data) &&
props.data.length > 0 &&
typeof props.data[0] === "object" &&
"name" in props.data[0]
);
});
// 缓存计算的最大值,避免重复计算
const maxValue = computed(() => {
if (isMultipleData.value) {
const multiData = props.data as LineDataItem[];
return multiData.reduce((max, item) => {
if (item.data?.length) {
const itemMax = Math.max(...item.data);
return Math.max(max, itemMax);
}
return max;
}, 0);
} else {
const singleData = props.data as number[];
return singleData?.length ? Math.max(...singleData) : 0;
}
});
// 初始化动画数据(优化:减少条件判断)
const initAnimationData = (): number[] | LineDataItem[] => {
if (isMultipleData.value) {
const multiData = props.data as LineDataItem[];
return multiData.map((item) => ({
...item,
data: Array(item.data.length).fill(0),
}));
}
const singleData = props.data as number[];
return Array(singleData.length).fill(0);
};
// 复制真实数据(优化:使用结构化克隆)
const copyRealData = (): number[] | LineDataItem[] => {
if (isMultipleData.value) {
return (props.data as LineDataItem[]).map((item) => ({ ...item, data: [...item.data] }));
}
return [...(props.data as number[])];
};
// 获取颜色配置(优化:缓存主题色)
const primaryColor = computed(() => getCssVar("--el-color-primary"));
const getColor = (customColor?: string, index?: number): string => {
if (customColor) return customColor;
if (index !== undefined) return props.colors![index % props.colors!.length];
return primaryColor.value;
};
// 生成区域样式
const generateAreaStyle = (item: LineDataItem, color: string) => {
// 如果有 areaStyle 配置,或者显式开启了区域颜色,则显示区域样式
if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return undefined;
const areaConfig = item.areaStyle || {};
if (areaConfig.custom) return areaConfig.custom;
return {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba,
},
{
offset: 1,
color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba,
},
]),
};
};
// 生成单数据区域样式
const generateSingleAreaStyle = () => {
if (!props.showAreaColor) return undefined;
const color = getColor(props.colors[0]);
return {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: hexToRgba(color, 0.2).rgba,
},
{
offset: 1,
color: hexToRgba(color, 0.02).rgba,
},
]),
};
};
// 创建系列配置
const createSeriesItem = (config: {
name?: string;
data: number[];
color?: string;
smooth?: boolean;
symbol?: string;
symbolSize?: number;
lineWidth?: number;
areaStyle?: any;
}) => {
return {
name: config.name,
data: config.data,
type: "line" as const,
color: config.color,
smooth: config.smooth ?? props.smooth,
symbol: config.symbol ?? props.symbol,
symbolSize: config.symbolSize ?? props.symbolSize,
lineStyle: {
width: config.lineWidth ?? props.lineWidth,
color: config.color,
},
areaStyle: config.areaStyle,
emphasis: {
focus: "series" as const,
lineStyle: {
width: (config.lineWidth ?? props.lineWidth) + 1,
},
},
};
};
// 生成图表配置
const generateChartOptions = (isInitial = false): EChartsOption => {
const options: EChartsOption = {
animation: true,
animationDuration: isInitial ? 0 : 1300,
animationDurationUpdate: isInitial ? 0 : 1300,
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
top: 15,
right: 15,
left: 0,
}),
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
xAxis: {
type: "category",
boundaryGap: false,
data: props.xAxisData,
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel),
},
yAxis: {
type: "value",
min: 0,
max: maxValue.value,
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
splitLine: getSplitLineStyle(props.showSplitLine),
},
};
// 添加图例配置
if (props.showLegend && isMultipleData.value) {
options.legend = getLegendStyle(props.legendPosition);
}
// 生成系列数据
if (isMultipleData.value) {
const multiData = animatedData.value as LineDataItem[];
options.series = multiData.map((item, index) => {
const itemColor = getColor(props.colors[index], index);
const areaStyle = generateAreaStyle(item, itemColor);
return createSeriesItem({
name: item.name,
data: item.data,
color: itemColor,
smooth: item.smooth,
symbol: item.symbol,
lineWidth: item.lineWidth,
areaStyle,
});
});
} else {
// 单数据情况
const singleData = animatedData.value as number[];
const computedColor = getColor(props.colors[0]);
const areaStyle = generateSingleAreaStyle();
options.series = [
createSeriesItem({
data: singleData,
color: computedColor,
areaStyle,
}),
];
}
return options;
};
// 更新图表
const updateChartOptions = (options: EChartsOption) => {
initChart(options);
};
// 初始化动画函数(优化:统一定时器管理,减少内存泄漏风险)
const initChartWithAnimation = () => {
clearAnimationTimers();
isAnimating.value = true;
// 初始化为0值数据
animatedData.value = initAnimationData();
updateChartOptions(generateChartOptions(true));
if (isMultipleData.value) {
// 多数据阶梯式动画
const multiData = props.data as LineDataItem[];
const currentAnimatedData = animatedData.value as LineDataItem[];
multiData.forEach((item, index) => {
const timer = window.setTimeout(
() => {
currentAnimatedData[index] = { ...item, data: [...item.data] };
animatedData.value = [...currentAnimatedData];
updateChartOptions(generateChartOptions(false));
},
index * props.animationDelay + 100
);
animationTimers.value.push(timer);
});
// 标记动画完成
const totalDelay = (multiData.length - 1) * props.animationDelay + 1500;
const finishTimer = window.setTimeout(() => {
isAnimating.value = false;
}, totalDelay);
animationTimers.value.push(finishTimer);
} else {
// 单数据简单动画 - 使用 nextTick 确保初始状态已渲染
nextTick(() => {
animatedData.value = copyRealData();
updateChartOptions(generateChartOptions(false));
isAnimating.value = false;
});
}
};
// 空数据检查函数
const checkIsEmpty = () => {
// 检查单数据情况
if (Array.isArray(props.data) && typeof props.data[0] === "number") {
const singleData = props.data as number[];
return !singleData.length || singleData.every((val) => val === 0);
}
// 检查多数据情况
if (Array.isArray(props.data) && typeof props.data[0] === "object") {
const multiData = props.data as LineDataItem[];
return (
!multiData.length ||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
);
}
return true;
};
// 使用新的图表组件抽象
const {
chartRef,
initChart,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getTooltipStyle,
getLegendStyle,
getGridWithLegend,
isEmpty,
} = useChartComponent({
props,
checkEmpty: checkIsEmpty,
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
onVisible: () => {
// 当图表变为可见时,检查是否为空数据
if (!isEmpty.value) {
initChartWithAnimation();
}
},
generateOptions: () => generateChartOptions(false),
});
// 图表渲染函数(优化:防止动画期间重复触发)
const renderChart = () => {
if (!isAnimating.value && !isEmpty.value) {
initChartWithAnimation();
}
};
// 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新)
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, { deep: true });
// 生命周期
onMounted(() => {
renderChart();
});
onBeforeUnmount(() => {
clearAnimationTimers();
});
</script>
@@ -0,0 +1,289 @@
<!-- 地图图表 -->
<template>
<div class="relative w-full" :style="{ height: 'calc(100vh - 120px)' }">
<div v-if="isEmpty" class="h-full flex-cc">
<ElEmpty description="暂无地图数据" />
</div>
<div v-else id="china-map" ref="chinaMapRef" class="h-full w-full overflow-hidden rounded-lg" />
</div>
</template>
<script setup lang="ts">
import { echarts } from "@/plugins/echarts";
import { useSettingsStore } from "@stores/modules/setting.store";
import chinaMapJson from "@/mock/json/chinaMap.json";
import type { MapChartProps } from "@/types/component/chart";
defineOptions({ name: "ArtMapChart" });
const chinaMapRef = ref<HTMLElement | null>(null);
const chartInstance = shallowRef<echarts.ECharts | null>(null);
const settingStore = useSettingsStore();
const { isDark } = storeToRefs(settingStore);
const props = withDefaults(defineProps<MapChartProps>(), {
mapData: () => [],
selectedRegion: "",
showLabels: true,
showScatter: true,
isEmpty: false,
});
// 定义 emit
const emit = defineEmits<{
renderComplete: [];
regionClick: [region: { name: string; adcode: string; level: string }];
}>();
// 检查是否为空数据
const isEmpty = computed(() => {
return props.isEmpty || (!props.mapData?.length && !chinaMapJson);
});
// 根据 geoJson 数据准备地图数据
const prepareMapData = (geoJson: { features: Array<{ properties: Record<string, unknown> }> }) => {
return geoJson.features.map((feature) => ({
name: feature.properties.name as string,
value: Math.round(Math.random() * 1000),
adcode: feature.properties.adcode as string,
level: feature.properties.level as string,
selected: false,
}));
};
// 获取主题相关的样式配置
const getThemeStyles = () => ({
borderColor: isDark.value ? "rgba(255,255,255,0.6)" : "rgba(147,235,248,1)",
shadowColor: isDark.value ? "rgba(0,0,0,0.8)" : "rgba(128,217,248,1)",
labelColor: isDark.value ? "#fff" : "#333",
backgroundColor: isDark.value ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)",
});
// 构造 ECharts 配置项
const createChartOption = (mapData: Array<Record<string, unknown>>) => {
const themeStyles = getThemeStyles();
return {
animation: false, // 关闭动画效果,减少鼠标移动高亮时的掉帧感
tooltip: {
show: true,
backgroundColor: themeStyles.backgroundColor,
borderColor: isDark.value ? "#333" : "#ddd",
borderWidth: 1,
textStyle: {
color: themeStyles.labelColor,
},
formatter: ({ data }: { data?: Record<string, unknown> }) => {
const { name, adcode, level } = data || {};
return `
<div style="padding: 8px;">
<div><strong>名称:</strong> ${name || "未知区域"}</div>
<div><strong>代码:</strong> ${adcode || "暂无"}</div>
<div><strong>级别:</strong> ${level || "暂无"}</div>
</div>
`;
},
},
geo: {
map: "china",
zoom: 1,
show: true,
roam: false,
scaleLimit: {
min: 0.8,
max: 3,
},
layoutSize: "100%",
emphasis: {
label: { show: props.showLabels },
itemStyle: {
areaColor: "rgba(82,180,255,0.9)",
borderColor: "#fff",
borderWidth: 3,
},
},
itemStyle: {
borderColor: themeStyles.borderColor,
borderWidth: 2,
shadowColor: themeStyles.shadowColor,
shadowOffsetX: 2,
shadowOffsetY: 15,
shadowBlur: 15,
},
},
series: [
{
type: "map",
map: "china",
aspectScale: 0.75,
zoom: 1,
label: {
show: props.showLabels,
color: "#fff",
fontSize: 10,
},
itemStyle: {
borderColor: "rgba(147,235,248,0.8)",
borderWidth: 2,
areaColor: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: "rgba(147,235,248,0.3)" },
{ offset: 1, color: "rgba(32,120,207,0.9)" },
],
},
shadowColor: "rgba(32,120,207,1)",
shadowOffsetY: 15,
shadowBlur: 20,
},
emphasis: {
label: {
show: true,
color: "#fff",
fontSize: 12,
},
itemStyle: {
areaColor: "rgba(82,180,255,0.9)",
borderColor: "#fff",
borderWidth: 3,
},
},
select: {
label: {
show: true,
color: "#fff",
fontWeight: "bold",
},
itemStyle: {
areaColor: "#4FAEFB",
borderColor: "#fff",
borderWidth: 2,
},
},
data: mapData,
},
// 散点标记配置(例如:城市标记)
...(props.showScatter
? [
{
name: "城市",
type: "scatter",
coordinateSystem: "geo",
symbol: "pin",
symbolSize: 15,
label: { show: false },
itemStyle: {
color: "#F99020",
shadowBlur: 10,
shadowColor: "#333",
},
data: [
{ name: "北京", value: [116.405285, 39.904989, 100] },
{ name: "上海", value: [121.472644, 31.231706, 100] },
{ name: "深圳", value: [114.085947, 22.547, 100] },
],
},
]
: []),
],
};
};
// 初始化并渲染地图
const initMap = async (): Promise<void> => {
if (!chinaMapRef.value) return;
chartInstance.value = echarts.init(chinaMapRef.value);
echarts.registerMap("china", chinaMapJson as any);
const mapData = props.mapData.length > 0 ? props.mapData : prepareMapData(chinaMapJson);
const option = createChartOption(mapData);
chartInstance.value.setOption(option);
// 绑定事件
chartInstance.value.on("click", handleMapClick);
emit("renderComplete");
};
// 处理地图点击事件
const handleMapClick = (params: Record<string, unknown>) => {
if (params.componentType === "series") {
const data = params.data as Record<string, unknown> | undefined;
const regionData = {
name: params.name as string,
adcode: (data?.adcode as string) || "",
level: (data?.level as string) || "",
};
console.log(`选中区域: ${params.name}`, params);
// 高亮选中区域
chartInstance.value?.dispatchAction({
type: "select",
seriesIndex: 0,
dataIndex: params.dataIndex as number,
});
emit("regionClick", regionData);
}
};
// 窗口 resize 时调整图表大小
const resizeChart = () => {
chartInstance.value?.resize();
};
// 处理组件销毁
const cleanupChart = () => {
if (chartInstance.value) {
chartInstance.value.off("click", handleMapClick);
chartInstance.value.dispose();
chartInstance.value = null;
}
window.removeEventListener("resize", resizeChart);
};
// 生命周期钩子
onMounted(() => {
if (!isEmpty.value) {
initMap().then(() => {
setTimeout(resizeChart, 100);
});
}
window.addEventListener("resize", resizeChart);
});
onUnmounted(cleanupChart);
// 监听主题变化,重新初始化地图
watch(isDark, (newVal, oldVal) => {
if (newVal !== oldVal && chartInstance.value) {
cleanupChart();
nextTick(() => {
if (!isEmpty.value) {
initMap();
}
});
}
});
// 监听数据变化
watch(
() => props.mapData,
() => {
if (chartInstance.value && !isEmpty.value) {
const mapData = props.mapData.length > 0 ? props.mapData : prepareMapData(chinaMapJson);
const option = createChartOption(mapData);
chartInstance.value.setOption(option);
}
},
{ deep: true }
);
</script>
@@ -0,0 +1,105 @@
<!-- 雷达图 -->
<template>
<div
ref="chartRef"
class="relative w-full"
:style="{ height: props.height }"
v-loading="props.loading"
></div>
</template>
<script setup lang="ts">
import type { EChartsOption } from "@/plugins/echarts";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { RadarChartProps } from "@/types/component/chart";
defineOptions({ name: "ArtRadarChart" });
const props = withDefaults(defineProps<RadarChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
indicator: () => [],
data: () => [],
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: "bottom",
});
// 使用新的图表组件抽象
const { chartRef, isDark, getAnimationConfig, getTooltipStyle } = useChartComponent({
props,
checkEmpty: () => {
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0));
},
watchSources: [() => props.data, () => props.indicator, () => props.colors],
generateOptions: (): EChartsOption => {
return {
tooltip: props.showTooltip ? getTooltipStyle("item") : undefined,
radar: {
indicator: props.indicator,
center: ["50%", "50%"],
radius: "70%",
axisName: {
color: isDark.value ? "#ccc" : "#666",
fontSize: 12,
},
splitLine: {
lineStyle: {
color: isDark.value ? "#444" : "#e6e6e6",
},
},
axisLine: {
lineStyle: {
color: isDark.value ? "#444" : "#e6e6e6",
},
},
splitArea: {
show: true,
areaStyle: {
color: isDark.value
? ["rgba(255, 255, 255, 0.02)", "rgba(255, 255, 255, 0.05)"]
: ["rgba(0, 0, 0, 0.02)", "rgba(0, 0, 0, 0.05)"],
},
},
},
series: [
{
type: "radar",
data: props.data.map((item, index) => ({
name: item.name,
value: item.value,
symbolSize: 4,
lineStyle: {
width: 2,
color: props.colors[index % props.colors.length],
},
itemStyle: {
color: props.colors[index % props.colors.length],
},
areaStyle: {
color: props.colors[index % props.colors.length],
opacity: 0.1,
},
emphasis: {
areaStyle: {
opacity: 0.25,
},
lineStyle: {
width: 3,
},
},
})),
...getAnimationConfig(200, 1800),
},
],
};
},
});
</script>
@@ -0,0 +1,133 @@
<!-- 环形图 -->
<template>
<div
ref="chartRef"
class="relative w-full"
:style="{ height: props.height }"
v-loading="props.loading"
></div>
</template>
<script setup lang="ts">
import type { EChartsOption } from "@/plugins/echarts";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { RingChartProps } from "@/types/component/chart";
defineOptions({ name: "ArtRingChart" });
const props = withDefaults(defineProps<RingChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
data: () => [],
radius: () => ["50%", "80%"],
borderRadius: 10,
centerText: "",
showLabel: false,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: "right",
});
// 使用新的图表组件抽象
const { chartRef, isDark, getAnimationConfig, getTooltipStyle, getLegendStyle } = useChartComponent(
{
props,
checkEmpty: () => {
return !props.data?.length || props.data.every((item) => item.value === 0);
},
watchSources: [() => props.data, () => props.centerText],
generateOptions: (): EChartsOption => {
// 根据图例位置计算环形图中心位置
const getCenterPosition = (): [string, string] => {
if (!props.showLegend) return ["50%", "50%"];
switch (props.legendPosition) {
case "left":
return ["60%", "50%"];
case "right":
return ["40%", "50%"];
case "top":
return ["50%", "60%"];
case "bottom":
return ["50%", "40%"];
default:
return ["50%", "50%"];
}
};
const option: EChartsOption = {
tooltip: props.showTooltip
? getTooltipStyle("item", {
formatter: "{b}: {c} ({d}%)",
})
: undefined,
legend: props.showLegend ? getLegendStyle(props.legendPosition) : undefined,
series: [
{
name: "数据占比",
type: "pie",
radius: props.radius,
center: getCenterPosition(),
avoidLabelOverlap: false,
itemStyle: {
borderRadius: props.borderRadius,
borderColor: isDark.value ? "#2c2c2c" : "#fff",
borderWidth: 0,
},
label: {
show: props.showLabel,
formatter: "{b}\n{d}%",
position: "outside",
color: isDark.value ? "#ccc" : "#999",
fontSize: 12,
},
emphasis: {
label: {
show: false,
fontSize: 14,
fontWeight: "bold",
},
},
labelLine: {
show: props.showLabel,
length: 15,
length2: 25,
smooth: true,
},
data: props.data,
color: props.colors,
...getAnimationConfig(),
animationType: "expansion",
},
],
};
// 添加中心文字
if (props.centerText) {
const centerPos = getCenterPosition();
option.title = {
text: props.centerText,
left: centerPos[0],
top: centerPos[1],
textAlign: "center",
textVerticalAlign: "middle",
textStyle: {
fontSize: 18,
fontWeight: 500,
color: isDark.value ? "#999" : "#ADB0BC",
},
};
}
return option;
},
}
);
</script>
@@ -0,0 +1,114 @@
<!-- 散点图 -->
<template>
<div
ref="chartRef"
class="relative w-full"
:style="{ height: props.height }"
v-loading="props.loading"
></div>
</template>
<script setup lang="ts">
import type { EChartsOption } from "@/plugins/echarts";
import { getCssVar } from "@utils/ui";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { ScatterChartProps } from "@/types/component/chart";
defineOptions({ name: "ArtScatterChart" });
const props = withDefaults(defineProps<ScatterChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
data: () => [{ value: [0, 0] }, { value: [0, 0] }],
symbolSize: 14,
// 轴线显示配置
showAxisLabel: true,
showAxisLine: true,
showSplitLine: true,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: "bottom",
});
// 使用新的图表组件抽象
const {
chartRef,
isDark,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getAnimationConfig,
getTooltipStyle,
} = useChartComponent({
props,
checkEmpty: () => {
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0));
},
watchSources: [() => props.data, () => props.colors, () => props.symbolSize],
generateOptions: (): EChartsOption => {
const computedColor = props.colors[0] || getCssVar("--el-color-primary");
return {
grid: {
top: 20,
right: 20,
bottom: 20,
left: 20,
containLabel: true,
},
tooltip: props.showTooltip
? getTooltipStyle("item", {
formatter: (params: { value: [number, number] }) => {
const [x, y] = params.value;
return `X: ${x}<br/>Y: ${y}`;
},
})
: undefined,
xAxis: {
type: "value",
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
axisTick: getAxisTickStyle(),
splitLine: getSplitLineStyle(props.showSplitLine),
},
yAxis: {
type: "value",
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
axisTick: getAxisTickStyle(),
splitLine: getSplitLineStyle(props.showSplitLine),
},
series: [
{
type: "scatter",
data: props.data,
symbolSize: props.symbolSize,
itemStyle: {
color: computedColor,
shadowBlur: 6,
shadowColor: isDark.value ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)",
shadowOffsetY: 2,
},
emphasis: {
itemStyle: {
shadowBlur: 12,
shadowColor: isDark.value ? "rgba(255, 255, 255, 0.2)" : "rgba(0, 0, 0, 0.2)",
},
scale: true,
},
...getAnimationConfig(),
},
],
};
},
});
</script>
@@ -0,0 +1,71 @@
<!-- 更多按钮 -->
<template>
<div>
<ElDropdown v-if="hasAnyAuthItem">
<ArtIconButton icon="ri:more-2-fill" class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm" />
<template #dropdown>
<ElDropdownMenu>
<template v-for="item in list" :key="item.key">
<ElDropdownItem
v-if="!item.auth || hasAuth(item.auth)"
:disabled="item.disabled"
@click="handleClick(item)"
>
<div class="flex-c gap-2" :style="{ color: item.color }">
<ArtSvgIcon v-if="item.icon" :icon="item.icon" />
<span>{{ item.label }}</span>
</div>
</ElDropdownItem>
</template>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
<script setup lang="ts">
import { useAuth } from "@/hooks/core/useAuth";
defineOptions({ name: "ArtButtonMore" });
const { hasAuth } = useAuth();
export interface ButtonMoreItem {
/** 按钮标识,可用于点击事件 */
key: string | number;
/** 按钮文本 */
label: string;
/** 是否禁用 */
disabled?: boolean;
/** 权限标识 */
auth?: string;
/** 图标组件 */
icon?: string;
/** 文本颜色 */
color?: string;
/** 图标颜色(优先级高于 color) */
iconColor?: string;
}
interface Props {
/** 下拉项列表 */
list: ButtonMoreItem[];
/** 整体权限控制 */
auth?: string;
}
const props = withDefaults(defineProps<Props>(), {});
// 检查是否有任何有权限的 item
const hasAnyAuthItem = computed(() => {
return props.list.some((item) => !item.auth || hasAuth(item.auth));
});
const emit = defineEmits<{
(e: "click", item: ButtonMoreItem): void;
}>();
const handleClick = (item: ButtonMoreItem) => {
emit("click", item);
};
</script>
@@ -0,0 +1,59 @@
<!-- 表格按钮 -->
<template>
<div
:class="[
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm c-p rounded-md align-middle',
buttonClass,
]"
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
@click="handleClick"
>
<ArtSvgIcon :icon="iconContent" />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "ArtButtonTable" });
interface Props {
/** 按钮类型 */
type?: "add" | "edit" | "delete" | "more" | "view";
/** 按钮图标 */
icon?: string;
/** 按钮样式类 */
iconClass?: string;
/** icon 颜色 */
iconColor?: string;
/** 按钮背景色 */
buttonBgColor?: string;
}
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<{
(e: "click"): void;
}>();
// 默认按钮配置
const defaultButtons = {
add: { icon: "ri:add-fill", class: "bg-theme/12 text-theme" },
edit: { icon: "ri:pencil-line", class: "bg-secondary/12 text-secondary" },
delete: { icon: "ri:delete-bin-5-line", class: "bg-error/12 text-error" },
view: { icon: "ri:eye-line", class: "bg-info/12 text-info" },
more: { icon: "ri:more-2-fill", class: "" },
} as const;
// 获取图标内容
const iconContent = computed(() => {
return props.icon || (props.type ? defaultButtons[props.type]?.icon : "") || "";
});
// 获取按钮样式类
const buttonClass = computed(() => {
return props.iconClass || (props.type ? defaultButtons[props.type]?.class : "") || "";
});
const handleClick = () => {
emit("click");
};
</script>
@@ -0,0 +1,429 @@
<!-- 拖拽验证组件 -->
<template>
<div
ref="dragVerify"
class="drag_verify"
:style="dragVerifyStyle"
@mousemove="dragMoving"
@mouseup="dragFinish"
@mouseleave="dragFinish"
@touchmove="dragMoving"
@touchend="dragFinish"
>
<!-- 进度条 -->
<div
class="dv_progress_bar"
:class="{ goFirst2: isOk }"
ref="progressBar"
:style="progressBarStyle"
></div>
<!-- 提示文本 -->
<div class="dv_text" :style="textStyle" ref="messageRef">
<slot name="textBefore" v-if="$slots.textBefore"></slot>
{{ message }}
<slot name="textAfter" v-if="$slots.textAfter"></slot>
</div>
<!-- 滑块处理器 -->
<div
class="dv_handler dv_handler_bg"
:class="{ goFirst: isOk }"
@mousedown="dragStart"
@touchstart="dragStart"
ref="handler"
:style="handlerStyle"
>
<ArtSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></ArtSvgIcon>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "ArtDragVerify" });
// 事件定义
const emit = defineEmits(["handlerMove", "update:value", "passCallback"]);
// 组件属性接口定义
interface PropsType {
/** 是否通过验证 */
value: boolean;
/** 组件宽度 */
width?: number | string;
/** 组件高度 */
height?: number;
/** 默认提示文本 */
text?: string;
/** 成功提示文本 */
successText?: string;
/** 背景色 */
background?: string;
/** 进度条背景色 */
progressBarBg?: string;
/** 完成状态背景色 */
completedBg?: string;
/** 是否圆角 */
circle?: boolean;
/** 圆角大小 */
radius?: string;
/** 滑块图标 */
handlerIcon?: string;
/** 成功图标 */
successIcon?: string;
/** 滑块背景色 */
handlerBg?: string;
/** 文本大小 */
textSize?: string;
/** 文本颜色 */
textColor?: string;
}
// 属性默认值设置
const props = withDefaults(defineProps<PropsType>(), {
value: false,
width: "100%",
height: 40,
text: "按住滑块拖动",
successText: "success",
background: "#eee",
progressBarBg: "#1385FF",
completedBg: "#57D187",
circle: false,
radius: "calc(var(--custom-radius) / 3 + 2px)",
handlerIcon: "solar:double-alt-arrow-right-linear",
successIcon: "ri:check-fill",
handlerBg: "#fff",
textSize: "13px",
textColor: "#333",
});
// 组件状态接口定义
interface StateType {
isMoving: boolean; // 是否正在拖拽
x: number; // 拖拽起始位置
isOk: boolean; // 是否验证成功
}
// 响应式状态定义
const state = reactive(<StateType>{
isMoving: false,
x: 0,
isOk: false,
});
// 解构响应式状态
const { isOk } = toRefs(state);
// DOM 元素引用
const dragVerify = ref();
const messageRef = ref();
const handler = ref();
const progressBar = ref();
// 触摸事件变量 - 用于禁止页面滑动
let startX: number, startY: number, moveX: number, moveY: number;
/**
* 触摸开始事件处理
* @param e 触摸事件对象
*/
const onTouchStart = (e: any) => {
startX = e.targetTouches[0].pageX;
startY = e.targetTouches[0].pageY;
};
/**
* 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为
* @param e 触摸事件对象
*/
const onTouchMove = (e: any) => {
moveX = e.targetTouches[0].pageX;
moveY = e.targetTouches[0].pageY;
// 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动)
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
e.preventDefault();
}
};
// 全局事件监听器添加
document.addEventListener("touchstart", onTouchStart);
document.addEventListener("touchmove", onTouchMove, { passive: false });
// 获取数值形式的宽度
const getNumericWidth = (): number => {
if (typeof props.width === "string") {
// 如果是字符串,尝试从DOM元素获取实际宽度
return dragVerify.value?.offsetWidth || 260;
}
return props.width;
};
// 获取样式字符串形式的宽度
const getStyleWidth = (): string => {
if (typeof props.width === "string") {
return props.width;
}
return props.width + "px";
};
// 组件挂载后的初始化
onMounted(() => {
// 设置 CSS 自定义属性
dragVerify.value?.style.setProperty("--textColor", props.textColor);
// 等待DOM更新后设置宽度相关属性
nextTick(() => {
const numericWidth = getNumericWidth();
dragVerify.value?.style.setProperty("--width", Math.floor(numericWidth / 2) + "px");
dragVerify.value?.style.setProperty("--pwidth", -Math.floor(numericWidth / 2) + "px");
});
// 重复添加事件监听器(确保事件绑定)
document.addEventListener("touchstart", onTouchStart);
document.addEventListener("touchmove", onTouchMove, { passive: false });
});
// 组件卸载前清理事件监听器
onBeforeUnmount(() => {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchmove", onTouchMove);
});
// 滑块样式计算
const handlerStyle = {
left: "0",
width: props.height + "px",
height: props.height + "px",
background: props.handlerBg,
};
// 主容器样式计算
const dragVerifyStyle = computed(() => ({
width: getStyleWidth(),
height: props.height + "px",
lineHeight: props.height + "px",
background: props.background,
borderRadius: props.circle ? props.height / 2 + "px" : props.radius,
}));
// 进度条样式计算
const progressBarStyle = {
background: props.progressBarBg,
height: props.height + "px",
borderRadius: props.circle
? props.height / 2 + "px 0 0 " + props.height / 2 + "px"
: props.radius,
};
// 文本样式计算
const textStyle = computed(() => ({
fontSize: props.textSize,
}));
// 显示消息计算属性
const message = computed(() => {
return props.value ? props.successText : props.text;
});
/**
* 拖拽开始处理函数
* @param e 鼠标或触摸事件对象
*/
const dragStart = (e: any) => {
if (!props.value) {
state.isMoving = true;
handler.value.style.transition = "none";
// 计算拖拽起始位置
state.x =
(e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace("px", ""), 10);
}
emit("handlerMove");
};
/**
* 拖拽移动处理函数
* @param e 鼠标或触摸事件对象
*/
const dragMoving = (e: any) => {
if (state.isMoving && !props.value) {
const numericWidth = getNumericWidth();
// 计算当前位置
const _x = (e.pageX || e.touches[0].pageX) - state.x;
// 在有效范围内移动
if (_x > 0 && _x <= numericWidth - props.height) {
handler.value.style.left = _x + "px";
progressBar.value.style.width = _x + props.height / 2 + "px";
} else if (_x > numericWidth - props.height) {
// 拖拽到末端,触发验证成功
handler.value.style.left = numericWidth - props.height + "px";
progressBar.value.style.width = numericWidth - props.height / 2 + "px";
passVerify();
}
}
};
/**
* 拖拽结束处理函数
* @param e 鼠标或触摸事件对象
*/
const dragFinish = (e: any) => {
if (state.isMoving && !props.value) {
const numericWidth = getNumericWidth();
// 计算最终位置
const _x = (e.pageX || e.changedTouches[0].pageX) - state.x;
if (_x < numericWidth - props.height) {
// 未拖拽到末端,重置位置
state.isOk = true;
handler.value.style.left = "0";
handler.value.style.transition = "all 0.2s";
progressBar.value.style.width = "0";
state.isOk = false;
} else {
// 拖拽到末端,保持验证成功状态
handler.value.style.transition = "none";
handler.value.style.left = numericWidth - props.height + "px";
progressBar.value.style.width = numericWidth - props.height / 2 + "px";
passVerify();
}
state.isMoving = false;
}
};
/**
* 验证通过处理函数
*/
const passVerify = () => {
emit("update:value", true);
state.isMoving = false;
// 更新样式为成功状态
progressBar.value.style.background = props.completedBg;
messageRef.value.style["-webkit-text-fill-color"] = "unset";
messageRef.value.style.animation = "slidetounlock2 2s cubic-bezier(0, 0.2, 1, 1) infinite";
messageRef.value.style.color = "#fff";
emit("passCallback");
};
/**
* 重置验证状态函数
*/
const reset = () => {
// 重置滑块位置
handler.value.style.left = "0";
progressBar.value.style.width = "0";
progressBar.value.style.background = props.progressBarBg;
// 重置文本样式
messageRef.value.style["-webkit-text-fill-color"] = "transparent";
messageRef.value.style.animation = "slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite";
messageRef.value.style.color = props.background;
// 重置状态
emit("update:value", false);
state.isOk = false;
state.isMoving = false;
state.x = 0;
};
// 暴露重置方法给父组件
defineExpose({
reset,
});
</script>
<style lang="scss" scoped>
.drag_verify {
position: relative;
box-sizing: border-box;
overflow: hidden;
text-align: center;
border: 1px solid var(--default-border-dashed);
.dv_handler {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
i {
padding-left: 0;
font-size: 14px;
color: #999;
}
.el-icon-circle-check {
margin-top: 9px;
color: #6c6;
}
}
.dv_progress_bar {
position: absolute;
width: 0;
height: 34px;
}
.dv_text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: transparent;
user-select: none;
background: linear-gradient(
to right,
var(--textColor) 0%,
var(--textColor) 40%,
#fff 50%,
var(--textColor) 60%,
var(--textColor) 100%
);
-webkit-background-clip: text;
background-clip: text;
animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite;
-webkit-text-fill-color: transparent;
text-size-adjust: none;
* {
-webkit-text-fill-color: var(--textColor);
}
}
}
.goFirst {
left: 0 !important;
transition: left 0.5s;
}
.goFirst2 {
width: 0 !important;
transition: width 0.5s;
}
</style>
<style lang="scss">
@keyframes slidetounlock {
0% {
background-position: var(--pwidth) 0;
}
100% {
background-position: var(--width) 0;
}
}
@keyframes slidetounlock2 {
0% {
background-position: var(--pwidth) 0;
}
100% {
background-position: var(--pwidth) 0;
}
}
</style>
@@ -0,0 +1,541 @@
<!-- 导出 Excel 文件 -->
<template>
<ElButton
:type="type"
:size="size"
:loading="isExporting"
:disabled="disabled || !hasData"
v-ripple
@click="handleExport"
>
<template #loading>
<ElIcon class="is-loading">
<Loading />
</ElIcon>
{{ loadingText }}
</template>
<slot>{{ buttonText }}</slot>
</ElButton>
</template>
<script setup lang="ts">
import * as XLSX from "xlsx";
import FileSaver from "file-saver";
import { ref, computed, nextTick } from "vue";
import { Loading } from "@element-plus/icons-vue";
import type { ButtonType } from "element-plus";
import { useThrottleFn } from "@vueuse/core";
defineOptions({ name: "ArtExcelExport" });
/** 导出数据类型 */
type ExportValue = string | number | boolean | null | undefined | Date;
interface ExportData {
[key: string]: ExportValue;
}
/** 列配置 */
interface ColumnConfig {
/** 列标题 */
title: string;
/** 列宽度 */
width?: number;
/** 数据格式化函数 */
formatter?: (value: ExportValue, row: ExportData, index: number) => string;
}
/** 导出配置选项 */
interface ExportOptions {
/** 数据源 */
data: ExportData[];
/** 文件名(不含扩展名) */
filename?: string;
/** 工作表名称 */
sheetName?: string;
/** 按钮类型 */
type?: ButtonType;
/** 按钮尺寸 */
size?: "large" | "default" | "small";
/** 是否禁用 */
disabled?: boolean;
/** 按钮文本 */
buttonText?: string;
/** 加载中文本 */
loadingText?: string;
/** 是否自动添加序号列 */
autoIndex?: boolean;
/** 序号列标题 */
indexColumnTitle?: string;
/** 列配置映射 */
columns?: Record<string, ColumnConfig>;
/** 表头映射(简化版本,向后兼容) */
headers?: Record<string, string>;
/** 最大导出行数 */
maxRows?: number;
/** 是否显示成功消息 */
showSuccessMessage?: boolean;
/** 是否显示错误消息 */
showErrorMessage?: boolean;
/** 工作簿配置 */
workbookOptions?: {
/** 创建者 */
creator?: string;
/** 最后修改者 */
lastModifiedBy?: string;
/** 创建时间 */
created?: Date;
/** 修改时间 */
modified?: Date;
};
}
const props = withDefaults(defineProps<ExportOptions>(), {
filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
sheetName: "Sheet1",
type: "primary",
size: "default",
disabled: false,
buttonText: "导出 Excel",
loadingText: "导出中...",
autoIndex: false,
indexColumnTitle: "序号",
columns: () => ({}),
headers: () => ({}),
maxRows: 100000,
showSuccessMessage: true,
showErrorMessage: true,
workbookOptions: () => ({}),
});
const emit = defineEmits<{
"before-export": [data: ExportData[]];
"export-success": [filename: string, rowCount: number];
"export-error": [error: ExportError];
"export-progress": [progress: number];
}>();
/** 导出错误类型 */
class ExportError extends Error {
constructor(
message: string,
public code: string,
public details?: any
) {
super(message);
this.name = "ExportError";
}
}
const isExporting = ref(false);
/** 是否有数据可导出 */
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0);
/** 验证导出数据 */
const validateData = (data: ExportData[]): void => {
if (!Array.isArray(data)) {
throw new ExportError("数据必须是数组格式", "INVALID_DATA_TYPE");
}
if (data.length === 0) {
throw new ExportError("没有可导出的数据", "NO_DATA");
}
if (data.length > props.maxRows) {
throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, "EXCEED_MAX_ROWS", {
currentRows: data.length,
maxRows: props.maxRows,
});
}
};
/** 格式化单元格值 */
const formatCellValue = (
value: ExportValue,
key: string,
row: ExportData,
index: number
): string => {
// 使用列配置的格式化函数
const column = props.columns[key];
if (column?.formatter) {
return column.formatter(value, row, index);
}
// 处理特殊值
if (value === null || value === undefined) {
return "";
}
if (value instanceof Date) {
return value.toLocaleDateString("zh-CN");
}
if (typeof value === "boolean") {
return value ? "是" : "否";
}
return String(value);
};
/** 处理数据 */
const processData = (data: ExportData[]): Record<string, string>[] => {
const processedData = data.map((item, index) => {
const processedItem: Record<string, string> = {};
// 添加序号列
if (props.autoIndex) {
processedItem[props.indexColumnTitle] = String(index + 1);
}
// 处理数据列
Object.entries(item).forEach(([key, value]) => {
// 获取列标题
let columnTitle = key;
if (props.columns[key]?.title) {
columnTitle = props.columns[key].title;
} else if (props.headers[key]) {
columnTitle = props.headers[key];
}
// 格式化值
processedItem[columnTitle] = formatCellValue(value, key, item, index);
});
return processedItem;
});
return processedData;
};
/** 计算列宽度 */
const calculateColumnWidths = (data: Record<string, string>[]): XLSX.ColInfo[] => {
if (data.length === 0) return [];
const sampleSize = Math.min(data.length, 100); // 只取前100行计算列宽
const columns = Object.keys(data[0]);
return columns.map((column) => {
// 使用配置的列宽度
const configWidth = Object.values(props.columns).find((col) => col.title === column)?.width;
if (configWidth) {
return { wch: configWidth };
}
// 自动计算列宽度
const maxLength = Math.max(
column.length, // 标题长度
...data.slice(0, sampleSize).map((row) => String(row[column] || "").length)
);
// 限制最小和最大宽度
const width = Math.min(Math.max(maxLength + 2, 8), 50);
return { wch: width };
});
};
/** 导出到 Excel */
const exportToExcel = async (
data: ExportData[],
filename: string,
sheetName: string
): Promise<void> => {
try {
emit("export-progress", 10);
// 处理数据
const processedData = processData(data);
emit("export-progress", 30);
// 创建工作簿
const workbook = XLSX.utils.book_new();
// 设置工作簿属性
if (props.workbookOptions) {
workbook.Props = {
Title: filename,
Subject: "数据导出",
Author: props.workbookOptions.creator || "Art Design Pro",
Manager: props.workbookOptions.lastModifiedBy || "",
Company: "系统导出",
Category: "数据",
Keywords: "excel,export,data",
Comments: "由系统自动生成",
CreatedDate: props.workbookOptions.created || new Date(),
ModifiedDate: props.workbookOptions.modified || new Date(),
};
}
emit("export-progress", 50);
// 创建工作表
const worksheet = XLSX.utils.json_to_sheet(processedData);
// 设置列宽度
worksheet["!cols"] = calculateColumnWidths(processedData);
emit("export-progress", 70);
// 添加工作表到工作簿
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
emit("export-progress", 85);
// 生成 Excel 文件
const excelBuffer = XLSX.write(workbook, {
bookType: "xlsx",
type: "array",
compression: true,
});
// 创建 Blob 并下载
const blob = new Blob([excelBuffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
emit("export-progress", 95);
// 使用时间戳确保文件名唯一
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const finalFilename = `${filename}_${timestamp}.xlsx`;
FileSaver.saveAs(blob, finalFilename);
emit("export-progress", 100);
// 等待下载开始
await nextTick();
return Promise.resolve();
} catch (error) {
throw new ExportError(`Excel 导出失败: ${(error as Error).message}`, "EXPORT_FAILED", error);
}
};
/** 处理导出 */
const handleExport = useThrottleFn(async () => {
if (isExporting.value) return;
isExporting.value = true;
try {
// 验证数据
validateData(props.data);
// 触发导出前事件
emit("before-export", props.data);
// 执行导出
await exportToExcel(props.data, props.filename, props.sheetName);
// 触发成功事件
emit("export-success", props.filename, props.data.length);
// 显示成功消息
if (props.showSuccessMessage) {
ElMessage.success({
message: `成功导出 ${props.data.length} 条数据`,
duration: 3000,
});
}
} catch (error) {
const exportError =
error instanceof ExportError
? error
: new ExportError(`导出失败: ${(error as Error).message}`, "UNKNOWN_ERROR", error);
// 触发错误事件
emit("export-error", exportError);
// 显示错误消息
if (props.showErrorMessage) {
ElMessage.error({
message: exportError.message,
duration: 5000,
});
}
console.error("Excel 导出错误:", exportError);
} finally {
isExporting.value = false;
emit("export-progress", 0);
}
}, 1000);
// 暴露方法供父组件调用
defineExpose({
exportData: handleExport,
isExporting: readonly(isExporting),
hasData,
});
</script>
<style scoped>
.is-loading {
animation: rotating 2s linear infinite;
}
@keyframes rotating {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<!-- <template>
<div class="page-content">
<ArtExcelImport @import-success="handleImportSuccess" @import-error="handleImportError">
<template #import-text>上传 Excel</template>
</ArtExcelImport>
<ArtExcelExport
style="margin-left: 10px"
:data="tableData"
filename="用户数据-1"
sheetName="用户列表"
type="success"
:headers="headers"
auto-index
:columns="columnConfig"
@export-success="handleExportSuccess"
@export-error="handleExportError"
@export-progress="handleProgress"
>
导出 Excel
</ArtExcelExport>
<ElButton type="danger" @click="handleClear" v-ripple>清除数据</ElButton>
<ArtTable :data="tableData" style="margin-top: 10px">
<ElTableColumn
v-for="key in Object.keys(headers)"
:key="key"
:prop="key"
:label="headers[key as keyof typeof headers]"
/>
</ArtTable>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "WidgetsExcel" });
/**
* 表格数据类型定义
*/
interface TableData {
name: string;
age: number;
city: string;
}
/**
* 表格数据
*/
const tableData = ref<TableData[]>([
{ name: "李四", age: 20, city: "上海" },
{ name: "张三", age: 25, city: "北京" },
{ name: "王五", age: 30, city: "广州" },
{ name: "赵六", age: 35, city: "深圳" },
{ name: "孙七", age: 28, city: "杭州" },
{ name: "周八", age: 32, city: "成都" },
{ name: "吴九", age: 27, city: "武汉" },
{ name: "郑十", age: 40, city: "南京" },
{ name: "刘一", age: 22, city: "重庆" },
{ name: "陈二", age: 33, city: "西安" },
]);
/**
* 表头映射配置
* 用于 Excel 导入导出时的字段映射
*/
const headers = {
name: "姓名",
age: "年龄",
city: "城市",
};
/**
* 列配置
* 用于 Excel 导出时的列宽和格式化
*/
const columnConfig = {
name: {
title: "姓名",
width: 20,
formatter: (value: unknown) => (value ? String(value) : "未知"),
},
age: {
title: "年龄",
width: 10,
formatter: (value: unknown) => (value ? `${value}岁` : "0岁"),
},
city: {
title: "城市",
width: 12,
formatter: (value: unknown) => (value ? `${value}市` : "未知"),
},
};
/**
* 处理 Excel 导入成功
* 将导入的数据转换为表格数据格式
* @param data 导入的原始数据
*/
const handleImportSuccess = (data: Array<Record<string, unknown>>) => {
const formattedData: TableData[] = data.map((item) => ({
name: String(item["姓名"] || ""),
age: Number(item["年龄"]) || 0,
city: String(item["城市"] || ""),
}));
tableData.value = formattedData;
ElMessage.success(`成功导入 ${formattedData.length} 条数据`);
};
/**
* 处理 Excel 导入错误
* @param error 错误对象
*/
const handleImportError = (error: Error) => {
console.error("导入失败:", error);
ElMessage.error(`导入失败: ${error.message}`);
};
/**
* 处理 Excel 导出成功
*/
const handleExportSuccess = () => {
console.log("导出成功");
ElMessage.success("Excel 导出成功");
};
/**
* 处理 Excel 导出错误
* @param error 错误对象
*/
const handleExportError = (error: Error) => {
ElMessage.error(`导出失败: ${error.message}`);
};
/**
* 处理导出进度
* @param progress 导出进度百分比
*/
const handleProgress = (progress: number) => {
console.log("导出进度:", progress);
};
/**
* 清空表格数据
*/
const handleClear = () => {
tableData.value = [];
ElMessage.info("已清空数据");
};
</script> -->
@@ -0,0 +1,214 @@
<!-- 导入 Excel 文件 -->
<template>
<div class="inline-block">
<ElUpload
:auto-upload="false"
accept=".xlsx, .xls"
:show-file-list="false"
@change="handleFileChange"
>
<ElButton type="primary" v-ripple>
<slot>导入 Excel</slot>
</ElButton>
</ElUpload>
</div>
</template>
<script setup lang="ts">
import * as XLSX from "xlsx";
import type { UploadFile } from "element-plus";
defineOptions({ name: "ArtExcelImport" });
// Excel 导入工具函数
async function importExcel(file: File): Promise<Array<Record<string, unknown>>> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = e.target?.result;
const workbook = XLSX.read(data, { type: "array" });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const results = XLSX.utils.sheet_to_json(worksheet);
resolve(results as Array<Record<string, unknown>>);
} catch (error) {
reject(error);
}
};
reader.onerror = (error) => reject(error);
reader.readAsArrayBuffer(file);
});
}
// 定义 emits
const emit = defineEmits<{
"import-success": [data: Array<Record<string, unknown>>];
"import-error": [error: Error];
}>();
// 处理文件导入
const handleFileChange = async (uploadFile: UploadFile) => {
try {
if (!uploadFile.raw) return;
const results = await importExcel(uploadFile.raw);
emit("import-success", results);
} catch (error) {
emit("import-error", error as Error);
}
};
</script>
<!-- <template>
<div class="page-content">
<ArtExcelImport @import-success="handleImportSuccess" @import-error="handleImportError">
<template #import-text>上传 Excel</template>
</ArtExcelImport>
<ArtExcelExport
style="margin-left: 10px"
:data="tableData"
filename="用户数据-1"
sheetName="用户列表"
type="success"
:headers="headers"
auto-index
:columns="columnConfig"
@export-success="handleExportSuccess"
@export-error="handleExportError"
@export-progress="handleProgress"
>
导出 Excel
</ArtExcelExport>
<ElButton type="danger" @click="handleClear" v-ripple>清除数据</ElButton>
<ArtTable :data="tableData" style="margin-top: 10px">
<ElTableColumn
v-for="key in Object.keys(headers)"
:key="key"
:prop="key"
:label="headers[key as keyof typeof headers]"
/>
</ArtTable>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "WidgetsExcel" });
/**
* 表格数据类型定义
*/
interface TableData {
name: string;
age: number;
city: string;
}
/**
* 表格数据
*/
const tableData = ref<TableData[]>([
{ name: "李四", age: 20, city: "上海" },
{ name: "张三", age: 25, city: "北京" },
{ name: "王五", age: 30, city: "广州" },
{ name: "赵六", age: 35, city: "深圳" },
{ name: "孙七", age: 28, city: "杭州" },
{ name: "周八", age: 32, city: "成都" },
{ name: "吴九", age: 27, city: "武汉" },
{ name: "郑十", age: 40, city: "南京" },
{ name: "刘一", age: 22, city: "重庆" },
{ name: "陈二", age: 33, city: "西安" },
]);
/**
* 表头映射配置
* 用于 Excel 导入导出时的字段映射
*/
const headers = {
name: "姓名",
age: "年龄",
city: "城市",
};
/**
* 列配置
* 用于 Excel 导出时的列宽和格式化
*/
const columnConfig = {
name: {
title: "姓名",
width: 20,
formatter: (value: unknown) => (value ? String(value) : "未知"),
},
age: {
title: "年龄",
width: 10,
formatter: (value: unknown) => (value ? `${value}岁` : "0岁"),
},
city: {
title: "城市",
width: 12,
formatter: (value: unknown) => (value ? `${value}市` : "未知"),
},
};
/**
* 处理 Excel 导入成功
* 将导入的数据转换为表格数据格式
* @param data 导入的原始数据
*/
const handleImportSuccess = (data: Array<Record<string, unknown>>) => {
const formattedData: TableData[] = data.map((item) => ({
name: String(item["姓名"] || ""),
age: Number(item["年龄"]) || 0,
city: String(item["城市"] || ""),
}));
tableData.value = formattedData;
ElMessage.success(`成功导入 ${formattedData.length} 条数据`);
};
/**
* 处理 Excel 导入错误
* @param error 错误对象
*/
const handleImportError = (error: Error) => {
console.error("导入失败:", error);
ElMessage.error(`导入失败: ${error.message}`);
};
/**
* 处理 Excel 导出成功
*/
const handleExportSuccess = () => {
console.log("导出成功");
ElMessage.success("Excel 导出成功");
};
/**
* 处理 Excel 导出错误
* @param error 错误对象
*/
const handleExportError = (error: Error) => {
ElMessage.error(`导出失败: ${error.message}`);
};
/**
* 处理导出进度
* @param progress 导出进度百分比
*/
const handleProgress = (progress: number) => {
console.log("导出进度:", progress);
};
/**
* 清空表格数据
*/
const handleClear = () => {
tableData.value = [];
ElMessage.info("已清空数据");
};
</script> -->
@@ -0,0 +1,507 @@
<!-- 表单组件 -->
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
<template>
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
<ElForm
ref="formRef"
:model="modelValue"
:label-position="labelPosition"
v-bind="{ ...$attrs }"
>
<ElRow class="flex flex-wrap" :gutter="gutter">
<ElCol
v-for="item in visibleFormItems"
:key="item.key"
:xs="getColSpan(item.span, 'xs')"
:sm="getColSpan(item.span, 'sm')"
:md="getColSpan(item.span, 'md')"
:lg="getColSpan(item.span, 'lg')"
:xl="getColSpan(item.span, 'xl')"
>
<ElFormItem
:prop="item.key"
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
>
<template #label v-if="item.label">
<component v-if="typeof item.label !== 'string'" :is="item.label" />
<span v-else>{{ item.label }}</span>
</template>
<slot :name="item.key" :item="item" :modelValue="modelValue">
<component
:is="getComponent(item)"
:model-value="getFieldValue(item.key)"
@update:model-value="setFieldValue(item.key, $event)"
v-bind="getProps(item)"
>
<!-- 下拉选择 -->
<template v-if="item.type === 'select' && getProps(item)?.options">
<ElOption
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 复选框组 -->
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
<ElCheckbox
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 单选框组 -->
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
<ElRadio
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 动态插槽支持 -->
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
<component :is="slotFn" />
</template>
</component>
</slot>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
<div
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
:style="actionButtonsStyle"
>
<div class="flex gap-2 md:justify-center">
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
{{ t("table.form.reset") }}
</ElButton>
<ElButton
v-if="showSubmit"
type="primary"
class="submit-button"
@click="handleSubmit"
v-ripple
:disabled="disabledSubmit"
>
{{ t("table.form.submit") }}
</ElButton>
</div>
</div>
</ElCol>
</ElRow>
</ElForm>
</section>
</template>
<script setup lang="ts">
import { useWindowSize } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { toRaw, type Component } from "vue";
import {
ElCascader,
ElCheckbox,
ElCheckboxGroup,
ElDatePicker,
ElInput,
ElInputTag,
ElInputNumber,
ElRadioGroup,
ElRate,
ElSelect,
ElSlider,
ElSwitch,
ElTimePicker,
ElTimeSelect,
ElTreeSelect,
type FormInstance,
} from "element-plus";
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from "@utils/form";
defineOptions({ name: "ArtForm" });
const componentMap = {
input: ElInput, // 输入框
inputtag: ElInputTag, // 标签输入框
number: ElInputNumber, // 数字输入框
select: ElSelect, // 选择器
switch: ElSwitch, // 开关
checkbox: ElCheckbox, // 复选框
checkboxgroup: ElCheckboxGroup, // 复选框组
radiogroup: ElRadioGroup, // 单选框组
date: ElDatePicker, // 日期选择器
daterange: ElDatePicker, // 日期范围选择器
datetime: ElDatePicker, // 日期时间选择器
datetimerange: ElDatePicker, // 日期时间范围选择器
rate: ElRate, // 评分
slider: ElSlider, // 滑块
cascader: ElCascader, // 级联选择器
timepicker: ElTimePicker, // 时间选择器
timeselect: ElTimeSelect, // 时间选择
treeselect: ElTreeSelect, // 树选择器
};
const { width } = useWindowSize();
const { t } = useI18n();
const isMobile = computed(() => width.value < 500);
const formInstance = useTemplateRef<FormInstance>("formRef");
// 表单项配置
export interface FormItem {
/** 表单项的唯一标识 */
key: string;
/** 表单项的标签文本或自定义渲染函数 */
label: string | (() => VNode) | Component;
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
labelWidth?: string | number;
/** 表单项类型,支持预定义的组件类型 */
type?: keyof typeof componentMap | string;
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
render?: (() => VNode) | Component;
/** 是否隐藏该表单项 */
hidden?: boolean;
/** 表单项占据的列宽,基于24格栅格系统 */
span?: number;
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
options?: Record<string, any>;
/** 传递给表单项组件的属性 */
props?: Record<string, any>;
/** 表单项的插槽配置 */
slots?: Record<string, (() => any) | undefined>;
/** 表单项的占位符文本 */
placeholder?: string;
/** 更多属性配置请参考 ElementPlus 官方文档 */
}
// 表单配置
interface FormProps {
/** 表单数据 */
items: FormItem[];
/** 每列的宽度(基于 24 格布局) */
span?: number;
/** 表单控件间隙 */
gutter?: number;
/** 表单域标签的位置 */
labelPosition?: "left" | "right" | "top";
/** 文字宽度 */
labelWidth?: string | number;
/** 按钮靠左对齐限制(表单项小于等于该值时) */
buttonLeftLimit?: number;
/** 是否显示重置按钮 */
showReset?: boolean;
/** 是否显示提交按钮 */
showSubmit?: boolean;
/** 是否禁用提交按钮 */
disabledSubmit?: boolean;
/** 提交时是否清洗空值 */
sanitizeOutput?: Partial<SanitizeOutputOptions>;
}
interface SanitizeOutputOptions {
/** 移除空字符串 */
removeEmptyString: boolean;
/** 移除空数组 */
removeEmptyArray: boolean;
/** 移除清洗后为空的对象 */
removeEmptyObject: boolean;
/** 移除空富文本占位内容,如 <p><br></p> */
removeEmptyRichText: boolean;
/** 保留数字 0 这类有效值 */
keepZero: boolean;
/** 保留 false 这类有效值 */
keepFalse: boolean;
}
const props = withDefaults(defineProps<FormProps>(), {
items: () => [],
span: 6,
gutter: 12,
labelPosition: "right",
labelWidth: "70px",
buttonLeftLimit: 2,
showReset: true,
showSubmit: true,
disabledSubmit: false,
sanitizeOutput: () => ({}),
});
interface FormEmits {
reset: [];
submit: [Record<string, any>];
}
const emit = defineEmits<FormEmits>();
const modelValue = defineModel<Record<string, any>>({ default: {} });
const initialModelValue = ref<Record<string, any>>({});
// 保存组件初始化时的表单快照,用于 reset 时恢复默认值。
const cloneModelValue = (value: Record<string, any> | undefined) => {
if (!value) return {};
const deepClone = (source: unknown): unknown => {
if (Array.isArray(source)) {
return source.map((item) => deepClone(item));
}
if (source && typeof source === "object") {
const rawSource = toRaw(source);
return Object.keys(rawSource).reduce<Record<string, unknown>>((accumulator, key) => {
accumulator[key] = deepClone((rawSource as Record<string, unknown>)[key]);
return accumulator;
}, {});
}
return source;
};
return deepClone(toRaw(value)) as Record<string, any>;
};
initialModelValue.value = cloneModelValue(modelValue.value);
const rootProps = ["label", "labelWidth", "key", "type", "hidden", "span", "slots"];
// 输出时的清洗策略默认偏“接口友好”,但允许按业务覆盖。
const sanitizeOutputOptions = computed<SanitizeOutputOptions>(() => ({
removeEmptyString: true,
removeEmptyArray: true,
removeEmptyObject: true,
removeEmptyRichText: true,
keepZero: true,
keepFalse: true,
...props.sanitizeOutput,
}));
const PATH_NUMBER_RE = /^\d+$/;
// 兼容 a.b、a.0.b 这类路径写法,数字段会被当作数组索引处理。
const parsePath = (path: string) => {
return path
.split(".")
.filter(Boolean)
.map((segment) => (PATH_NUMBER_RE.test(segment) ? Number(segment) : segment));
};
const getFieldValue = (path: string) => {
return parsePath(path).reduce<any>((currentValue, segment) => {
if (currentValue == null) return undefined;
return currentValue[segment];
}, modelValue.value);
};
// 清空字段时只删除路径的最后一段,避免误删同级数据。
const deleteFieldValue = (path: string) => {
const segments = parsePath(path);
if (!segments.length) return;
const lastSegment = segments.pop();
const parent = segments.reduce<any>((currentValue, segment) => {
if (currentValue == null) return undefined;
return currentValue[segment];
}, modelValue.value);
if (parent != null && lastSegment !== undefined) {
delete parent[lastSegment];
}
};
// 表单清空输入时不保留空字符串,同时按路径自动补齐中间对象或数组。
const setFieldValue = (path: string, value: unknown) => {
const normalizedValue = value === "" ? undefined : value;
const segments = parsePath(path);
if (!segments.length) return;
if (normalizedValue === undefined) {
deleteFieldValue(path);
return;
}
let currentValue: any = modelValue.value;
segments.forEach((segment, index) => {
const isLast = index === segments.length - 1;
if (isLast) {
currentValue[segment] = normalizedValue;
return;
}
const nextSegment = segments[index + 1];
const nextContainer = typeof nextSegment === "number" ? [] : {};
if (
currentValue[segment] === null ||
currentValue[segment] === undefined ||
typeof currentValue[segment] !== "object"
) {
currentValue[segment] = nextContainer;
}
currentValue = currentValue[segment];
});
};
const isRichTextEmpty = (value: string) => {
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
return false;
}
// 去掉编辑器常见占位标签后再判断是否还有实际内容。
return (
value
.replace(/&nbsp;/gi, "")
.replace(/<br\s*\/?>/gi, "")
.replace(/<[^>]*>/g, "")
.trim() === ""
);
};
// 提交时按配置清洗空值,但保留 0 和 false 这类有效值。
const sanitizeOutputValue = (value: unknown): unknown => {
const options = sanitizeOutputOptions.value;
if (Array.isArray(value)) {
const sanitizedArray = value
.map((item) => sanitizeOutputValue(item))
.filter((item) => item !== undefined);
return sanitizedArray.length === 0 && options.removeEmptyArray ? undefined : sanitizedArray;
}
if (value && typeof value === "object") {
const rawValue = toRaw(value);
const sanitizedObject = Object.entries(rawValue).reduce<Record<string, unknown>>(
(accumulator, [key, item]) => {
const sanitizedItem = sanitizeOutputValue(item);
if (sanitizedItem !== undefined) {
accumulator[key] = sanitizedItem;
}
return accumulator;
},
{}
);
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
? undefined
: sanitizedObject;
}
if (typeof value === "string") {
if (options.removeEmptyString && value.trim() === "") {
return undefined;
}
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
return undefined;
}
return value;
}
if (value === 0) {
return options.keepZero ? value : undefined;
}
if (value === false) {
return options.keepFalse ? value : undefined;
}
return value ?? undefined;
};
const getSanitizedOutput = () => {
return (sanitizeOutputValue(cloneModelValue(modelValue.value)) || {}) as Record<string, any>;
};
const getProps = (item: FormItem) => {
if (item.props) return item.props;
const props = { ...item };
rootProps.forEach((key) => delete (props as Record<string, any>)[key]);
return props;
};
// 获取插槽
const getSlots = (item: FormItem) => {
if (!item.slots) return {};
const validSlots: Record<string, () => any> = {};
Object.entries(item.slots).forEach(([key, slotFn]) => {
if (slotFn) {
validSlots[key] = slotFn;
}
});
return validSlots;
};
// 组件
const getComponent = (item: FormItem) => {
// 优先使用 render 函数或组件渲染自定义组件
if (item.render) {
return item.render;
}
// 使用 type 获取预定义组件
const { type } = item;
return componentMap[type as keyof typeof componentMap] || componentMap["input"];
};
/**
* 获取列宽 span 值
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
*/
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
return calculateResponsiveSpan(itemSpan, span.value, breakpoint);
};
/**
* 可见的表单项
*/
const visibleFormItems = computed(() => {
return props.items.filter((item) => !item.hidden);
});
/**
* 操作按钮样式
*/
const actionButtonsStyle = computed(() => ({
"justify-content": isMobile.value
? "flex-end"
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
? "flex-start"
: "flex-end",
}));
/**
* 处理重置事件
*/
const handleReset = () => {
// 重置表单字段(UI 层)
formInstance.value?.resetFields();
// 恢复初始表单值,保留默认值而不是简单清空。
Object.keys(modelValue.value).forEach((key) => {
delete modelValue.value[key];
});
Object.assign(modelValue.value, cloneModelValue(initialModelValue.value));
// 触发 reset 事件
emit("reset");
};
/**
* 处理提交事件
*/
const handleSubmit = () => {
// 对外只抛出清洗后的结果,避免业务层重复过滤空值。
emit("submit", getSanitizedOutput());
};
defineExpose({
ref: formInstance,
validate: (...args: any[]) => formInstance.value?.validate(...args),
reset: handleReset,
// 允许外部在不触发提交事件时主动获取清洗后的输出。
getOutput: getSanitizedOutput,
});
// 解构 props 以便在模板中直接使用
const { span, gutter, labelPosition, labelWidth } = toRefs(props);
</script>
@@ -0,0 +1,94 @@
<!-- 在 ArtSearchBar 上追加「创建人 / 更新人 / 创建时间 / 更新时间」,并内置 UserTableSelect 插槽;业务页只传自己的 items 即可 -->
<template>
<ArtSearchBar
ref="innerRef"
v-model="modelValue"
v-bind="forwardedAttrs"
:items="mergedItems"
@search="(p) => emit('search', p)"
@reset="emit('reset')"
>
<template v-for="(_, name) in $slots" :key="name" #[name]="scope">
<slot :name="name" v-bind="scope || {}" />
</template>
<template v-if="!$slots.created_id" #created_id>
<div class="w-full min-w-0">
<UserTableSelect
:model-value="modelValue?.created_id == null ? undefined : modelValue.created_id"
@update:model-value="(v) => patchField('created_id', v)"
@confirm-click="emitImmediateSearch"
@clear-click="emitImmediateSearch"
/>
</div>
</template>
<template v-if="!$slots.updated_id" #updated_id>
<div class="w-full min-w-0">
<UserTableSelect
:model-value="modelValue?.updated_id == null ? undefined : modelValue.updated_id"
@update:model-value="(v) => patchField('updated_id', v)"
@confirm-click="emitImmediateSearch"
@clear-click="emitImmediateSearch"
/>
</div>
</template>
</ArtSearchBar>
</template>
<script setup lang="ts">
import { computed, ref, useAttrs } from "vue";
import ArtSearchBar from "./index.vue";
import type { SearchFormItem } from "./index.vue";
import {
getAuditSearchFormItems,
type GetAuditSearchFormItemsOptions,
} from "./auditSearchFormItems";
import UserTableSelect from "@views/module_system/user/components/UserTableSelect.vue";
defineOptions({ name: "ArtSearchBarWithAudit", inheritAttrs: false });
const props = withDefaults(
defineProps<{
/** 仅业务条件表单项,不含审计四字段 */
items: SearchFormItem[];
/** 为 false 时与原生 ArtSearchBar 一致,仅使用 `items` */
includeAudit?: boolean;
/** 传给 getAuditSearchFormItems 的选项 */
auditItemOptions?: GetAuditSearchFormItemsOptions;
}>(),
{ includeAudit: true }
);
const emit = defineEmits<{
search: [Record<string, any>];
reset: [];
}>();
const modelValue = defineModel<Record<string, any>>({ default: () => ({}) });
const attrs = useAttrs();
const innerRef = ref<InstanceType<typeof ArtSearchBar> | null>(null);
const forwardedAttrs = computed(() => attrs as Record<string, unknown>);
const auditItems = computed(() => getAuditSearchFormItems(props.auditItemOptions));
const mergedItems = computed(() => {
if (!props.includeAudit) return props.items;
return [...props.items, ...auditItems.value];
});
function patchField(key: "created_id" | "updated_id", val: number | undefined) {
modelValue.value = { ...modelValue.value, [key]: val };
}
function emitImmediateSearch() {
emit("search", { ...modelValue.value });
}
defineExpose({
validate: (...args: any[]) => innerRef.value?.validate?.(...args),
reset: () => innerRef.value?.reset?.(),
getOutput: () => innerRef.value?.getOutput?.(),
ref: computed(() => innerRef.value?.ref),
});
</script>
@@ -0,0 +1,90 @@
import type { SearchFormItem } from "./index.vue";
/** 与创建/更新人、时间范围配套的后端查询字段(可与其他业务条件组合) */
export type AuditSearchFormParams = {
created_id?: number | null;
updated_id?: number | null;
created_time?: string[];
updated_time?: string[];
};
export interface GetAuditSearchFormItemsOptions {
/** 栅格列宽,与 ArtSearchBar `span` 一致时建议传相同值,默认 6 */
span?: number;
createdByLabel?: string;
updatedByLabel?: string;
createdTimeLabel?: string;
updatedTimeLabel?: string;
createdByPlaceholder?: string;
updatedByPlaceholder?: string;
/** 日期时间范围 valueFormat,默认 YYYY-MM-DD HH:mm:ss */
valueFormat?: string;
rangeSeparator?: string;
startPlaceholder?: string;
endPlaceholder?: string;
}
/**
* 常见「创建人 / 更新人 / 创建时间 / 更新时间」搜索项,供 ArtSearchBar `items` 使用。
* 创建人、更新人需配合 `#created_id`、`#updated_id` 插槽(如 ArtSearchBarWithAudit)。
*/
export function getAuditSearchFormItems(
options?: GetAuditSearchFormItemsOptions
): SearchFormItem[] {
const span = options?.span ?? 6;
const valueFormat = options?.valueFormat ?? "YYYY-MM-DD HH:mm:ss";
const rangeSep = options?.rangeSeparator ?? "至";
const sp = options?.startPlaceholder ?? "开始";
const ep = options?.endPlaceholder ?? "结束";
return [
{
label: options?.createdByLabel ?? "创建人",
key: "created_id",
type: "input",
props: {
placeholder: options?.createdByPlaceholder ?? "请选择创建人",
style: { width: "100%" },
},
span,
},
{
label: options?.updatedByLabel ?? "更新人",
key: "updated_id",
type: "input",
props: {
placeholder: options?.updatedByPlaceholder ?? "请选择更新人",
style: { width: "100%" },
},
span,
},
{
label: options?.createdTimeLabel ?? "创建时间",
key: "created_time",
type: "datetimerange",
props: {
style: { width: "100%" },
type: "datetimerange",
rangeSeparator: rangeSep,
startPlaceholder: sp,
endPlaceholder: ep,
valueFormat,
},
span,
},
{
label: options?.updatedTimeLabel ?? "更新时间",
key: "updated_time",
type: "datetimerange",
props: {
style: { width: "100%" },
type: "datetimerange",
rangeSeparator: rangeSep,
startPlaceholder: sp,
endPlaceholder: ep,
valueFormat,
},
span,
},
];
}
@@ -0,0 +1,587 @@
<!-- 表格搜索组件 -->
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
<template>
<section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }">
<ElForm
ref="formRef"
:model="modelValue"
:label-position="labelPosition"
v-bind="{ ...$attrs }"
>
<ElRow :gutter="gutter">
<ElCol
v-for="item in visibleFormItems"
:key="item.key"
:xs="getColSpan(item.span, 'xs')"
:sm="getColSpan(item.span, 'sm')"
:md="getColSpan(item.span, 'md')"
:lg="getColSpan(item.span, 'lg')"
:xl="getColSpan(item.span, 'xl')"
>
<ElFormItem
:prop="item.key"
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
>
<template #label v-if="item.label">
<component v-if="typeof item.label !== 'string'" :is="item.label" />
<span v-else>{{ item.label }}</span>
</template>
<slot :name="item.key" :item="item" :modelValue="modelValue">
<component
:is="getComponent(item)"
:model-value="getFieldValue(item.key)"
@update:model-value="setFieldValue(item.key, $event)"
v-bind="getProps(item)"
>
<!-- 下拉选择 -->
<template v-if="item.type === 'select' && getProps(item)?.options">
<ElOption
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 复选框组 -->
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
<ElCheckbox
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 单选框组 -->
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
<ElRadio
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 动态插槽支持 -->
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
<component :is="slotFn" />
</template>
</component>
</slot>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
<div class="form-buttons">
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
<template #icon>
<Refresh />
</template>
{{ t("table.searchBar.reset") }}
</ElButton>
<ElButton
v-if="showSearch"
type="primary"
class="search-button"
@click="handleSearch"
v-ripple
:disabled="disabledSearch"
>
<template #icon>
<Search />
</template>
{{ t("table.searchBar.search") }}
</ElButton>
</div>
<div v-if="shouldShowExpandToggle" class="filter-toggle" @click="toggleExpand">
<span>{{ expandToggleText }}</span>
<div class="icon-wrapper">
<ElIcon>
<ArrowUpBold v-if="isExpanded" />
<ArrowDownBold v-else />
</ElIcon>
</div>
</div>
</div>
</ElCol>
</ElRow>
</ElForm>
</section>
</template>
<script setup lang="ts">
import { ArrowUpBold, ArrowDownBold, Refresh, Search } from "@element-plus/icons-vue";
import { useWindowSize } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { toRaw, type Component } from "vue";
import {
ElCascader,
ElCheckbox,
ElCheckboxGroup,
ElDatePicker,
ElInput,
ElInputTag,
ElInputNumber,
ElRadioGroup,
ElRate,
ElSelect,
ElSlider,
ElSwitch,
ElTimePicker,
ElTimeSelect,
ElTreeSelect,
type FormInstance,
} from "element-plus";
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from "@utils/form";
defineOptions({ name: "ArtSearchBar" });
const componentMap = {
input: ElInput, // 输入框
inputTag: ElInputTag, // 标签输入框
number: ElInputNumber, // 数字输入框
select: ElSelect, // 选择器
switch: ElSwitch, // 开关
checkbox: ElCheckbox, // 复选框
checkboxgroup: ElCheckboxGroup, // 复选框组
radiogroup: ElRadioGroup, // 单选框组
date: ElDatePicker, // 日期选择器
daterange: ElDatePicker, // 日期范围选择器
datetime: ElDatePicker, // 日期时间选择器
datetimerange: ElDatePicker, // 日期时间范围选择器
rate: ElRate, // 评分
slider: ElSlider, // 滑块
cascader: ElCascader, // 级联选择器
timepicker: ElTimePicker, // 时间选择器
timeselect: ElTimeSelect, // 时间选择
treeselect: ElTreeSelect, // 树选择器
};
const { width } = useWindowSize();
const { t } = useI18n();
const isMobile = computed(() => width.value < 500);
const formInstance = useTemplateRef<FormInstance>("formRef");
// 表单项配置
export interface SearchFormItem {
/** 表单项的唯一标识 */
key: string;
/** 表单项的标签文本或自定义渲染函数 */
label: string | (() => VNode) | Component;
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
labelWidth?: string | number;
/** 表单项类型,支持预定义的组件类型 */
type?: keyof typeof componentMap | string;
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
render?: (() => VNode) | Component;
/** 是否隐藏该表单项 */
hidden?: boolean;
/** 表单项占据的列宽,基于24格栅格系统 */
span?: number;
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
options?: Record<string, any>;
/** 传递给表单项组件的属性 */
props?: Record<string, any>;
/** 表单项的插槽配置 */
slots?: Record<string, (() => any) | undefined>;
/** 表单项的占位符文本 */
placeholder?: string;
/** 更多属性配置请参考 ElementPlus 官方文档 */
}
// 表单配置
interface SearchBarProps {
/** 表单数据 */
items: SearchFormItem[];
/** 每列的宽度(基于 24 格布局) */
span?: number;
/** 表单控件间隙 */
gutter?: number;
/** 展开/收起 */
isExpand?: boolean;
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
defaultExpanded?: boolean;
/** 表单域标签的位置 */
labelPosition?: "left" | "right" | "top";
/** 文字宽度 */
labelWidth?: string | number;
/** 是否需要展示,收起 */
showExpand?: boolean;
/** 按钮靠左对齐限制(表单项小于等于该值时) */
buttonLeftLimit?: number;
/** 是否显示重置按钮 */
showReset?: boolean;
/** 是否显示搜索按钮 */
showSearch?: boolean;
/** 是否禁用搜索按钮 */
disabledSearch?: boolean;
/** 搜索时是否清洗空值 */
sanitizeOutput?: Partial<SanitizeOutputOptions>;
}
interface SanitizeOutputOptions {
/** 移除空字符串 */
removeEmptyString: boolean;
/** 移除空数组 */
removeEmptyArray: boolean;
/** 移除清洗后为空的对象 */
removeEmptyObject: boolean;
/** 移除空富文本占位内容,如 <p><br></p> */
removeEmptyRichText: boolean;
/** 保留数字 0 这类有效筛选值 */
keepZero: boolean;
/** 保留 false 这类有效筛选值 */
keepFalse: boolean;
}
const props = withDefaults(defineProps<SearchBarProps>(), {
items: () => [],
span: 6,
gutter: 12,
isExpand: false,
labelPosition: "right",
labelWidth: "70px",
showExpand: true,
defaultExpanded: false,
buttonLeftLimit: 2,
showReset: true,
showSearch: true,
disabledSearch: false,
sanitizeOutput: () => ({}),
});
interface SearchBarEmits {
reset: [];
search: [Record<string, any>];
}
const emit = defineEmits<SearchBarEmits>();
const modelValue = defineModel<Record<string, any>>({ default: {} });
const initialModelValue = ref<Record<string, any>>({});
// 保存组件初始化时的表单快照,用于 reset 时恢复默认筛选条件。
const cloneModelValue = (value: Record<string, any> | undefined) => {
if (!value) return {};
const deepClone = (source: unknown): unknown => {
if (Array.isArray(source)) {
return source.map((item) => deepClone(item));
}
if (source && typeof source === "object") {
const rawSource = toRaw(source);
return Object.keys(rawSource).reduce<Record<string, unknown>>((accumulator, key) => {
accumulator[key] = deepClone((rawSource as Record<string, unknown>)[key]);
return accumulator;
}, {});
}
return source;
};
return deepClone(toRaw(value)) as Record<string, any>;
};
initialModelValue.value = cloneModelValue(modelValue.value);
/**
* 是否展开状态
*/
const isExpanded = ref(props.defaultExpanded);
const rootProps = ["label", "labelWidth", "key", "type", "hidden", "span", "slots"];
// 搜索参数默认更激进地去掉空值,减少无效 query 参数。
const sanitizeOutputOptions = computed<SanitizeOutputOptions>(() => ({
removeEmptyString: true,
removeEmptyArray: true,
removeEmptyObject: true,
removeEmptyRichText: true,
keepZero: true,
keepFalse: true,
...props.sanitizeOutput,
}));
const getProps = (item: SearchFormItem) => {
if (item.props) return item.props;
const props = { ...item };
rootProps.forEach((key) => delete (props as Record<string, any>)[key]);
return props;
};
// 获取插槽
const getSlots = (item: SearchFormItem) => {
if (!item.slots) return {};
const validSlots: Record<string, () => any> = {};
Object.entries(item.slots).forEach(([key, slotFn]) => {
if (slotFn) {
validSlots[key] = slotFn;
}
});
return validSlots;
};
/**
* 获取列宽 span 值
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
*/
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
return calculateResponsiveSpan(itemSpan, span.value, breakpoint);
};
// 搜索表单清空输入时不保留空字符串,避免后续请求携带空字段。
const normalizeFieldValue = (value: unknown) => {
return value === "" ? undefined : value;
};
const getFieldValue = (key: string) => modelValue.value[key];
const setFieldValue = (key: string, value: unknown) => {
const normalizedValue = normalizeFieldValue(value);
if (normalizedValue === undefined) {
delete modelValue.value[key];
return;
}
modelValue.value[key] = normalizedValue;
};
const isRichTextEmpty = (value: string) => {
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
return false;
}
// 去掉编辑器常见占位标签后再判断是否还有实际内容。
return (
value
.replace(/&nbsp;/gi, "")
.replace(/<br\s*\/?>/gi, "")
.replace(/<[^>]*>/g, "")
.trim() === ""
);
};
// 搜索时按配置清洗空值,但保留 0 和 false 这类有效筛选条件。
const sanitizeOutputValue = (value: unknown): unknown => {
const options = sanitizeOutputOptions.value;
if (Array.isArray(value)) {
const sanitizedArray = value
.map((item) => sanitizeOutputValue(item))
.filter((item) => item !== undefined);
return sanitizedArray.length === 0 && options.removeEmptyArray ? undefined : sanitizedArray;
}
if (value && typeof value === "object") {
const rawValue = toRaw(value);
const sanitizedObject = Object.entries(rawValue).reduce<Record<string, unknown>>(
(accumulator, [key, item]) => {
const sanitizedItem = sanitizeOutputValue(item);
if (sanitizedItem !== undefined) {
accumulator[key] = sanitizedItem;
}
return accumulator;
},
{}
);
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
? undefined
: sanitizedObject;
}
if (typeof value === "string") {
if (options.removeEmptyString && value.trim() === "") {
return undefined;
}
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
return undefined;
}
return value;
}
if (value === 0) {
return options.keepZero ? value : undefined;
}
if (value === false) {
return options.keepFalse ? value : undefined;
}
return value ?? undefined;
};
const getSanitizedOutput = () => {
return (sanitizeOutputValue(cloneModelValue(modelValue.value)) || {}) as Record<string, any>;
};
// 组件
const getComponent = (item: SearchFormItem) => {
// 优先使用 render 函数或组件渲染自定义组件
if (item.render) {
return item.render;
}
// 使用 type 获取预定义组件
const { type } = item;
return componentMap[type as keyof typeof componentMap] || componentMap["input"];
};
/**
* 可见的表单项
*/
const visibleFormItems = computed(() => {
const filteredItems = props.items.filter((item) => !item.hidden);
const shouldShowLess = !props.isExpand && !isExpanded.value;
if (shouldShowLess) {
const maxItemsPerRow = Math.floor(24 / props.span) - 1;
return filteredItems.slice(0, maxItemsPerRow);
}
return filteredItems;
});
/**
* 是否应该显示展开/收起按钮
*/
const shouldShowExpandToggle = computed(() => {
const filteredItems = props.items.filter((item) => !item.hidden);
return (
!props.isExpand && props.showExpand && filteredItems.length > Math.floor(24 / props.span) - 1
);
});
/**
* 展开/收起按钮文本
*/
const expandToggleText = computed(() => {
return isExpanded.value ? t("table.searchBar.collapse") : t("table.searchBar.expand");
});
/**
* 操作按钮样式
*/
const actionButtonsStyle = computed(() => ({
"justify-content": isMobile.value
? "flex-end"
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
? "flex-start"
: "flex-end",
}));
/**
* 切换展开/收起状态
*/
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
/**
* 处理重置事件
*/
const handleReset = () => {
// 重置表单字段(UI 层)
formInstance.value?.resetFields();
// 恢复初始表单值,保留默认搜索条件而不是简单清空。
Object.keys(modelValue.value).forEach((key) => {
delete modelValue.value[key];
});
Object.assign(modelValue.value, cloneModelValue(initialModelValue.value));
// 触发 reset 事件
emit("reset");
};
/**
* 处理搜索事件
*/
const handleSearch = () => {
// 对外只抛出清洗后的查询参数,避免接口收到空数组/空字符串。
emit("search", getSanitizedOutput());
};
defineExpose({
ref: formInstance,
validate: (...args: any[]) => formInstance.value?.validate(...args),
reset: handleReset,
// 允许外部在手动组装请求前直接读取清洗后的参数。
getOutput: getSanitizedOutput,
});
// 解构 props 以便在模板中直接使用
const { span, gutter, labelPosition, labelWidth } = toRefs(props);
</script>
<style lang="scss" scoped>
.art-search-bar {
padding: 15px 20px 0;
.action-column {
flex: 1;
max-width: 100%;
.action-buttons-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
margin-bottom: 12px;
}
.form-buttons {
display: flex;
gap: 8px;
}
.filter-toggle {
display: flex;
align-items: center;
margin-left: 10px;
line-height: 32px;
color: var(--theme-color);
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: var(--ElColor-primary);
}
span {
font-size: 14px;
user-select: none;
}
.icon-wrapper {
display: flex;
align-items: center;
margin-left: 4px;
font-size: 14px;
transition: transform 0.2s ease;
}
}
}
}
// 响应式优化
@media (width <= 768px) {
.art-search-bar {
padding: 16px 16px 0;
.action-column {
.action-buttons-wrapper {
flex-direction: column;
gap: 8px;
align-items: stretch;
.form-buttons {
justify-content: center;
}
.filter-toggle {
justify-content: center;
margin-left: 0;
}
}
}
}
}
</style>
@@ -0,0 +1,262 @@
<!-- WangEditor 富文本编辑器 插件地址:https://www.wangeditor.com/ -->
<template>
<div class="editor-wrapper">
<Toolbar
class="editor-toolbar"
:editor="editorRef"
:mode="mode"
:defaultConfig="toolbarConfig"
/>
<Editor
:style="{ height: height, overflowY: 'hidden' }"
v-model="modelValue"
:mode="mode"
:defaultConfig="editorConfig"
@onCreated="onCreateEditor"
/>
</div>
</template>
<script setup lang="ts">
import "@wangeditor/editor/dist/css/style.css";
import { onBeforeUnmount, onMounted, shallowRef, computed } from "vue";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import { useUserStore } from "@stores/modules/user.store";
import { EmojiText } from "@utils/ui";
import { IDomEditor, IToolbarConfig, IEditorConfig } from "@wangeditor/editor";
import request from "@utils/http";
import type { AxiosResponse } from "axios";
defineOptions({ name: "ArtWangEditor" });
type InsertFnType = (url: string, alt: string, href: string) => void;
const { VITE_API_URL } = import.meta.env;
// Props 定义
interface Props {
/** 编辑器高度 */
height?: string;
/** 自定义工具栏配置 */
toolbarKeys?: string[];
/** 插入新工具到指定位置 */
insertKeys?: { index: number; keys: string[] };
/** 排除的工具栏项 */
excludeKeys?: string[];
/** 编辑器模式 */
mode?: "default" | "simple";
/** 占位符文本 */
placeholder?: string;
/** 上传配置 */
uploadConfig?: {
maxFileSize?: number;
maxNumberOfFiles?: number;
server?: string;
// 是否开启自定义上传
isCustomUpload?: boolean;
};
}
const props = withDefaults(defineProps<Props>(), {
height: "500px",
mode: "default",
placeholder: "请输入内容...",
excludeKeys: () => ["fontFamily"],
isCustomUpload: false,
});
const modelValue = defineModel<string>({ required: true });
// 编辑器实例
const editorRef = shallowRef<IDomEditor>();
const userStore = useUserStore();
// 常量配置
const DEFAULT_UPLOAD_CONFIG = {
maxFileSize: 3 * 1024 * 1024, // 3MB
maxNumberOfFiles: 10,
fieldName: "file",
allowedFileTypes: ["image/*"],
} as const;
// 计算属性:上传服务器地址
const uploadServer = computed(
() => props.uploadConfig?.server || `${VITE_API_URL}/common/upload/wangeditor`
);
// 合并上传配置
const mergedUploadConfig = computed(() => ({
...DEFAULT_UPLOAD_CONFIG,
...props.uploadConfig,
}));
// 工具栏配置
const toolbarConfig = computed((): Partial<IToolbarConfig> => {
const config: Partial<IToolbarConfig> = {};
// 完全自定义工具栏
if (props.toolbarKeys && props.toolbarKeys.length > 0) {
config.toolbarKeys = props.toolbarKeys;
}
// 插入新工具
if (props.insertKeys) {
config.insertKeys = props.insertKeys;
}
// 排除工具
if (props.excludeKeys && props.excludeKeys.length > 0) {
config.excludeKeys = props.excludeKeys;
}
return config;
});
// 编辑器配置
const editorConfig: Partial<IEditorConfig> = {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: {
fieldName: mergedUploadConfig.value.fieldName,
maxFileSize: mergedUploadConfig.value.maxFileSize,
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
allowedFileTypes: mergedUploadConfig.value.allowedFileTypes,
server: uploadServer.value,
headers: {
Authorization: userStore.accessToken,
},
onSuccess() {
ElMessage.success(`图片上传成功 ${EmojiText[200]}`);
},
onError(file: File, err: any, res: any) {
console.error("图片上传失败:", err, res);
ElMessage.error(`图片上传失败 ${EmojiText[500]}`);
},
},
},
};
// 自定义上传
const uploadConfig = props.uploadConfig;
if (uploadConfig?.isCustomUpload && uploadConfig.server && editorConfig.MENU_CONF) {
const uploadServerUrl = uploadConfig.server;
editorConfig.MENU_CONF.uploadImage.customUpload = async (file: File, insertFn: InsertFnType) => {
try {
const formData = new FormData();
formData.append(mergedUploadConfig.value.fieldName, file);
type UploadImagePayload = { url: string; alt?: string; href?: string };
const response = await request.post<
ApiResponse<UploadImagePayload>,
AxiosResponse<ApiResponse<UploadImagePayload>>
>(uploadServerUrl, formData, {
headers: {
"Content-Type": "multipart/form-data",
Authorization: userStore.accessToken,
},
});
const { url, alt = "", href = "" } = response.data.data ?? ({} as any);
if (!url) {
throw new Error("上传失败,请检查服务端配置");
}
insertFn(url, alt, href);
ElMessage.success(`图片上传成功 ${EmojiText[200]}`);
} catch (error) {
console.error("图片上传失败:", error);
ElMessage.error(`图片上传失败 ${EmojiText[500]}`);
}
};
}
// 编辑器创建回调
const onCreateEditor = (editor: IDomEditor) => {
editorRef.value = editor;
// 监听全屏事件
editor.on("fullScreen", () => {
console.log("编辑器进入全屏模式");
});
// 确保在编辑器创建后应用自定义图标
applyCustomIcons();
};
// 应用自定义图标(带重试机制)
const applyCustomIcons = () => {
let retryCount = 0;
const maxRetries = 10;
const retryDelay = 100;
const tryApplyIcons = () => {
const editor = editorRef.value;
if (!editor) {
if (retryCount < maxRetries) {
retryCount++;
setTimeout(tryApplyIcons, retryDelay);
}
return;
}
// 获取当前编辑器的工具栏容器
const editorContainer = editor.getEditableContainer().closest(".editor-wrapper");
if (!editorContainer) {
if (retryCount < maxRetries) {
retryCount++;
setTimeout(tryApplyIcons, retryDelay);
}
return;
}
const toolbar = editorContainer.querySelector(".w-e-toolbar");
const toolbarButtons = editorContainer.querySelectorAll(".w-e-bar-item button[data-menu-key]");
if (toolbar && toolbarButtons.length > 0) {
return;
}
// 如果工具栏还没渲染完成,继续重试
if (retryCount < maxRetries) {
retryCount++;
setTimeout(tryApplyIcons, retryDelay);
} else {
console.warn("工具栏渲染超时,无法应用自定义图标 - 编辑器实例:", editor.id);
}
};
// 使用 requestAnimationFrame 确保在下一帧执行
requestAnimationFrame(tryApplyIcons);
};
// 暴露编辑器实例和方法
defineExpose({
/** 获取编辑器实例 */
getEditor: () => editorRef.value,
/** 设置编辑器内容 */
setHtml: (html: string) => editorRef.value?.setHtml(html),
/** 获取编辑器内容 */
getHtml: () => editorRef.value?.getHtml(),
/** 清空编辑器 */
clear: () => editorRef.value?.clear(),
/** 聚焦编辑器 */
focus: () => editorRef.value?.focus(),
});
// 生命周期
onMounted(() => {
// 图标替换已在 onCreateEditor 中处理
});
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor) {
editor.destroy();
}
});
</script>
<style lang="scss">
@use "./style";
</style>
@@ -0,0 +1,273 @@
$box-radius: calc(var(--custom-radius) / 3 + 2px);
// 全屏容器 z-index 调整
.w-e-full-screen-container {
z-index: 100 !important;
}
/* 编辑器容器 */
.editor-wrapper {
width: 100%;
height: 100%;
border: 1px solid var(--art-gray-300);
border-radius: $box-radius !important;
.w-e-bar {
border-radius: $box-radius $box-radius 0 0 !important;
}
.menu-item {
display: flex;
flex-direction: row;
align-items: center;
i {
margin-right: 5px;
}
}
/* 工具栏 */
.editor-toolbar {
border-bottom: 1px solid var(--default-border);
}
/* 下拉选择框配置 */
.w-e-select-list {
min-width: 140px;
padding: 5px 10px 10px;
border: none;
border-radius: $box-radius;
}
/* 下拉选择框元素配置 */
.w-e-select-list ul li {
margin-top: 5px;
font-size: 15px !important;
border-radius: $box-radius;
}
/* 下拉选择框 正文文字大小调整 */
.w-e-select-list ul li:last-of-type {
font-size: 16px !important;
}
/* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover {
background-color: var(--art-gray-200);
}
:root {
/* 激活颜色 */
--w-e-toolbar-active-bg-color: var(--art-gray-200);
/* toolbar 图标和文字颜色 */
--w-e-toolbar-color: #000;
/* 表格选中时候的边框颜色 */
--w-e-textarea-selected-border-color: #ddd;
/* 表格头背景颜色 */
--w-e-textarea-slight-bg-color: var(--art-gray-200);
}
/* 工具栏按钮样式 */
.w-e-bar-item svg {
fill: var(--art-gray-800);
}
.w-e-bar-item button {
color: var(--art-gray-800);
border-radius: $box-radius;
}
/* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover {
background-color: var(--art-gray-200);
}
/* 工具栏分割线 */
.w-e-bar-divider {
height: 20px;
margin-top: 10px;
background-color: #ccc;
}
/* 工具栏菜单 */
.w-e-bar-item-group .w-e-bar-item-menus-container {
min-width: 120px;
padding: 10px 0;
border: none;
border-radius: $box-radius;
.w-e-bar-item {
button {
width: 100%;
margin: 0 5px;
}
}
}
/* 代码块 */
.w-e-text-container [data-slate-editor] pre > code {
padding: 0.6rem 1rem;
background-color: var(--art-gray-50);
border-radius: $box-radius;
}
/* 弹出框 */
.w-e-drop-panel {
border: 0;
border-radius: $box-radius;
}
a {
color: #318ef4;
}
.w-e-text-container {
[data-slate-editor] {
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0.8em 0 0.4em;
font-weight: 700;
line-height: 1.35;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.5em;
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1.125em;
}
h5 {
font-size: 1em;
}
h6 {
font-size: 0.875em;
}
ul,
ol {
padding-left: 1.5em;
margin: 0.8em 0;
}
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
li {
margin: 0.25em 0;
}
ul ul {
list-style: circle;
}
ul ul ul {
list-style: square;
}
}
strong,
b {
font-weight: 700;
}
i,
em {
font-style: italic;
}
}
/* 表格样式优化 */
.w-e-text-container [data-slate-editor] .table-container th {
border-right: none;
}
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid #ccc !important;
}
/* 引用 */
.w-e-text-container [data-slate-editor] blockquote {
background-color: var(--art-gray-200);
border-left: 4px solid var(--art-gray-300);
}
/* 输入区域弹出 bar */
.w-e-hover-bar {
border-radius: $box-radius;
}
/* 超链接弹窗 */
.w-e-modal {
border: none;
border-radius: $box-radius;
}
/* 图片样式调整 */
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
overflow: inherit;
&:hover {
border: 0;
}
img {
border: 1px solid transparent;
transition: border 0.3s;
&:hover {
border: 1px solid #318ef4 !important;
}
}
.w-e-image-dragger {
width: 12px;
height: 12px;
background-color: #318ef4;
border: 2px solid #fff;
border-radius: $box-radius;
}
.left-top {
top: -6px;
left: -6px;
}
.right-top {
top: -6px;
right: -6px;
}
.left-bottom {
bottom: -6px;
left: -6px;
}
.right-bottom {
right: -6px;
bottom: -6px;
}
}
}
@@ -0,0 +1,350 @@
<!-- 图片裁剪组件 github: https://github.com/acccccccb/vue-img-cutter/tree/master -->
<template>
<div class="cutter-container">
<div class="cutter-component">
<div class="title">{{ title }}</div>
<ImgCutter
ref="imgCutterModal"
@cutDown="cutDownImg"
@onPrintImg="cutterPrintImg"
@onImageLoadComplete="handleImageLoadComplete"
@onImageLoadError="handleImageLoadError"
@onClearAll="handleClearAll"
v-bind="cutterProps"
class="img-cutter"
>
<template #choose>
<ElButton type="primary" plain v-ripple>选择图片</ElButton>
</template>
<template #cancel>
<ElButton type="danger" plain v-ripple>清除</ElButton>
</template>
<template #confirm>
<!-- <ElButton type="primary" style="margin-left: 10px">确定</ElButton> -->
<div></div>
</template>
</ImgCutter>
</div>
<div v-if="showPreview" class="preview-container">
<div class="title">{{ previewTitle }}</div>
<div
class="preview-box"
:style="{
width: `${cutterProps.cutWidth}px`,
height: `${cutterProps.cutHeight}px`,
}"
>
<img class="preview-img" :src="temImgPath" alt="预览图" v-if="temImgPath" />
</div>
<ElButton class="download-btn" @click="downloadImg" :disabled="!temImgPath" v-ripple>
下载图片
</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import ImgCutter from "vue-img-cutter";
defineOptions({ name: "ArtCutterImg" });
interface CutterProps {
// 基础配置
/** 是否模态框 */
isModal?: boolean;
/** 是否显示工具栏 */
tool?: boolean;
/** 工具栏背景色 */
toolBgc?: string;
/** 标题 */
title?: string;
/** 预览标题 */
previewTitle?: string;
/** 是否显示预览 */
showPreview?: boolean;
// 尺寸相关
/** 容器宽度 */
boxWidth?: number;
/** 容器高度 */
boxHeight?: number;
/** 裁剪宽度 */
cutWidth?: number;
/** 裁剪高度 */
cutHeight?: number;
/** 是否允许大小调整 */
sizeChange?: boolean;
// 移动和缩放
/** 是否允许移动 */
moveAble?: boolean;
/** 是否允许图片移动 */
imgMove?: boolean;
/** 是否允许缩放 */
scaleAble?: boolean;
// 图片相关
/** 是否显示原始图片 */
originalGraph?: boolean;
/** 是否允许跨域 */
crossOrigin?: boolean;
/** 文件类型 */
fileType?: "png" | "jpeg" | "webp";
/** 质量 */
quality?: number;
// 水印
/** 水印文本 */
watermarkText?: string;
/** 水印字体大小 */
watermarkFontSize?: number;
/** 水印颜色 */
watermarkColor?: string;
// 其他功能
/** 是否保存裁剪位置 */
saveCutPosition?: boolean;
/** 是否预览模式 */
previewMode?: boolean;
// 输入图片
imgUrl?: string;
}
interface CutterResult {
fileName: string;
file: File;
blob: Blob;
dataURL: string;
}
const props = withDefaults(defineProps<CutterProps>(), {
// 基础配置默认值
isModal: false,
tool: true,
toolBgc: "#fff",
title: "",
previewTitle: "",
showPreview: true,
// 尺寸相关默认值
boxWidth: 700,
boxHeight: 458,
cutWidth: 470,
cutHeight: 270,
sizeChange: true,
// 移动和缩放默认值
moveAble: true,
imgMove: true,
scaleAble: true,
// 图片相关默认值
originalGraph: true,
crossOrigin: true,
fileType: "png",
quality: 0.9,
// 水印默认值
watermarkText: "",
watermarkFontSize: 20,
watermarkColor: "#ffffff",
// 其他功能默认值
saveCutPosition: true,
previewMode: true,
});
const emit = defineEmits(["update:imgUrl", "error", "imageLoadComplete", "imageLoadError"]);
const temImgPath = ref("");
const imgCutterModal = ref();
// 计算属性:整合所有ImgCutter的props
const cutterProps = computed(() => ({
...props,
WatermarkText: props.watermarkText,
WatermarkFontSize: props.watermarkFontSize,
WatermarkColor: props.watermarkColor,
}));
// 图片预加载
function preloadImage(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve();
img.onerror = reject;
img.src = url;
});
}
// 初始化裁剪器
async function initImgCutter() {
if (props.imgUrl) {
try {
await preloadImage(props.imgUrl);
imgCutterModal.value?.handleOpen({
name: "封面图片",
src: props.imgUrl,
});
} catch (error) {
emit("error", error);
console.error("图片加载失败:", error);
}
}
}
// 生命周期钩子
onMounted(() => {
if (props.imgUrl) {
temImgPath.value = props.imgUrl;
initImgCutter();
}
});
// 监听图片URL变化
watch(
() => props.imgUrl,
(newVal) => {
if (newVal) {
temImgPath.value = newVal;
initImgCutter();
}
}
);
// 实时预览
function cutterPrintImg(result: { dataURL: string }) {
temImgPath.value = result.dataURL;
}
// 裁剪完成
function cutDownImg(result: CutterResult) {
emit("update:imgUrl", result.dataURL);
}
// 图片加载完成
function handleImageLoadComplete(result: any) {
emit("imageLoadComplete", result);
}
// 图片加载失败
function handleImageLoadError(error: any) {
emit("error", error);
emit("imageLoadError", error);
}
// 清除所有
function handleClearAll() {
temImgPath.value = "";
}
// 下载图片
function downloadImg() {
console.log("下载图片");
const a = document.createElement("a");
a.href = temImgPath.value;
a.download = "image.png";
a.click();
}
</script>
<style lang="scss" scoped>
.cutter-container {
display: flex;
flex-flow: row wrap;
.title {
padding-bottom: 10px;
font-size: 18px;
font-weight: 500;
}
.cutter-component {
margin-right: 30px;
}
.preview-container {
.preview-box {
background-color: var(--art-active-color) !important;
.preview-img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.download-btn {
display: block;
margin: 20px auto;
}
}
:deep(.toolBoxControl) {
z-index: 100;
}
:deep(.dockMain) {
right: 0;
bottom: -40px;
left: 0;
z-index: 10;
padding: 0;
background-color: transparent !important;
opacity: 1;
}
:deep(.copyright) {
display: none !important;
}
:deep(.i-dialog-footer) {
margin-top: 60px !important;
}
:deep(.dockBtn) {
height: 26px;
padding: 0 10px;
font-size: 12px;
line-height: 26px;
color: var(--el-color-primary) !important;
background-color: var(--el-color-primary-light-9) !important;
border: 1px solid var(--el-color-primary-light-4) !important;
}
:deep(.dockBtnScrollBar) {
margin: 0 10px 0 6px;
background-color: var(--el-color-primary-light-1);
}
:deep(.scrollBarControl) {
border-color: var(--el-color-primary);
}
:deep(.closeIcon) {
line-height: 15px !important;
}
}
.dark {
.cutter-container {
:deep(.toolBox) {
border: transparent;
}
:deep(.dialogMain) {
background-color: transparent !important;
}
:deep(.i-dialog-footer) {
.btn {
background-color: var(--el-color-primary) !important;
border: transparent;
}
}
}
}
</style>
@@ -0,0 +1,144 @@
<!-- 视频播放器组件:https://h5player.bytedance.com/-->
<template>
<div :id="playerId" />
</template>
<script setup lang="ts">
import Player from "xgplayer";
import "xgplayer/dist/index.min.css";
defineOptions({ name: "ArtVideoPlayer" });
interface Props {
/** 播放器容器 ID */
playerId: string;
/** 视频源URL */
videoUrl: string;
/** 视频封面图URL */
posterUrl: string;
/** 是否自动播放 */
autoplay?: boolean;
/** 音量大小(0-1) */
volume?: number;
/** 可选的播放速率 */
playbackRates?: number[];
/** 是否循环播放 */
loop?: boolean;
/** 是否静音 */
muted?: boolean;
commonStyle?: VideoPlayerStyle;
}
const props = withDefaults(defineProps<Props>(), {
playerId: "",
videoUrl: "",
posterUrl: "",
autoplay: false,
volume: 1,
loop: false,
muted: false,
});
// 设置属性默认值
// 播放器实例引用
const playerInstance = ref<Player | null>(null);
// 播放器样式接口定义
interface VideoPlayerStyle {
progressColor?: string; // 进度条背景色
playedColor?: string; // 已播放部分颜色
cachedColor?: string; // 缓存部分颜色
sliderBtnStyle?: Record<string, string>; // 滑块按钮样式
volumeColor?: string; // 音量控制器颜色
}
// 默认样式配置
const defaultStyle: VideoPlayerStyle = {
progressColor: "rgba(255, 255, 255, 0.3)",
playedColor: "#00AEED",
cachedColor: "rgba(255, 255, 255, 0.6)",
sliderBtnStyle: {
width: "10px",
height: "10px",
backgroundColor: "#00AEED",
},
volumeColor: "#00AEED",
};
// 组件挂载时初始化播放器
onMounted(() => {
playerInstance.value = new Player({
id: props.playerId,
lang: "zh", // 设置界面语言为中文
volume: props.volume,
autoplay: props.autoplay,
screenShot: true, // 启用截图功能
url: props.videoUrl,
poster: props.posterUrl,
fluid: true, // 启用流式布局,自适应容器大小
playbackRate: props.playbackRates,
loop: props.loop,
muted: props.muted,
commonStyle: {
...defaultStyle,
...props.commonStyle,
},
});
// 播放事件监听器
playerInstance.value.on("play", () => {
console.log("Video is playing");
});
// 暂停事件监听器
playerInstance.value.on("pause", () => {
console.log("Video is paused");
});
// 错误事件监听器
playerInstance.value.on("error", (error) => {
console.error("Error occurred:", error);
});
});
// 组件卸载前清理播放器实例
onBeforeUnmount(() => {
if (playerInstance.value) {
playerInstance.value.destroy();
}
});
</script>
<!-- <template>
<div class="page-content">
<div class="max-w-150">
<ArtVideoPlayer
playerId="my-video-1"
:videoUrl="videoUrl"
:posterUrl="posterUrl"
:autoplay="false"
:volume="1"
:playbackRates="[0.5, 1, 1.5, 2]"
/>
</div>
</div>
</template>
<script setup lang="ts">
import lockImg from "@imgs/lock/bg_dark.webp";
defineOptions({ name: "WidgetsVideo" });
/**
* 视频源 URL
*/
const videoUrl = ref(
"//lf3-static.bytednsdoc.com/obj/eden-cn/nupenuvpxnuvo/xgplayer_doc/xgplayer-demo.mp4"
);
/**
* 视频封面图片 URL
*/
const posterUrl = ref(lockImg);
</script> -->
@@ -1,5 +1,5 @@
<template>
<el-dialog
<ElDialog
v-model="visible"
:width="width"
:draggable="draggable"
@@ -14,22 +14,27 @@
@opened="emit('opened')"
>
<template #header="{ titleId, titleClass, close }">
<div class="curd-dialog-header">
<div class="core-overlay-dialog__header">
<span :id="titleId" :class="titleClass">{{ title }}</span>
<div class="curd-dialog-header__actions">
<el-tooltip :content="fullscreen ? '还原' : '全屏'" placement="top">
<el-button text circle type="primary" @click="fullscreen = !fullscreen">
<el-icon>
<div class="core-overlay-dialog__actions">
<ElTooltip :content="fullscreen ? '还原' : '全屏'" placement="top">
<ElButton
class="core-overlay-icon-btn"
text
type="primary"
@click="fullscreen = !fullscreen"
>
<ElIcon>
<Fold v-if="fullscreen" />
<FullScreen v-else />
</el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="关闭" placement="top">
<el-button text circle @click="close">
<el-icon><Close /></el-icon>
</el-button>
</el-tooltip>
</ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip content="关闭" placement="top">
<ElButton class="core-overlay-icon-btn" text @click="close">
<ElIcon><Close /></ElIcon>
</ElButton>
</ElTooltip>
</div>
</div>
</template>
@@ -37,7 +42,7 @@
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</el-dialog>
</ElDialog>
</template>
<script setup lang="ts">
@@ -45,7 +50,7 @@ import { Close, Fold, FullScreen } from "@element-plus/icons-vue";
import type { DialogProps } from "element-plus";
import { computed, ref, useAttrs, watch } from "vue";
defineOptions({ inheritAttrs: false });
defineOptions({ name: "ArtDialog", inheritAttrs: false });
const props = withDefaults(
defineProps<{
@@ -74,7 +79,6 @@ const emit = defineEmits<{
const attrs = useAttrs();
const fullscreen = ref(false);
// 监听全屏状态变化并发出事件
watch(fullscreen, (newVal) => {
emit("fullscreen-change", newVal);
});
@@ -98,7 +102,7 @@ const dialogAttrs = computed(() => {
</script>
<style scoped lang="scss">
.curd-dialog-header {
.core-overlay-dialog__header {
display: flex;
flex-wrap: wrap;
gap: 8px;
@@ -108,11 +112,21 @@ const dialogAttrs = computed(() => {
padding-right: 4px;
}
.curd-dialog-header__actions {
.core-overlay-dialog__actions {
display: inline-flex;
flex-shrink: 0;
gap: 2px;
gap: 4px;
align-items: center;
margin-left: auto;
:deep(.core-overlay-icon-btn.el-button) {
min-width: 32px;
padding: 6px;
border-radius: var(--el-border-radius-base);
&.is-text:not(.is-disabled):hover {
border-radius: var(--el-border-radius-base);
}
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<el-drawer
<ElDrawer
v-model="visible"
:size="size"
:direction="direction"
@@ -11,14 +11,14 @@
@opened="emit('opened')"
>
<template #header>
<div class="curd-drawer-header">
<span class="curd-drawer-header__title">{{ title }}</span>
<div class="curd-drawer-header__actions">
<el-tooltip content="关闭" placement="top">
<el-button text circle @click="visible = false">
<el-icon><Close /></el-icon>
</el-button>
</el-tooltip>
<div class="core-overlay-drawer__header">
<span class="core-overlay-drawer__title">{{ title }}</span>
<div class="core-overlay-drawer__actions">
<ElTooltip content="关闭" placement="top">
<ElButton class="core-overlay-icon-btn" text @click="visible = false">
<ElIcon><Close /></ElIcon>
</ElButton>
</ElTooltip>
</div>
</div>
</template>
@@ -26,7 +26,7 @@
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</el-drawer>
</ElDrawer>
</template>
<script setup lang="ts">
@@ -34,7 +34,7 @@ import { Close } from "@element-plus/icons-vue";
import type { DrawerProps } from "element-plus";
import { computed, useAttrs } from "vue";
defineOptions({ inheritAttrs: false });
defineOptions({ name: "ArtDrawer", inheritAttrs: false });
const props = withDefaults(
defineProps<{
@@ -76,7 +76,7 @@ const drawerAttrs = computed(() => {
</script>
<style scoped lang="scss">
.curd-drawer-header {
.core-overlay-drawer__header {
display: flex;
flex-wrap: wrap;
gap: 8px;
@@ -86,17 +86,27 @@ const drawerAttrs = computed(() => {
padding-right: 4px;
}
.curd-drawer-header__title {
.core-overlay-drawer__title {
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.curd-drawer-header__actions {
.core-overlay-drawer__actions {
display: inline-flex;
flex-shrink: 0;
gap: 2px;
gap: 4px;
align-items: center;
margin-left: auto;
:deep(.core-overlay-icon-btn.el-button) {
min-width: 32px;
padding: 6px;
border-radius: var(--el-border-radius-base);
&.is-text:not(.is-disabled):hover {
border-radius: var(--el-border-radius-base);
}
}
}
</style>
@@ -1,81 +1,97 @@
<template>
<div class="curd-export-modal-host">
<div class="crud-export-modal-host">
<!-- 导出弹窗 -->
<EnhancedDialog
<ArtDialog
v-model="exportsModalVisible"
title="导出数据"
width="600px"
dialog-class="curd-embed-dialog"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
@close="handleCloseExportsModal"
>
<!-- 滚动 -->
<el-scrollbar max-height="60vh">
<ElScrollbar max-height="60vh">
<!-- 表单 -->
<el-form
<ElForm
ref="exportsFormRef"
style="padding-right: var(--el-dialog-padding-primary)"
:model="exportsFormData"
:rules="exportsFormRules"
>
<el-form-item label="文件名" prop="filename">
<el-input v-model="exportsFormData.filename" placeholder="请输入文件名" clearable />
</el-form-item>
<el-form-item label="工作表名" prop="sheetname">
<el-input v-model="exportsFormData.sheetname" placeholder="请输入工作表名" clearable />
</el-form-item>
<el-form-item label="数据源" prop="origin">
<el-select v-model="exportsFormData.origin">
<el-option
<ElFormItem label="文件名" prop="filename">
<ElInput v-model="exportsFormData.filename" placeholder="请输入文件名" clearable />
</ElFormItem>
<ElFormItem label="工作表名" prop="sheetname">
<ElInput v-model="exportsFormData.sheetname" placeholder="请输入工作表名" clearable />
</ElFormItem>
<ElFormItem label="数据源" prop="origin">
<ElSelect v-model="exportsFormData.origin">
<ElOption
label="当前数据 (当前页的数据)"
:value="ExportsOriginEnum.CURRENT"
:disabled="!pageData?.length"
/>
<el-option
<ElOption
label="选中数据 (所有选中的数据)"
:value="ExportsOriginEnum.SELECTED"
:disabled="!selectionData?.length"
/>
<el-option
<ElOption
label="全量数据 (所有分页的数据)"
:value="ExportsOriginEnum.REMOTE"
:disabled="!props.contentConfig.exportsAction"
:disabled="!remoteExportEnabled"
/>
</el-select>
</el-form-item>
<el-form-item label="字段" prop="fields">
<el-checkbox-group v-model="exportsFormData.fields">
</ElSelect>
</ElFormItem>
<ElFormItem label="字段" prop="fields">
<ElCheckboxGroup v-model="exportsFormData.fields">
<template v-for="col in cols" :key="col.prop">
<el-checkbox v-if="col.prop" :value="col.prop" :label="col.label" />
<ElCheckbox v-if="col.prop" :value="col.prop" :label="col.label" />
</template>
</el-checkbox-group>
</el-form-item>
</el-form>
</el-scrollbar>
</ElCheckboxGroup>
</ElFormItem>
</ElForm>
</ElScrollbar>
<!-- 弹窗底部操作按钮 -->
<template #footer>
<div style="padding-right: var(--el-dialog-padding-primary)">
<el-button type="primary" @click="handleExportsSubmit">确 定</el-button>
<el-button @click="handleCloseExportsModal">取 消</el-button>
<ElButton type="primary" @click="handleExportsSubmit">确 定</ElButton>
<ElButton @click="handleCloseExportsModal">取 消</ElButton>
</div>
</template>
</EnhancedDialog>
</ArtDialog>
</div>
</template>
<script lang="ts" setup>
import EnhancedDialog from "./EnhancedDialog.vue";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
import ExcelJS from "exceljs";
import type { IContentConfig, IObject } from "./types";
import type { IContentConfig, IObject } from "@/components/Core/modal/types";
import { useThrottleFn } from "@vueuse/core";
import { type FormInstance, type FormRules, ElMessage } from "element-plus";
import { nextTick, ref, reactive, computed } from "vue";
defineOptions({ name: "ArtExportDialog", inheritAttrs: false });
function saveBlobDownload(blob: Blob, rawName: string) {
const name = /\.xlsx?$/i.test(rawName) ? rawName : `${rawName}.xlsx`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
/**
* 导出模态框组件属性定义
*/
interface ExportModalProps {
interface ArtExportDialogProps {
/** 内容配置 */
contentConfig: Pick<IContentConfig, "permPrefix" | "cols" | "exportsAction">;
contentConfig: Pick<
IContentConfig,
"permPrefix" | "cols" | "exportsAction" | "exportsBlobAction"
>;
/** 查询参数 */
queryParams?: IObject;
/** 页面数据 */
@@ -85,7 +101,11 @@ interface ExportModalProps {
}
// 定义接收的属性
const props = defineProps<ExportModalProps>();
const props = defineProps<ArtExportDialogProps>();
const remoteExportEnabled = computed(
() => !!(props.contentConfig.exportsAction || props.contentConfig.exportsBlobAction)
);
// 定义模型值(控制弹窗显示/隐藏)
const exportsModalVisible = defineModel<boolean>("modelValue", {
@@ -182,8 +202,21 @@ function handleExports() {
worksheet.columns = columns;
if (exportsFormData.origin === ExportsOriginEnum.REMOTE) {
const lastFormData = props.queryParams ?? {};
if (props.contentConfig.exportsBlobAction) {
props.contentConfig
.exportsBlobAction(lastFormData)
.then((blob) => {
saveBlobDownload(blob, filename as string);
ElMessage.success("导出成功");
})
.catch((error) => {
console.error("导出远程文件失败:", error);
ElMessage.error("导出远程文件失败");
});
return;
}
if (props.contentConfig.exportsAction) {
const lastFormData = props.queryParams ?? {};
props.contentConfig
.exportsAction(lastFormData)
.then((res) => {
@@ -203,7 +236,7 @@ function handleExports() {
ElMessage.error("获取远程数据失败");
});
} else {
ElMessage.error("未配置exportsAction");
ElMessage.error("未配置 exportsAction 或 exportsBlobAction");
}
} else if (exportsFormData.origin === ExportsOriginEnum.SELECTED) {
const rows = props.selectionData ?? [];
@@ -1,24 +1,25 @@
<template>
<div class="curd-import-modal-host">
<div class="crud-import-modal-host">
<!-- 导入弹窗 -->
<EnhancedDialog
<ArtDialog
v-model="importModalVisible"
:title="props.title"
:width="props.width"
dialog-class="curd-embed-dialog"
dialog-class="crud-embed-dialog"
modal-class="crud-embed-dialog"
@close="handleClose"
>
<!-- 滚动 -->
<el-scrollbar :max-height="props.maxHeight">
<ElScrollbar :max-height="props.maxHeight">
<!-- 表单 -->
<el-form
<ElForm
ref="importFormRef"
style="padding-right: var(--el-dialog-padding-primary)"
:model="importFormData"
:rules="importFormRules"
>
<el-form-item prop="files">
<el-upload
<ElFormItem prop="files">
<ElUpload
ref="uploadRef"
v-model:file-list="importFormData.files"
class="w-full"
@@ -28,61 +29,64 @@
:auto-upload="false"
:on-exceed="handleFileExceed"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text">
{{ props.dropText || "将文件拖到此处,或" }}
<em>{{ props.browseText || "点击上传" }}</em>
</div>
<template #tip>
<div class="el-upload__tip flex flex-wrap gap-2">
<el-text v-if="props.note" type="warning" class="mx-1">{{ props.note }}</el-text>
<el-text v-if="props.fileTypeWarning" type="danger" class="mx-1">
<ElText v-if="props.note" type="warning" class="mx-1">{{ props.note }}</ElText>
<ElText v-if="props.fileTypeWarning" type="danger" class="mx-1">
{{ props.fileTypeWarning }}
</el-text>
<el-link
</ElText>
<ElLink
v-if="props.showTemplateDownload"
v-hasPerm="[`${props.contentConfig.permPrefix}:download`]"
class="mx-1"
class="mx-1 inline-flex items-center gap-0.5"
type="primary"
icon="download"
underline="never"
@click="handleDownloadTemplate"
>
{{ props.templateDownloadText || "下载模板" }}
</el-link>
<ElIcon class="text-base"><Download /></ElIcon>
<span>{{ props.templateDownloadText || "下载模板" }}</span>
</ElLink>
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
</el-scrollbar>
</ElUpload>
</ElFormItem>
</ElForm>
</ElScrollbar>
<template #footer>
<div style="padding-right: var(--el-dialog-padding-primary)">
<el-button @click="handleClose">{{ props.cancelButtonText || "取 消" }}</el-button>
<el-button
<ElButton @click="handleClose">{{ props.cancelButtonText || "取 消" }}</ElButton>
<ElButton
type="primary"
:disabled="importFormData.files.length === 0 || props.loading"
:loading="props.loading"
@click="handleUpload"
>
{{ props.confirmButtonText || "确 定" }}
</el-button>
</ElButton>
</div>
</template>
</EnhancedDialog>
</ArtDialog>
</div>
</template>
<script lang="ts" setup>
import EnhancedDialog from "./EnhancedDialog.vue";
import { Download, UploadFilled } from "@element-plus/icons-vue";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
import { ElMessage, type UploadUserFile } from "element-plus";
import { ref, reactive } from "vue";
import type { IContentConfig, IObject } from "./types";
import type { IContentConfig, IObject } from "@/components/Core/modal/types";
defineOptions({ name: "ArtImportDialog", inheritAttrs: false });
/**
* 导入模态框组件属性定义
*/
interface ImportModalProps {
interface ArtImportDialogProps {
/**
* 弹窗标题
*/
@@ -128,6 +132,11 @@ interface ImportModalProps {
*/
templateDownloadText?: string;
/**
* 当接口无 Content-Disposition 时使用的默认模板文件名
*/
defaultTemplateFileName?: string;
/**
* 取消按钮文本
*/
@@ -170,7 +179,7 @@ interface ImportModalProps {
}
// 定义props
const props = withDefaults(defineProps<ImportModalProps>(), {
const props = withDefaults(defineProps<ArtImportDialogProps>(), {
title: "导入数据",
width: "600px",
maxHeight: "60vh",
@@ -253,9 +262,19 @@ function handleDownloadTemplate() {
} else if (typeof importTemplate === "function") {
importTemplate().then((response) => {
const fileData = response.data;
const fileName = decodeURI(
response.headers["content-disposition"].split(";")[1].split("=")[1]
);
const cd = response.headers?.["content-disposition"] as string | undefined;
let fileName = props.defaultTemplateFileName || "template.xlsx";
if (cd) {
try {
const part = cd.split(";").find((s) => s.trim().startsWith("filename"));
if (part) {
const raw = part.split("=")[1]?.replace(/^"|"$/g, "");
if (raw) fileName = decodeURI(raw);
}
} catch {
/* 使用 defaultTemplateFileName */
}
}
saveXlsx(fileData, fileName);
});
} else {
@@ -0,0 +1,5 @@
/** 通用弹窗、抽屉(与业务 CRUD 解耦) */
export { default as ArtDialog } from "./art-dialog/index.vue";
export { default as ArtDrawer } from "./art-drawer/index.vue";
export { default as ArtImportDialog } from "./art-import-dialog/index.vue";
export { default as ArtExportDialog } from "./art-export-dialog/index.vue";
@@ -1,13 +1,19 @@
import type { DialogProps, DrawerProps, FormItemRule, PaginationProps } from "element-plus";
import type {
DialogProps,
DrawerProps,
FormItemRule,
FormRules,
PaginationProps,
} from "element-plus";
import type { FormProps, ColProps, ButtonProps, CardProps } from "element-plus";
import type PageContent from "./PageContent.vue";
import type PageModal from "./PageModal.vue";
import type PageSearch from "./PageSearch.vue";
import type CrudContent from "./CrudContent.vue";
import type CrudFormModal from "./CrudFormModal.vue";
import type CrudSearch from "./CrudSearch.vue";
import type { CSSProperties } from "vue";
export type PageSearchInstance = InstanceType<typeof PageSearch>;
export type PageContentInstance = InstanceType<typeof PageContent>;
export type PageModalInstance = InstanceType<typeof PageModal>;
export type CrudSearchInstance = InstanceType<typeof CrudSearch>;
export type CrudContentInstance = InstanceType<typeof CrudContent>;
export type CrudFormModalInstance = InstanceType<typeof CrudFormModal>;
/**
* 通用对象类型
@@ -114,6 +120,40 @@ export interface ISearchConfig {
searchButtonPerm?: string | string[];
/** 重置按钮权限 */
resetButtonPerm?: string | string[];
/**
* 搜索区形态:与「高级表格」示例一致用 `art`(ArtSearchBar + art-card-xs);
* `grid` 等复杂布局仍走 `legacy`(或显式传 legacy)
*/
searchVariant?: "legacy" | "art";
/**
* ArtSearchBar `span`(不传则组件默认 6)。
* 若需与 legacy `showNumber` 折叠条数对齐,可自行传入 `computeArtSearchSpan(showNumber)` 的结果。
*/
artSearchSpan?: number;
/** ArtSearchBar `showExpand`(不传则 `isExpandable !== false`,否则与组件默认 true 一致) */
artSearchShowExpand?: boolean;
/** ArtSearchBar 栅格 gutter(不传则用组件默认 12) */
artSearchGutter?: number;
/** art 搜索区默认展开全部筛选项(对应 ArtSearchBar defaultExpanded) */
searchDefaultExpanded?: boolean;
/**
* art 搜索区内控件最大宽度(不传则不限制,与 ArtSearchBar 示例一致)。
* 需要限制时再传,如 `"200px"`、`"min(100%, 280px)"`;`false` 显式铺满栅格列。
*/
artSearchFieldMaxWidth?: string | false;
/** 透传 ArtSearchBar → ElForm.rules */
artSearchRules?: FormRules;
/** 透传 ArtSearchBar `sanitizeOutput`,控制搜索提交前清洗空值等行为 */
artSearchSanitizeOutput?: IObject;
/** 透传 ArtSearchBar `buttonLeftLimit`(表单项数 ≤ 该值时操作按钮靠左,默认 2) */
artSearchButtonLeftLimit?: number;
/** 透传 ArtSearchBar `disabledSearch`,为 true 时禁用查询按钮 */
artSearchDisabledSearch?: boolean;
/**
* 透传 ArtSearchBar `isExpand`:为 true 时始终展示全部筛选项(不出现「展开更多」)。
* 不传时:`isExpandable === false` 自动为 true(与 legacy 全展示一致);可折叠搜索时为 false。
*/
artSearchIsExpand?: boolean;
/** 自定义按钮组 */
customButtons?: Array<{
/** 按钮唯一标识 */
@@ -156,7 +196,7 @@ export interface IContentConfig<T = any> {
indexAction: (queryParams: T) => Promise<any>;
/**
* 是否在挂载时立即请求列表(默认 true)。
* 若需在父组件合并 PageSearch 与额外条件后再请求,可设为 false,并在 onMounted 中自行调用 fetchPageData。
* 若需在父组件合并 CrudSearch 与额外条件后再请求,可设为 false,并在 onMounted 中自行调用 fetchPageData。
*/
initialFetch?: boolean;
/** 默认的分页相关的请求参数 */
@@ -190,6 +230,8 @@ export interface IContentConfig<T = any> {
exportAction?: (queryParams: T) => Promise<any>;
/** 前端全量导出的网络请求函数(需返回promise) */
exportsAction?: (queryParams: T) => Promise<IObject[]>;
/** 服务端导出 Excel 二进制(与 exportsAction 二选一用于「全量远程」导出) */
exportsBlobAction?: (queryParams: T) => Promise<Blob>;
/** 导入模板 */
importTemplate?: string | (() => Promise<any>);
/** 后端导入的网络请求函数(需返回promise) */
@@ -200,16 +242,28 @@ export interface IContentConfig<T = any> {
pk?: string;
/** 表格工具栏(默认:add,delete,export,也可自定义) */
toolbar?: Array<ToolbarLeft | IToolsButton>;
/** 表格工具栏右侧图标(默认:refresh,filter,import,export) */
/**
* 表格工具栏右侧图标(仅 **legacy** `el-table` 栈由 `CrudToolbarActions` 渲染)。
* **tableVariant: 'art'** 时刷新/列设置等由 `ArtTableHeader` 自带;导入导出请用 `#toolbar` 插槽自建。
*/
defaultToolbar?: Array<ToolbarRight | IToolsButton>;
/** 使用 #table 插槽自定义表格/树表时,为 true 则隐藏「列筛选」按钮(避免与自定义列不同步) */
/** 为 true 时隐藏 ArtTableHeader「列筛选」按钮(默认 false,显示列筛选) */
hideColumnFilter?: boolean;
/** 内容区外层 el-card 额外 class */
/**
* 表格栈:`art` 时使用 ArtTableHeader + ArtTable;
* `legacy` 为内置 el-table(默认)。
*/
tableVariant?: "legacy" | "art";
/** 内容区外层 el-card 额外 class(默认已含 `art-table-card`) */
cardClass?: string;
/** el-card 阴影(默认 never,与 Element Plus el-card shadow 一致) */
cardShadow?: "always" | "hover" | "never";
/** 是否显示表格上方工具条(默认 true;纯卡片/无按钮时可设为 false) */
showToolbar?: boolean;
/** ArtTableHeader 布局(不传则用内置默认) */
artToolbarLayout?: string;
/** 表格全屏区域 class,传给 ArtTableHeader fullClass */
artFullScreenClass?: string;
/** 更多操作按钮配置 */
moreButtons?: Array<IToolsButton>;
/** table组件列属性(额外的属性templet,operat,slotName) */
@@ -282,9 +336,9 @@ export interface IContentConfig<T = any> {
}
/**
* PageContent 左侧 `createToolbar` 产物,供 CrudToolbarLeft 的 `configButtons` 使用。
* CrudContent 左侧 `createToolbar` 产物,供 CrudToolbarLeft 的 `configButtons` 使用。
*/
export type CrudToolbarConfigButton = {
export type ArtTableHeaderLeftConfigButton = {
name: string;
text?: string;
attrs?: Record<string, unknown>;
@@ -303,7 +357,7 @@ export interface IModalConfig<T = any> {
pk?: string;
/** 组件类型(默认:dialog) */
component?: "dialog" | "drawer";
/** dialog组件属性(默认可拖拽;全屏由 EnhancedDialog 标题栏按钮切换) */
/** dialog组件属性(默认可拖拽;全屏由 core/overlays/ArtDialog 标题栏按钮切换) */
dialog?: Partial<Omit<DialogProps, "modelValue">> & { draggable?: boolean };
/** drawer组件属性 */
drawer?: Partial<Omit<DrawerProps, "modelValue">>;
@@ -0,0 +1,418 @@
<!-- 右键菜单 -->
<template>
<div class="menu-right">
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
<div
v-show="visible"
:style="menuStyle"
class="context-menu art-card-xs !shadow-xl min-w-[var(--menu-width)] w-[var(--menu-width)]"
>
<ul class="menu-list m-0 list-none" :style="menuListStyle">
<template v-for="item in menuItems" :key="item.key">
<!-- 普通菜单项 -->
<li
v-if="!item.children"
class="menu-item relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:class="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
:style="menuItemStyle"
@click="handleMenuClick(item)"
>
<ArtSvgIcon
v-if="item.icon"
class="mr-2 shrink-0 text-base text-g-800"
:icon="item.icon"
/>
<span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>
{{ item.label }}
</span>
</li>
<!-- 子菜单 -->
<li
v-else
class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:style="menuItemStyle"
>
<div class="submenu-title flex-c w-full">
<ArtSvgIcon
v-if="item.icon"
class="mr-2 shrink-0 text-base text-g-800"
:icon="item.icon"
/>
<span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>
{{ item.label }}
</span>
<ArtSvgIcon
icon="ri:arrow-right-s-line"
class="ubmenu-arrow ml-auto mr-0 text-base text-g-500 transition-transform duration-150"
/>
</div>
<ul
class="submenu-list art-card-xs absolute left-full top-0 z-[2001] hidden w-max min-w-max list-none !shadow-xl"
:style="submenuListStyle"
>
<li
v-for="child in item.children"
:key="child.key"
class="menu-item relative mx-1.5 flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:class="{ 'is-disabled': child.disabled, 'has-line': child.showLine }"
:style="menuItemStyle"
@click="handleMenuClick(child)"
>
<ArtSvgIcon
v-if="child.icon"
class="r-2 shrink-0 text-base text-g-800 mr-1"
:icon="child.icon"
/>
<span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>
{{ child.label }}
</span>
</li>
</ul>
</li>
</template>
</ul>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from "vue";
defineOptions({ name: "ArtMenuRight" });
export interface MenuItemType {
/** 菜单项唯一标识 */
key: string;
/** 菜单项标签 */
label: string;
/** 菜单项图标 */
icon?: string;
/** 菜单项是否禁用 */
disabled?: boolean;
/** 菜单项是否显示分割线 */
showLine?: boolean;
/** 子菜单 */
children?: MenuItemType[];
[key: string]: any;
}
interface Props {
menuItems: MenuItemType[];
/** 菜单宽度 */
menuWidth?: number;
/** 子菜单宽度 */
submenuWidth?: number;
/** 菜单项高度 */
itemHeight?: number;
/** 边界距离 */
boundaryDistance?: number;
/** 菜单内边距 */
menuPadding?: number;
/** 菜单项水平内边距 */
itemPaddingX?: number;
/** 菜单圆角 */
borderRadius?: number;
/** 动画持续时间 */
animationDuration?: number;
}
const props = withDefaults(defineProps<Props>(), {
menuWidth: 120,
submenuWidth: 150,
itemHeight: 32,
boundaryDistance: 10,
menuPadding: 5,
itemPaddingX: 6,
borderRadius: 6,
animationDuration: 100,
});
const emit = defineEmits<{
(e: "select", item: MenuItemType): void;
(e: "show"): void;
(e: "hide"): void;
}>();
const visible = ref(false);
const position = ref({ x: 0, y: 0 });
// 用于清理定时器和事件监听器
let showTimer: number | null = null;
let eventListenersAdded = false;
// 计算菜单样式
const menuStyle = computed(
(): CSSProperties => ({
position: "fixed" as const,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
zIndex: 2000,
width: `${props.menuWidth}px`,
})
);
// 计算菜单列表样式
const menuListStyle = computed(
(): CSSProperties => ({
padding: `${props.menuPadding}px`,
})
);
// 计算菜单项样式
const menuItemStyle = computed(
(): CSSProperties => ({
height: `${props.itemHeight}px`,
padding: `0 ${props.itemPaddingX}px`,
borderRadius: "4px",
})
);
// 计算子菜单列表样式
const submenuListStyle = computed(
(): CSSProperties => ({
minWidth: `${props.submenuWidth}px`,
padding: `${props.menuPadding}px 0`,
borderRadius: `${props.borderRadius}px`,
})
);
// 计算菜单高度(用于边界检测)
const calculateMenuHeight = (): number => {
let totalHeight = props.menuPadding * 2; // 上下内边距
props.menuItems.forEach((item) => {
totalHeight += props.itemHeight;
if (item.showLine) {
totalHeight += 10; // 分割线额外高度
}
});
return totalHeight;
};
// 优化的位置计算函数
const calculatePosition = (e: MouseEvent) => {
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
const menuHeight = calculateMenuHeight();
let x = e.clientX;
let y = e.clientY;
// 检查右边界 - 优先显示在鼠标右侧,如果空间不足则显示在左侧
if (x + props.menuWidth > screenWidth - props.boundaryDistance) {
x = Math.max(props.boundaryDistance, x - props.menuWidth);
}
// 检查下边界 - 优先显示在鼠标下方,如果空间不足则向上调整
if (y + menuHeight > screenHeight - props.boundaryDistance) {
y = Math.max(props.boundaryDistance, screenHeight - menuHeight - props.boundaryDistance);
}
// 确保不会超出边界
x = Math.max(
props.boundaryDistance,
Math.min(x, screenWidth - props.menuWidth - props.boundaryDistance)
);
y = Math.max(
props.boundaryDistance,
Math.min(y, screenHeight - menuHeight - props.boundaryDistance)
);
return { x, y };
};
// 添加事件监听器
const addEventListeners = () => {
if (eventListenersAdded) return;
document.addEventListener("click", handleDocumentClick);
document.addEventListener("contextmenu", handleDocumentContextmenu);
document.addEventListener("keydown", handleKeydown);
eventListenersAdded = true;
};
// 移除事件监听器
const removeEventListeners = () => {
if (!eventListenersAdded) return;
document.removeEventListener("click", handleDocumentClick);
document.removeEventListener("contextmenu", handleDocumentContextmenu);
document.removeEventListener("keydown", handleKeydown);
eventListenersAdded = false;
};
// 处理文档点击事件
const handleDocumentClick = (e: Event) => {
// 检查点击是否在菜单内部
const target = e.target as Element;
const menuElement = document.querySelector(".context-menu");
if (menuElement && menuElement.contains(target)) {
return;
}
hide();
};
// 处理文档右键事件
const handleDocumentContextmenu = () => {
hide();
};
// 处理键盘事件
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
hide();
}
};
const show = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// 清理之前的定时器
if (showTimer) {
window.clearTimeout(showTimer);
showTimer = null;
}
// 计算位置
position.value = calculatePosition(e);
visible.value = true;
emit("show");
// 延迟添加事件监听器,避免立即触发关闭
showTimer = window.setTimeout(() => {
if (visible.value) {
addEventListeners();
}
showTimer = null;
}, 50); // 减少延迟时间,提升响应性
};
const hide = () => {
if (!visible.value) return;
visible.value = false;
emit("hide");
// 清理定时器
if (showTimer) {
window.clearTimeout(showTimer);
showTimer = null;
}
// 移除事件监听器
removeEventListeners();
};
const handleMenuClick = (item: MenuItemType) => {
if (item.disabled) return;
emit("select", item);
hide();
};
// 动画钩子函数
const onBeforeEnter = (el: Element) => {
const element = el as HTMLElement;
element.style.transformOrigin = "top left";
};
const onAfterLeave = () => {
// 确保清理所有资源
removeEventListeners();
if (showTimer) {
window.clearTimeout(showTimer);
showTimer = null;
}
};
// 组件卸载时清理资源
onUnmounted(() => {
removeEventListeners();
if (showTimer) {
window.clearTimeout(showTimer);
showTimer = null;
}
});
// 导出方法供父组件调用
defineExpose({
show,
hide,
visible: computed(() => visible.value),
});
</script>
<style scoped>
.menu-right {
--menu-width: v-bind('props.menuWidth + "px"');
--border-radius: v-bind('props.borderRadius + "px"');
}
.menu-item.has-line {
margin-bottom: 10px;
}
.menu-item.has-line::after {
position: absolute;
right: 0;
bottom: -5px;
left: 0;
height: 1px;
content: "";
background-color: var(--art-gray-300);
}
.menu-item.is-disabled {
color: var(--el-text-color-disabled);
cursor: not-allowed;
}
.menu-item.is-disabled:hover {
background-color: transparent !important;
}
.menu-item.is-disabled i:not(.submenu-arrow),
.menu-item.is-disabled :deep(.art-svg-icon) {
color: var(--el-text-color-disabled) !important;
}
.menu-item.is-disabled .menu-label {
color: var(--el-text-color-disabled) !important;
}
.menu-item.submenu:hover .submenu-list {
display: block;
}
.menu-item.submenu:hover .submenu-title .submenu-arrow {
transform: rotate(90deg);
}
/* 动画样式 */
.context-menu-enter-active,
.context-menu-leave-active {
transition: all v-bind('props.animationDuration + "ms"') ease-out;
}
.context-menu-enter-from,
.context-menu-leave-to {
opacity: 0;
transform: scale(0.9);
}
.context-menu-enter-to,
.context-menu-leave-from {
opacity: 1;
transform: scale(1);
}
</style>
@@ -0,0 +1,86 @@
<!-- 水印组件 -->
<template>
<div
v-if="watermarkVisible"
class="fixed left-0 top-0 h-screen w-screen pointer-events-none"
:style="{ zIndex: zIndex }"
>
<ElWatermark
:content="content"
:font="watermarkFont"
:rotate="rotate"
:gap="[gapX, gapY]"
:offset="[offsetX, offsetY]"
>
<div style="height: 100vh"></div>
</ElWatermark>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { storeToRefs } from "pinia";
import AppConfig from "@/config";
import { defaultSettings } from "@/config/setting";
import { ThemeMode } from "@/enums";
import { hexToRgba } from "@utils/ui";
import { useSettingsStore } from "@stores/modules/setting.store";
defineOptions({ name: "ArtWatermark" });
interface WatermarkProps {
/** 水印内容 */
content?: string;
/** 水印是否可见 */
visible?: boolean;
/** 水印字体大小 */
fontSize?: number;
/** 水印字体颜色(不传则跟随设置里的主题色) */
fontColor?: string;
/** 水印旋转角度 */
rotate?: number;
/** 水印间距X */
gapX?: number;
/** 水印间距Y */
gapY?: number;
/** 水印偏移X */
offsetX?: number;
/** 水印偏移Y */
offsetY?: number;
/** 水印层级 */
zIndex?: number;
}
const props = withDefaults(defineProps<WatermarkProps>(), {
content: AppConfig.systemInfo.name,
visible: false,
fontSize: 16,
fontColor: undefined,
rotate: -22,
gapX: 100,
gapY: 100,
offsetX: 50,
offsetY: 50,
zIndex: 3100,
});
const settingStore = useSettingsStore();
const { watermarkVisible, themeColor, theme } = storeToRefs(settingStore);
/** 未指定 fontColor 时使用当前主题色半透明,与 App.vue 全局水印策略一致 */
const watermarkFont = computed(() => {
let color: string;
if (props.fontColor) {
color = props.fontColor;
} else {
const hex = themeColor.value || defaultSettings.themeColor;
const alpha = theme.value === ThemeMode.DARK ? 0.22 : 0.16;
try {
color = hexToRgba(hex, alpha).rgba;
} catch {
color = hexToRgba(defaultSettings.themeColor, alpha).rgba;
}
}
return { fontSize: props.fontSize, color };
});
</script>
@@ -0,0 +1,27 @@
<template>
<component :is="tag" class="art-widget-demo-title">
<slot />
</component>
</template>
<script setup lang="ts">
/**
* Widget 演示页章节标题(与历史 `page-title` Tailwind 一致)。
* 业务页如需同款排版可直接复用;CRUD 列表页优先用 ArtSearchBar + 表格区标题即可。
*/
withDefaults(
defineProps<{
/** 页面唯一主标题用 h1,分区用 h2 */
tag?: "h1" | "h2";
}>(),
{ tag: "h1" }
);
</script>
<style scoped>
@reference "@styles/core/tailwind.css";
.art-widget-demo-title {
@apply my-5 text-xl font-medium first:mt-0;
}
</style>
@@ -0,0 +1 @@
export { default as ArtWidgetDemoTitle } from "./ArtWidgetDemoTitle.vue";
@@ -0,0 +1,134 @@
<!-- 列表页左侧工具栏:1) configButtons 与 CrudContent 配置驱动一致 2) perm 预设「新增/导入/导出/批删/更多」 3) 默认插槽可整块替换 -->
<template>
<div class="data-table__toolbar--left inline-flex flex-wrap items-center gap-2">
<template v-if="configButtons && configButtons.length">
<template v-for="(btn, index) in configButtons" :key="index">
<ElButton
v-hasPerm="btn.perm ?? '*:*:*'"
v-bind="btn.attrs"
:disabled="btn.name === 'delete' && removeIds.length === 0"
@click="$emit('toolbar', btn.name)"
>
{{ btn.text }}
</ElButton>
</template>
</template>
<slot v-else>
<slot>
<ElSpace>
<ElButton
v-if="permCreate"
v-hasPerm="permCreate"
type="success"
:icon="Plus"
@click="$emit('add')"
plain
>
新增
</ElButton>
<ElButton
v-if="permImport"
v-hasPerm="permImport"
v-ripple
type="warning"
:loading="importLoading"
:icon="Upload"
@click="$emit('import')"
plain
>
导入
</ElButton>
<ElButton
v-if="permExport"
v-hasPerm="permExport"
v-ripple
type="primary"
:loading="exportLoading"
:icon="Download"
@click="$emit('export')"
plain
>
导出
</ElButton>
<ElButton
v-if="permDelete"
v-hasPerm="permDelete"
type="danger"
:loading="deleteLoading"
:disabled="removeIds.length === 0"
:icon="Delete"
@click="$emit('delete')"
plain
>
批量删除
</ElButton>
<ElDropdown v-if="permPatch" v-hasPerm="permPatch" trigger="click">
<ElButton type="default" :disabled="removeIds.length === 0 || moreDisabled">
<template #icon>
<ArrowDown />
</template>
更多
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem icon="Check" @click="$emit('more', '0')">批量启用</ElDropdownItem>
<ElDropdownItem icon="CircleClose" @click="$emit('more', '1')">
批量停用
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</ElSpace>
</slot>
</slot>
</div>
</template>
<script setup lang="ts">
import { ArrowDown, Delete, Download, Plus, Upload } from "@element-plus/icons-vue";
import { computed } from "vue";
import type { ArtTableHeaderLeftConfigButton } from "@/components/Core/modal/types";
const props = withDefaults(
defineProps<{
/** 与 CrudContent `toolbarLeftBtn` 一致时走配置驱动(与 handleToolbar 对齐) */
configButtons?: ArtTableHeaderLeftConfigButton[];
/** 勾选行主键,用于禁用批删 / 更多(插槽完全自定义时可不传) */
removeIds?: Array<string | number>;
/** 新增按钮权限,不传则不显示(configButtons 未传时) */
permCreate?: string | string[];
/** 导入按钮权限,不传则不显示;顺序在新增之后、批量删除之前 */
permImport?: string | string[];
/** 导出按钮权限,不传则不显示 */
permExport?: string | string[];
/** 导入按钮 loading */
importLoading?: boolean;
/** 导出按钮 loading */
exportLoading?: boolean;
/** 批量删除权限,不传则不显示 */
permDelete?: string | string[];
/** 「更多」下拉权限,不传则不显示 */
permPatch?: string | string[];
/** 批量删除中(按钮 loading,并禁用「更多」) */
deleteLoading?: boolean;
}>(),
{
removeIds: () => [],
deleteLoading: false,
importLoading: false,
exportLoading: false,
}
);
defineEmits<{
/** 配置模式:与 CrudContent handleToolbar 一致 */
toolbar: [name: string];
add: [];
import: [];
export: [];
delete: [];
more: [value: string];
}>();
const moreDisabled = computed(() => props.removeIds.length === 0 || props.deleteLoading);
</script>
@@ -0,0 +1,358 @@
<!-- 表格头部,包含表格大小、刷新、全屏、列设置、其他设置 -->
<template>
<div class="flex-cb max-md:!block" id="art-table-header">
<div class="flex-wrap">
<slot name="left"></slot>
</div>
<div class="flex-c md:justify-end max-md:mt-3 max-sm:!hidden">
<!-- 搜索区域显示/隐藏:默认展示搜索(未高亮);点按收起后高亮表示当前为隐藏状态 -->
<ElTooltip
v-if="showSearchBar != null"
placement="bottom"
:content="showSearchBar ? t('table.toolbar.hideSearch') : t('table.toolbar.showSearch')"
>
<div
class="button"
@click="search"
:class="!showSearchBar ? 'active !bg-theme hover:!bg-theme/80' : ''"
>
<ArtSvgIcon icon="ri:search-line" :class="!showSearchBar ? 'text-white' : 'text-g-700'" />
</div>
</ElTooltip>
<!-- 刷新 -->
<div
v-if="shouldShow('refresh')"
class="button"
@click="refresh"
:class="{ loading: loading && isManualRefresh }"
>
<ArtSvgIcon
icon="ri:refresh-line"
:class="loading && isManualRefresh ? 'animate-spin text-g-600' : ''"
/>
</div>
<!-- 表格大小 -->
<ElDropdown v-if="shouldShow('size')" @command="handleTableSizeChange">
<div class="button">
<ArtSvgIcon icon="ri:arrow-up-down-fill" />
</div>
<template #dropdown>
<ElDropdownMenu>
<div
v-for="item in tableSizeOptions"
:key="item.value"
class="table-size-btn-item [&_.el-dropdown-menu__item]:!mb-[3px] last:[&_.el-dropdown-menu__item]:!mb-0"
>
<ElDropdownItem
:key="item.value"
:command="item.value"
:class="tableSize === item.value ? '!bg-g-300/55' : ''"
>
{{ item.label }}
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<!-- 全屏 -->
<div v-if="shouldShow('fullscreen')" class="button" @click="toggleFullScreen">
<ArtSvgIcon :icon="isFullScreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'" />
</div>
<!-- 行拖拽排序 -->
<ElTooltip
v-if="shouldShow('rowDrag')"
placement="bottom"
:content="isRowDrag ? t('table.toolbar.disableRowDrag') : t('table.toolbar.enableRowDrag')"
>
<div
class="button"
@click="toggleRowDrag"
:class="isRowDrag ? 'active !bg-theme hover:!bg-theme/80' : ''"
>
<ArtSvgIcon icon="ri:drag-move-line" :class="isRowDrag ? 'text-white' : 'text-g-700'" />
</div>
</ElTooltip>
<!-- 列设置 -->
<ElPopover v-if="shouldShow('columns')" placement="bottom" trigger="click">
<template #reference>
<div class="button">
<ArtSvgIcon icon="ri:align-right" />
</div>
</template>
<div>
<ElScrollbar max-height="380px">
<VueDraggable
v-model="columns"
:disabled="false"
filter=".fixed-column"
:prevent-on-filter="false"
@move="checkColumnMove"
>
<div
v-for="item in columns"
:key="item.prop || item.type"
class="column-option flex-c"
:class="{ 'fixed-column': item.fixed }"
>
<div
class="drag-icon mr-2 h-4.5 flex-cc text-g-500"
:class="item.fixed ? 'cursor-default text-g-300' : 'cursor-move'"
>
<ArtSvgIcon
:icon="item.fixed ? 'ri:unpin-line' : 'ri:drag-move-2-fill'"
class="text-base"
/>
</div>
<ElCheckbox
:model-value="getColumnVisibility(item)"
@update:model-value="(val) => updateColumnVisibility(item, val)"
:disabled="item.disabled"
class="flex-1 min-w-0 [&_.el-checkbox__label]:overflow-hidden [&_.el-checkbox__label]:text-ellipsis [&_.el-checkbox__label]:whitespace-nowrap"
>
{{ item.label || (item.type === "selection" ? t("table.selection") : "") }}
</ElCheckbox>
</div>
</VueDraggable>
</ElScrollbar>
</div>
</ElPopover>
<!-- 其他设置 -->
<ElPopover v-if="shouldShow('settings')" placement="bottom" trigger="click">
<template #reference>
<div class="button">
<ArtSvgIcon icon="ri:settings-line" />
</div>
</template>
<div class="flex min-w-[200px] flex-col gap-2">
<ElCheckbox v-model="isZebra" :value="true">
{{ t("table.zebra") }}
</ElCheckbox>
<ElCheckbox v-model="isBorder" :value="true">
{{ t("table.border") }}
</ElCheckbox>
<ElCheckbox v-model="isHeaderBackground" :value="true">
{{ t("table.headerBackground") }}
</ElCheckbox>
</div>
</ElPopover>
<slot name="right"></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import { TableSizeEnum } from "@/enums/formEnum";
import { useTableStore } from "@stores/modules/table.store";
import { VueDraggable } from "vue-draggable-plus";
import { useI18n } from "vue-i18n";
import type { ColumnOption } from "@/types/component";
import { ElScrollbar } from "element-plus";
defineOptions({ name: "ArtTableHeader" });
const { t } = useI18n();
interface Props {
/** 全屏 class */
fullClass?: string;
/** 组件布局,子组件名用逗号分隔 */
layout?: string;
/** 加载中 */
loading?: boolean;
/** 搜索栏显示状态 */
showSearchBar?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
fullClass: "art-page-view",
layout: "search,refresh,size,fullscreen,columns,rowDrag,settings",
showSearchBar: undefined,
});
const columns = defineModel<ColumnOption[]>("columns", {
required: false,
default: () => [],
});
const emit = defineEmits<{
(e: "refresh"): void;
(e: "search"): void;
(e: "update:showSearchBar", value: boolean): void;
}>();
/**
* 获取列的显示状态
* 优先使用 visible 字段,如果不存在则使用 checked 字段
*/
const getColumnVisibility = (col: ColumnOption): boolean => {
if (col.visible !== undefined) {
return col.visible;
}
return col.checked ?? true;
};
/**
* 更新列的显示状态
* 同时更新 checked 和 visible 字段以保持兼容性
*/
const updateColumnVisibility = (col: ColumnOption, value: boolean | string | number): void => {
const boolValue = !!value;
col.checked = boolValue;
col.visible = boolValue;
};
/** 表格大小选项配置 */
const tableSizeOptions = [
{ value: TableSizeEnum.SMALL, label: t("table.sizeOptions.small") },
{ value: TableSizeEnum.DEFAULT, label: t("table.sizeOptions.default") },
{ value: TableSizeEnum.LARGE, label: t("table.sizeOptions.large") },
];
const tableStore = useTableStore();
const { tableSize, isZebra, isBorder, isHeaderBackground, isRowDrag } = storeToRefs(tableStore);
const toggleRowDrag = () => {
tableStore.setIsRowDrag(!isRowDrag.value);
};
/** 解析 layout 属性,转换为数组 */
const layoutItems = computed(() => {
return props.layout.split(",").map((item) => item.trim());
});
/**
* 检查组件是否应该显示
* @param componentName 组件名称
* @returns 是否显示
*/
const shouldShow = (componentName: string) => {
return layoutItems.value.includes(componentName);
};
/**
* 拖拽移动事件处理 - 防止固定列位置改变
* @param evt move事件对象
* @returns 是否允许移动
*/
const checkColumnMove = (event: any) => {
// 拖拽进入的目标 DOM 元素
const toElement = event.related as HTMLElement;
// 如果目标位置是 fixed 列,则不允许移动
if (toElement && toElement.classList.contains("fixed-column")) {
return false;
}
return true;
};
/** 搜索事件处理 */
const search = () => {
// 切换搜索栏显示状态
emit("update:showSearchBar", !props.showSearchBar);
emit("search");
};
/** 刷新事件处理 */
const refresh = () => {
isManualRefresh.value = true;
emit("refresh");
};
/**
* 表格大小变化处理
* @param command 表格大小枚举值
*/
const handleTableSizeChange = (command: TableSizeEnum) => {
useTableStore().setTableSize(command);
};
/** 是否手动点击刷新 */
const isManualRefresh = ref(false);
/** 加载中 */
const isFullScreen = ref(false);
/** 保存原始的 overflow 样式,用于退出全屏时恢复 */
const originalOverflow = ref("");
/**
* 切换全屏状态
* 进入全屏时会隐藏页面滚动条,退出时恢复原状态
*/
const toggleFullScreen = () => {
const el = document.querySelector(`.${props.fullClass}`);
if (!el) return;
isFullScreen.value = !isFullScreen.value;
if (isFullScreen.value) {
// 进入全屏:保存原始样式并隐藏滚动条
originalOverflow.value = document.body.style.overflow;
document.body.style.overflow = "hidden";
el.classList.add("el-full-screen");
tableStore.setIsFullScreen(true);
} else {
// 退出全屏:恢复原始样式
document.body.style.overflow = originalOverflow.value;
el.classList.remove("el-full-screen");
tableStore.setIsFullScreen(false);
}
};
/**
* ESC键退出全屏的事件处理器
* 需要保存引用以便在组件卸载时正确移除监听器
*/
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullScreen.value) {
toggleFullScreen();
}
};
/** 组件挂载时注册全局事件监听器 */
onMounted(() => {
document.addEventListener("keydown", handleEscapeKey);
});
/** 组件卸载时清理资源 */
onUnmounted(() => {
// 移除事件监听器
document.removeEventListener("keydown", handleEscapeKey);
// 如果组件在全屏状态下被卸载,恢复页面滚动状态
if (isFullScreen.value) {
document.body.style.overflow = originalOverflow.value;
const el = document.querySelector(`.${props.fullClass}`);
if (el) {
el.classList.remove("el-full-screen");
}
}
});
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
.button {
@apply ml-2
size-8
flex
items-center
justify-center
cursor-pointer
rounded-md
bg-g-300/55
dark:bg-g-300/40
text-g-700
hover:bg-g-300
md:ml-0
md:mr-2.5;
}
</style>
@@ -0,0 +1,483 @@
<!-- 表格组件 -->
<!-- 支持:el-table 全部属性、事件、插槽,同官方文档写法 -->
<!-- 扩展功能:分页组件、渲染自定义列、loading、表格全局边框、斑马纹、表格尺寸、表头背景配置 -->
<!-- 获取 ref:默认暴露了 elTableRef 外部通过 ref.value.elTableRef 可以调用 el-table 方法 -->
<template>
<div class="art-table" :class="{ 'is-empty': isEmpty }" :style="containerHeight">
<VueDraggable
target="tbody"
v-model="dragModel"
:animation="150"
:disabled="rowDragDisabled"
@end="onRowDragEnd"
>
<ElTable ref="elTableRef" v-loading="!!loading" v-bind="mergedTableProps">
<template v-for="col in columns" :key="col.prop || col.type">
<!-- 渲染全局序号列 -->
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
<template #default="{ $index }">
<span>{{ getGlobalIndex($index) }}</span>
</template>
</ElTableColumn>
<!-- 渲染展开行 -->
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
<template #default="{ row: expandRow }">
<component :is="col.formatter ? col.formatter(expandRow) : null" />
</template>
</ElTableColumn>
<!-- 渲染普通列:default 插槽须紧凑书写,避免空白文本抢占 formatter(见 renderColumnFormatter 注释) -->
<ElTableColumn v-else v-bind="cleanBodyColumnProps(col)">
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
<slot
:name="col.headerSlotName || `${col.prop}-header`"
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
>
{{ col.label }}
</slot>
</template>
<!-- 整段单行:插槽内兄弟节点之间的空白文本会让 EP 误判单元格内容 -->
<!-- eslint-disable-next-line vue/max-attributes-per-line, prettier/prettier -->
<template #default="slotScope">
<slot
v-if="col.useSlot && col.prop && shouldRenderSlotScope(slotScope)"
:name="col.slotName || col.prop"
v-bind="{
...slotScope,
prop: col.prop,
value: col.prop ? slotScope.row[col.prop] : undefined,
}"
/>
<TableFormatterOutlet
v-else-if="col.formatter && !col.useSlot && shouldRenderSlotScope(slotScope)"
:column="col"
:record="slotScope.row"
/>
</template>
</ElTableColumn>
</template>
<template v-if="$slots.default" #default><slot /></template>
<template #empty>
<div v-if="loading"></div>
<ElEmpty v-else :description="emptyText" :image-size="120" />
</template>
</ElTable>
</VueDraggable>
<div
class="pagination custom-pagination"
v-if="showPagination"
:class="mergedPaginationOptions?.align"
ref="paginationRef"
>
<Pagination
v-if="pagination"
:page="pagination.current"
:limit="pagination.size"
:total="pagination.total"
:page-sizes="mergedPaginationOptions.pageSizes"
:layout="mergedPaginationOptions.layout"
:background="mergedPaginationOptions.background ?? true"
:disabled="!!loading"
:hidden="paginationHidden"
:pager-count="mergedPaginationOptions.pagerCount"
:size="mergedPaginationOptions.size"
@pagination="handlePaginationEvent"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref,
computed,
nextTick,
watchEffect,
getCurrentInstance,
useAttrs,
isVNode,
h,
defineComponent,
type PropType,
} from "vue";
import type { ElTable, TableProps } from "element-plus";
import { storeToRefs } from "pinia";
import { ColumnOption } from "@/types";
import { useTableStore } from "@stores/modules/table.store";
import { useCommon } from "@/hooks/core/useCommon";
import { useTableHeight } from "@/hooks/core/useTableHeight";
import { useResizeObserver, useWindowSize } from "@vueuse/core";
import Pagination from "@/components/Pagination/index.vue";
import { VueDraggable } from "vue-draggable-plus";
defineOptions({ name: "ArtTable" });
const { width } = useWindowSize();
const elTableRef = ref<InstanceType<typeof ElTable> | null>(null);
const paginationRef = ref<HTMLElement>();
const tableHeaderRef = ref<HTMLElement>();
const tableStore = useTableStore();
const { isBorder, isZebra, tableSize, isFullScreen, isHeaderBackground, isRowDrag } =
storeToRefs(tableStore);
/** 分页配置接口 */
interface PaginationConfig {
/** 当前页码 */
current: number;
/** 每页显示条目个数 */
size: number;
/** 总条目数 */
total: number;
}
/** 分页器配置选项接口 */
interface PaginationOptions {
/** 每页显示个数选择器的选项列表 */
pageSizes?: number[];
/** 分页器的对齐方式 */
align?: "left" | "center" | "right";
/** 分页器的布局 */
layout?: string;
/** 是否显示分页器背景 */
background?: boolean;
/** 只有一页时是否隐藏分页器 */
hideOnSinglePage?: boolean;
/** 分页器的大小 */
size?: "small" | "default" | "large";
/** 分页器的页码数量 */
pagerCount?: number;
}
/** ArtTable 组件的 Props 接口 */
interface ArtTableProps extends TableProps<Record<string, any>> {
/** 加载状态 */
loading?: boolean;
/** 列渲染配置 */
columns?: ColumnOption[];
/** 分页状态 */
pagination?: PaginationConfig;
/** 分页配置 */
paginationOptions?: PaginationOptions;
/** 空数据表格高度 */
emptyHeight?: string;
/** 空数据时显示的文本 */
emptyText?: string;
/** 是否开启 ArtTableHeader,解决表格高度自适应问题 */
showTableHeader?: boolean;
/** 为 true 时关闭行拖拽(忽略工具栏「行拖拽」开关) */
disableRowDrag?: boolean;
}
const props = withDefaults(defineProps<ArtTableProps>(), {
columns: () => [],
fit: true,
showHeader: true,
stripe: undefined,
border: undefined,
size: undefined,
emptyHeight: "100%",
emptyText: "暂无数据",
showTableHeader: true,
disableRowDrag: false,
});
const instance = getCurrentInstance();
const attrs = useAttrs();
/** 仅当调用方显式传入对应 prop 时视为「固定」,否则交由表格 store */
const hasExplicitTableProp = (propName: string): boolean => {
const rawProps = (instance?.vnode.props || {}) as Record<string, unknown>;
const kebabName = propName.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
return propName in rawProps || kebabName in rawProps;
};
const LAYOUT = {
MOBILE: "prev, pager, next, sizes, jumper, total",
IPAD: "prev, pager, next, jumper, total",
DESKTOP: "total, prev, pager, next, sizes, jumper",
};
const layout = computed(() => {
if (width.value < 768) {
return LAYOUT.MOBILE;
} else if (width.value < 1024) {
return LAYOUT.IPAD;
} else {
return LAYOUT.DESKTOP;
}
});
// 默认分页常量
const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = {
pageSizes: [10, 20, 30, 50, 100],
align: "center",
background: true,
layout: layout.value,
hideOnSinglePage: false,
size: "default",
pagerCount: width.value > 1200 ? 7 : 5,
};
// 合并分页配置
const mergedPaginationOptions = computed(() => ({
...DEFAULT_PAGINATION_OPTIONS,
...props.paginationOptions,
}));
/** 对齐 ElPagination hide-on-single-page,交给封装组件的 hidden */
const paginationHidden = computed(() => {
const p = props.pagination;
const opts = mergedPaginationOptions.value;
if (!p || !opts.hideOnSinglePage) return false;
const size = p.size || 10;
const total = p.total ?? 0;
if (total <= 0) return false;
return Math.ceil(total / size) <= 1;
});
// 边框 (优先级:props > store)
const border = computed(() => props.border ?? isBorder.value);
// 斑马纹
const stripe = computed(() => props.stripe ?? isZebra.value);
// 表格尺寸
const size = computed(() => props.size ?? tableSize.value);
// 数据是否为空
const isEmpty = computed(() => props.data?.length === 0);
const paginationHeight = ref(0);
const tableHeaderHeight = ref(0);
// 使用 useResizeObserver 监听分页器高度变化
useResizeObserver(paginationRef, (entries) => {
const entry = entries[0];
if (entry) {
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
requestAnimationFrame(() => {
paginationHeight.value = entry.contentRect.height;
});
}
});
// 使用 useResizeObserver 监听表格头部高度变化
useResizeObserver(tableHeaderRef, (entries) => {
const entry = entries[0];
if (entry) {
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
requestAnimationFrame(() => {
tableHeaderHeight.value = entry.contentRect.height;
});
}
});
// 分页器与表格之间的间距常量(计算属性,响应 showTableHeader 变化)
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15));
// 使用表格高度计算 Hook
const { containerHeight } = useTableHeight({
showTableHeader: computed(() => props.showTableHeader),
paginationHeight,
tableHeaderHeight,
paginationSpacing: PAGINATION_SPACING,
});
// 表格高度逻辑
const height = computed(() => {
// 全屏模式下占满全屏
if (isFullScreen.value) return "100%";
// 空数据且非加载状态时固定高度
if (isEmpty.value && !props.loading) return props.emptyHeight;
// 使用传入的高度
if (props.height) return props.height;
// 默认占满容器高度
return "100%";
});
// 表头背景颜色样式
const headerCellStyle = computed(() => ({
background: isHeaderBackground.value
? "var(--el-fill-color-lighter)"
: "var(--default-box-color)",
...(props.headerCellStyle || {}), // 合并用户传入的样式
}));
const mergedTableProps = computed(() => ({
...attrs,
...props,
height: height.value,
stripe: stripe.value,
border: border.value,
size: size.value,
headerCellStyle: headerCellStyle.value,
// Element Plus 默认值为 true,未显式传入时不应被 ArtTable 覆盖成 false。
selectOnIndeterminate: hasExplicitTableProp("selectOnIndeterminate")
? props.selectOnIndeterminate
: undefined,
}));
const emit = defineEmits<{
(e: "pagination:size-change", val: number): void;
(e: "pagination:current-change", val: number): void;
(e: "update:data", val: Record<string, unknown>[]): void;
(e: "row-order-change", val: Record<string, unknown>[]): void;
}>();
/** 无 data 时用固定空数组,避免 v-model 每次拿到新 [] */
const emptyDataStub = ref<Record<string, unknown>[]>([]);
const dragModel = computed({
get() {
const d = props.data;
if (Array.isArray(d)) return d;
return emptyDataStub.value;
},
set(val) {
emit("update:data", val);
},
});
const rowDragActive = computed(() => !props.disableRowDrag && isRowDrag.value);
const rowDragDisabled = computed(() => !rowDragActive.value || !!props.loading);
const onRowDragEnd = () => {
const d = props.data;
if (Array.isArray(d)) {
emit("row-order-change", d as Record<string, unknown>[]);
}
};
// 是否显示分页器
const showPagination = computed(() => props.pagination && !isEmpty.value);
// Element Plus 在部分场景会先用 $index = -1 进行预渲染。
// 这对普通展示无影响,但会让 ElForm 错误注册出 lineList.-1.xxx 这类字段。
const shouldRenderSlotScope = (slotScope: { $index?: number }) => {
return slotScope.$index === undefined || slotScope.$index >= 0;
};
/**
* ElTableColumn 若存在 default 插槽且插槽产物含任意非 Comment 的 vnode(含空白文本节点),
* 将不会执行 formatter(见 element-plus render-helper setColumnRenders)。
* ArtTable 中 ElTableColumn 与子节点之间的换行/缩进可能被编译进默认插槽,导致 formatter(如操作列里的按钮)永远不渲染。
* 对声明了 formatter 且未使用 useSlot 的列,在此显式渲染 formatter 返回值。
*/
const renderColumnFormatter = (col: ColumnOption, row: Record<string, unknown>) => {
if (!col.formatter) return null;
const result = col.formatter(row as never);
if (isVNode(result)) return result;
if (result === null || result === undefined) return null;
return h("span", String(result));
};
/**
* 在 render 里调用 formatter(row) 生成 VNode;勿把 VNode 当 props 传入(克隆后会失效)。
* 与 renderColumnFormatter 同文件定义,保证闭包一致。
*/
const TableFormatterOutlet = defineComponent({
name: "TableFormatterOutlet",
props: {
column: { type: Object as PropType<ColumnOption>, required: true },
/** 避免 prop 名 row 与插槽解构冲突 */
record: { type: Object as PropType<Record<string, unknown>>, required: true },
},
setup(props) {
return () => renderColumnFormatter(props.column, props.record);
},
});
// 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释
const cleanColumnProps = (col: ColumnOption) => {
const columnProps = { ...col };
// 删除自定义的插槽控制属性
delete columnProps.useHeaderSlot;
delete columnProps.headerSlotName;
delete columnProps.useSlot;
delete columnProps.slotName;
return columnProps;
};
/** 普通列:单元格已由插槽内 TableFormatterOutlet 渲染,勿再把 formatter 传给 ElTableColumn,避免与 EP 内置 renderCell 混用 */
const cleanBodyColumnProps = (col: ColumnOption) => {
const columnProps = cleanColumnProps(col);
delete columnProps.formatter;
return columnProps;
};
const { scrollToTop: scrollPageToTop } = useCommon();
// 滚动表格内容到顶部,并可以联动页面滚动到顶部
const scrollToTop = () => {
nextTick(() => {
elTableRef.value?.setScrollTop(0); // 滚动 ElTable 内部滚动条到顶部
scrollPageToTop(); // 调用公共 composable 滚动页面到顶部
});
};
/** 对接封装分页 @pagination,保持对外仍为 size-change / current-change 事件 */
const handlePaginationEvent = (payload: { page: number; limit: number }) => {
const p = props.pagination;
if (!p) return;
if (payload.limit !== p.size) {
emit("pagination:size-change", payload.limit);
return;
}
if (payload.page !== p.current) {
emit("pagination:current-change", payload.page);
scrollToTop();
}
};
// 全局序号
const getGlobalIndex = (index: number) => {
if (!props.pagination) return index + 1;
const { current, size } = props.pagination;
return (current - 1) * size + index + 1;
};
// 查找并绑定表格头部元素 - 使用 VueUse 优化
const findTableHeader = () => {
if (!props.showTableHeader) {
tableHeaderRef.value = undefined;
return;
}
const tableHeader = document.getElementById("art-table-header");
if (tableHeader) {
tableHeaderRef.value = tableHeader;
} else {
// 如果找不到表格头部,设置为 undefined,useElementSize 会返回 0
tableHeaderRef.value = undefined;
}
};
watchEffect(
() => {
// 访问响应式数据以建立依赖追踪
void props.data?.length; // 追踪数据变化
const shouldShow = props.showTableHeader;
// 只有在需要显示表格头部时才查找
if (shouldShow) {
nextTick(() => {
findTableHeader();
});
} else {
// 不显示时清空引用
tableHeaderRef.value = undefined;
}
},
{ flush: "post" }
);
defineExpose({
scrollToTop,
elTableRef,
});
</script>
<style lang="scss" scoped>
@use "./style";
</style>
@@ -0,0 +1,99 @@
.art-table {
position: relative;
height: 100%;
.el-table {
height: 100%;
margin-top: 10px;
}
:deep(.el-loading-mask) {
z-index: 100;
background-color: var(--default-box-color) !important;
}
// Loading 过渡动画 - 消失时淡出
.loading-fade-leave-active {
transition: opacity 0.3s ease-out;
}
.loading-fade-leave-to {
opacity: 0;
}
// 空状态垂直居中
&.is-empty {
:deep(.el-scrollbar__wrap) {
display: flex;
}
}
.pagination {
display: flex;
margin-top: 13px;
:deep(.el-select) {
width: 102px !important;
}
// 分页对齐方式
&.left {
justify-content: flex-start;
}
&.center {
justify-content: center;
}
&.right {
justify-content: flex-end;
}
// 自定义分页组件样式
&.custom-pagination {
:deep(.el-pagination) {
.btn-prev,
.btn-next {
background-color: transparent;
border: 1px solid var(--art-gray-300);
transition: border-color 0.15s;
&:hover:not(.is-disabled) {
color: var(--theme-color);
border-color: var(--theme-color);
}
}
li {
box-sizing: border-box;
font-weight: 400 !important;
background-color: transparent;
border: 1px solid var(--art-gray-300);
transition: border-color 0.15s;
&.is-active {
font-weight: 400;
color: #fff;
background-color: var(--theme-color);
border: 1px solid var(--theme-color);
}
&:hover:not(.is-disabled) {
border-color: var(--theme-color);
}
}
}
}
}
}
// 移动端分页
@media (width <= 640px) {
:deep(.el-pagination) {
display: flex;
flex-wrap: wrap;
gap: 15px 0;
align-items: center;
justify-content: center;
}
}
@@ -0,0 +1,310 @@
<!-- 数字滚动 -->
<template>
<span
class="text-g-900 tabular-nums"
:class="isRunning ? 'transition-opacity duration-300 ease-in-out' : ''"
>
{{ formattedValue }}
</span>
</template>
<script setup lang="ts">
import { computed, watch, nextTick, onUnmounted, shallowRef } from "vue";
import { useTransition, TransitionPresets } from "@vueuse/core";
// 类型定义
interface CountToProps {
/** 目标值 */
target: number;
/** 动画持续时间(毫秒) */
duration?: number;
/** 是否自动开始 */
autoStart?: boolean;
/** 小数位数 */
decimals?: number;
/** 小数点符号 */
decimal?: string;
/** 千分位分隔符 */
separator?: string;
/** 前缀 */
prefix?: string;
/** 后缀 */
suffix?: string;
/** 缓动函数 */
easing?: keyof typeof TransitionPresets;
/** 是否禁用动画 */
disabled?: boolean;
}
interface CountToEmits {
started: [value: number];
finished: [value: number];
paused: [value: number];
reset: [];
}
interface CountToExpose {
start: (target?: number) => void;
pause: () => void;
reset: (newTarget?: number) => void;
stop: () => void;
setTarget: (target: number) => void;
readonly isRunning: boolean;
readonly isPaused: boolean;
readonly currentValue: number;
readonly targetValue: number;
readonly progress: number;
}
// 常量定义
const EPSILON = Number.EPSILON;
const MIN_DURATION = 100;
const MAX_DURATION = 60000;
const MAX_DECIMALS = 10;
const DEFAULT_EASING = "easeOutExpo";
const DEFAULT_DURATION = 2000;
const props = withDefaults(defineProps<CountToProps>(), {
target: 0,
duration: DEFAULT_DURATION,
autoStart: true,
decimals: 0,
decimal: ".",
separator: "",
prefix: "",
suffix: "",
easing: DEFAULT_EASING,
disabled: false,
});
const emit = defineEmits<CountToEmits>();
// 工具函数
const validateNumber = (value: number, name: string, defaultValue: number): number => {
if (!Number.isFinite(value)) {
console.warn(`[CountTo] Invalid ${name} value:`, value);
return defaultValue;
}
return value;
};
const clamp = (value: number, min: number, max: number): number => {
return Math.max(min, Math.min(value, max));
};
const formatNumber = (
value: number,
decimals: number,
decimal: string,
separator: string
): string => {
let result = decimals > 0 ? value.toFixed(decimals) : Math.floor(value).toString();
// 处理小数点符号
if (decimal !== "." && result.includes(".")) {
result = result.replace(".", decimal);
}
// 处理千分位分隔符
if (separator) {
const parts = result.split(decimal);
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator);
result = parts.join(decimal);
}
return result;
};
// 安全计算值
const safeTarget = computed(() => validateNumber(props.target, "target", 0));
const safeDuration = computed(() =>
clamp(validateNumber(props.duration, "duration", DEFAULT_DURATION), MIN_DURATION, MAX_DURATION)
);
const safeDecimals = computed(() =>
clamp(validateNumber(props.decimals, "decimals", 0), 0, MAX_DECIMALS)
);
const safeEasing = computed(() => {
const easing = props.easing;
if (!(easing in TransitionPresets)) {
console.warn("[CountTo] Invalid easing value:", easing);
return DEFAULT_EASING;
}
return easing;
});
// 状态管理
const currentValue = shallowRef(0);
const targetValue = shallowRef(safeTarget.value);
const isRunning = shallowRef(false);
const isPaused = shallowRef(false);
const pausedValue = shallowRef(0);
// 动画控制
const transitionValue = useTransition(currentValue, {
duration: safeDuration,
transition: computed(() => TransitionPresets[safeEasing.value]),
onStarted: () => {
isRunning.value = true;
isPaused.value = false;
emit("started", targetValue.value);
},
onFinished: () => {
isRunning.value = false;
isPaused.value = false;
emit("finished", targetValue.value);
},
});
// 格式化显示值
const formattedValue = computed(() => {
const value = isPaused.value ? pausedValue.value : transitionValue.value;
if (!Number.isFinite(value)) {
return `${props.prefix}0${props.suffix}`;
}
const formattedNumber = formatNumber(value, safeDecimals.value, props.decimal, props.separator);
return `${props.prefix}${formattedNumber}${props.suffix}`;
});
// 私有方法
const shouldSkipAnimation = (target: number): boolean => {
const current = isPaused.value ? pausedValue.value : transitionValue.value;
return Math.abs(current - target) < EPSILON;
};
const resetPauseState = (): void => {
isPaused.value = false;
pausedValue.value = 0;
};
// 公共方法
const start = (target?: number): void => {
if (props.disabled) {
console.warn("[CountTo] Animation is disabled");
return;
}
const finalTarget = target !== undefined ? target : targetValue.value;
if (!Number.isFinite(finalTarget)) {
console.warn("[CountTo] Invalid target value for start:", finalTarget);
return;
}
targetValue.value = finalTarget;
if (shouldSkipAnimation(finalTarget)) {
return;
}
// 从暂停值开始(如果存在)
if (isPaused.value) {
currentValue.value = pausedValue.value;
resetPauseState();
}
nextTick(() => {
currentValue.value = finalTarget;
});
};
const pause = (): void => {
if (!isRunning.value || isPaused.value) {
return;
}
isPaused.value = true;
pausedValue.value = transitionValue.value;
currentValue.value = pausedValue.value;
emit("paused", pausedValue.value);
};
const reset = (newTarget = 0): void => {
const target = validateNumber(newTarget, "reset target", 0);
currentValue.value = target;
targetValue.value = target;
resetPauseState();
emit("reset");
};
const setTarget = (target: number): void => {
if (!Number.isFinite(target)) {
console.warn("[CountTo] Invalid target value for setTarget:", target);
return;
}
targetValue.value = target;
if ((isRunning.value || props.autoStart) && !props.disabled) {
start(target);
}
};
const stop = (): void => {
if (isRunning.value || isPaused.value) {
currentValue.value = 0;
resetPauseState();
emit("paused", 0);
}
};
// 监听器
watch(
safeTarget,
(newTarget) => {
if (props.autoStart && !props.disabled) {
start(newTarget);
} else {
targetValue.value = newTarget;
}
},
{ immediate: props.autoStart && !props.disabled }
);
watch(
() => props.disabled,
(disabled) => {
if (disabled && isRunning.value) {
stop();
}
}
);
// 清理
onUnmounted(() => {
if (isRunning.value) {
stop();
}
});
// 暴露 API
defineExpose<CountToExpose>({
start,
pause,
reset,
stop,
setTarget,
get isRunning() {
return isRunning.value;
},
get isPaused() {
return isPaused.value;
},
get currentValue() {
return isPaused.value ? pausedValue.value : transitionValue.value;
},
get targetValue() {
return targetValue.value;
},
get progress() {
const current = isPaused.value ? pausedValue.value : transitionValue.value;
const target = targetValue.value;
if (target === 0) return current === 0 ? 1 : 0;
return Math.abs(current / target);
},
});
</script>
@@ -0,0 +1,50 @@
<!-- 节日 / 公告顶栏:文案来自 festival 配置(占位符 {{version}}、{{introduceUrl}}) -->
<template>
<div
class="overflow-hidden transition-[height] duration-300 ease-in-out"
:style="{ height: showFestivalStrip ? '48px' : '0' }"
>
<ArtTextScroll
v-if="showFestivalStrip"
class="!mb-3"
type="primary"
:text="festivalScrollDisplayHtml"
height="40px"
:speed="55"
:always-scroll="true"
show-close
@close="closeFestivalScroll"
/>
</div>
</template>
<script setup lang="ts">
import { useSettingsStore } from "@stores/modules/setting.store";
import { useCeremony } from "@/hooks/core/useCeremony";
import { WEB_LINKS } from "@utils/constants";
defineOptions({ name: "ArtFestivalTextScroll" });
const settingStore = useSettingsStore();
const { showFestivalText } = storeToRefs(settingStore);
const { currentFestivalData, closeFestivalScroll } = useCeremony();
function versionLabel(): string {
const v = String(import.meta.env.VITE_VERSION ?? "").trim();
if (!v) return "";
return v.startsWith("v") ? v : `v${v}`;
}
const festivalScrollDisplayHtml = computed(() => {
const raw = currentFestivalData.value?.scrollText ?? "";
const ver = versionLabel() || "v0.0.0";
return raw.replace(/\{\{version\}\}/g, ver).replace(/\{\{introduceUrl\}\}/g, WEB_LINKS.INTRODUCE);
});
const showFestivalStrip = computed(
() =>
showFestivalText.value &&
!!currentFestivalData.value?.scrollText &&
currentFestivalData.value.scrollText !== ""
);
</script>
@@ -0,0 +1,331 @@
<!-- 文字滚动 -->
<template>
<div
ref="containerRef"
class="relative overflow-hidden rounded-custom-sm border flex-c box-border text-sm"
:class="themeClasses"
:style="containerStyle"
>
<div class="flex-cc absolute left-0 h-full w-9 z-10" :style="{ backgroundColor: bgColor }">
<ArtSvgIcon icon="ri:volume-down-line" class="text-lg" />
</div>
<div
ref="contentRef"
class="whitespace-nowrap inline-block transition-opacity duration-600 [&_a]:text-danger [&_a:hover]:underline [&_a:hover]:text-danger/80 px-9"
:class="[contentClass, { 'opacity-0': !isReady, 'opacity-100': isReady }]"
:style="contentStyle"
@click="handleContentClick"
>
<!-- 原始内容 -->
<span ref="textRef" class="inline-block">
<slot>
<span v-html="text"></span>
</slot>
</span>
<!-- 克隆内容用于无缝循环 -->
<span v-if="shouldClone" class="inline-block" :style="cloneSpacing">
<slot>
<span v-html="text"></span>
</slot>
</span>
</div>
<div
v-if="showClose"
class="flex-cc absolute right-0 h-full w-9 c-p"
:style="{ backgroundColor: bgColor }"
@click="handleClose"
>
<ArtSvgIcon icon="ri:close-fill" class="text-lg" />
</div>
</div>
</template>
<script setup lang="ts">
import {
useElementSize,
useRafFn,
useElementHover,
useDebounceFn,
useTimeoutFn,
} from "@vueuse/core";
import { useSettingsStore } from "@stores/modules/setting.store";
type ThemeType =
| "theme"
| "primary"
| "secondary"
| "error"
| "info"
| "success"
| "warning"
| "danger";
/**
* 文本滚动组件属性接口
*/
export interface TextScrollProps {
/** 滚动文本内容 */
text?: string;
/** 主题类型 */
type?: ThemeType;
/** 滚动方向 */
direction?: "left" | "right" | "up" | "down";
/** 滚动速度,单位:像素/秒 */
speed?: number;
/** 容器宽度 */
width?: string;
/** 容器高度 */
height?: string;
/** 鼠标悬停时是否暂停滚动 */
pauseOnHover?: boolean;
/** 是否显示关闭按钮 */
showClose?: boolean;
/** 始终滚动(即使文字未溢出) */
alwaysScroll?: boolean;
}
const props = withDefaults(defineProps<TextScrollProps>(), {
text: "",
direction: "left",
speed: 80,
width: "100%",
height: "36px",
pauseOnHover: true,
type: "theme",
showClose: false,
alwaysScroll: true,
});
const emit = defineEmits<{
close: [];
}>();
const handleClose = () => {
emit("close");
};
const settingStore = useSettingsStore();
const { isDark } = storeToRefs(settingStore);
const containerRef = ref<HTMLElement>();
const contentRef = ref<HTMLElement>();
const textRef = ref<HTMLElement>();
const isReady = ref(false);
const currentPosition = ref(0);
const textSize = ref(0);
const containerSize = ref(0);
const shouldClone = ref(false);
const isHorizontal = computed(() => props.direction === "left" || props.direction === "right");
const isReverse = computed(() => props.direction === "right" || props.direction === "down");
// 使用 VueUse 的 useElementSize 监听容器尺寸变化
const { width: containerWidth, height: containerHeight } = useElementSize(containerRef);
// 使用 VueUse 的 useElementHover 检测鼠标悬停
const isHovered = useElementHover(containerRef);
// 计算是否应该暂停动画
const isPaused = computed(() => {
// 如果未启用 alwaysScroll,且文字未超出容器,则暂停滚动
if (!props.alwaysScroll && textSize.value <= containerSize.value) {
return true;
}
return props.pauseOnHover && isHovered.value;
});
// 主题样式映射
const themeClasses = computed(() => {
const themeMap: Record<ThemeType, string> = {
theme: "text-theme/90 !border-theme/50",
primary: "text-primary/90 !border-primary/50",
secondary: "text-secondary/90 !border-secondary/50",
error: "text-error/90 !border-error/50",
info: "text-info/90 !border-info/50",
success: "text-success/90 !border-success/50",
warning: "text-warning/90 !border-warning/50",
danger: "text-danger/90 !border-danger/50",
};
return themeMap[props.type] || themeMap.theme;
});
// 背景色
const bgColor = computed(
() =>
`color-mix(in oklch, var(--color-${props.type}) ${isDark.value ? "25" : "10"}%, var(--art-color))`
);
const containerStyle = computed(() => ({
width: props.width,
height: props.height,
backgroundColor: bgColor.value,
}));
const contentClass = computed(() => {
if (!isHorizontal.value) {
return "flex flex-col";
}
return "";
});
const contentStyle = computed(() => {
const transform = isHorizontal.value
? `translateX(${currentPosition.value}px)`
: `translateY(${currentPosition.value}px)`;
return {
transform,
willChange: "transform",
};
});
// 克隆元素的间距
const cloneSpacing = computed(() => {
const spacing = "2em";
return isHorizontal.value ? { marginLeft: spacing } : { marginTop: spacing };
});
const measureSizes = () => {
if (!containerRef.value || !textRef.value) return;
const text = textRef.value;
if (isHorizontal.value) {
containerSize.value = containerWidth.value;
textSize.value = text.offsetWidth;
} else {
containerSize.value = containerHeight.value;
textSize.value = text.offsetHeight;
}
const isOverflow = textSize.value > containerSize.value;
shouldClone.value = isOverflow;
// 居中显示
currentPosition.value = (containerSize.value - textSize.value) / 2;
// 测量完成后才显示内容
if (!isReady.value) {
isReady.value = true;
}
};
// 使用 VueUse 的 useDebounceFn 防抖测量
const debouncedMeasure = useDebounceFn(measureSizes, 150);
let lastTimestamp = 0;
// 使用 VueUse 的 useRafFn 替代手动 requestAnimationFrame
const { pause, resume } = useRafFn(
({ timestamp }) => {
if (!lastTimestamp) lastTimestamp = timestamp;
if (!isPaused.value) {
const delta = (timestamp - lastTimestamp) / 1000;
const distance = props.speed * delta;
const spacing = textSize.value * 0.1;
currentPosition.value += isReverse.value ? distance : -distance;
// 循环边界检测
if (isReverse.value) {
if (currentPosition.value > containerSize.value) {
currentPosition.value = -(textSize.value + spacing);
}
} else {
if (currentPosition.value < -(textSize.value + spacing)) {
currentPosition.value = containerSize.value;
}
}
}
lastTimestamp = timestamp;
},
{ immediate: false }
);
const handleContentClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "A") {
e.stopPropagation();
}
};
// 监听容器尺寸变化
watch([containerWidth, containerHeight], () => {
debouncedMeasure();
});
// 监听属性变化
watch(
() => [props.direction, props.speed, props.text],
() => {
measureSizes();
lastTimestamp = 0;
}
);
// 使用 VueUse 的 useTimeoutFn 替代 setTimeout
const { start: startMeasure } = useTimeoutFn(() => {
measureSizes();
// 测量完成后立即开始动画
resume();
}, 100);
onMounted(() => {
startMeasure();
});
onBeforeUnmount(() => {
pause();
});
</script>
<!-- <template>
<div class="page-content space-y-5">
<ArtTextScroll
text="Art Design Pro 是一款兼具设计美学与高效开发的后台系统 <a target='_blank' href='https://www.artd.pro/docs/'>点击我 </a>访问官方文档"
showClose
/>
<ArtTextScroll type="success" text="这是一条成功类型的滚动公告" />
<ArtTextScroll type="warning" text="这是一条警告类型的滚动公告" />
<ArtTextScroll type="danger" text="这是一条危险类型的滚动公告" />
<ArtTextScroll type="info" text="这是一条信息类型的滚动公告" />
<ArtTextScroll text="这是一条可关闭的滚动公告" @close="handleClose" />
<ArtTextScroll
type="warning"
text="这是一条速度较慢、向右滚动的公告"
:speed="30"
direction="right"
/>
<ArtTextScroll
text="这是一条文字溢出才会滚动的公告,当文本内容超出容器宽度时才会开始滚动显示,否则保持静止状态"
@close="handleClose"
:alwaysScroll="false"
/>
<ArtTextScroll type="danger" direction="up" :speed="30" text="这是一条向上滚动的公告" />
<ArtTextScroll type="info" direction="down" :speed="30" text="这是一条向下滚动的公告" />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "WidgetsTextScroll" });
const handleClose = () => {
console.log("文本滚动组件已关闭");
ElMessage.info("已关闭");
};
</script> -->
@@ -0,0 +1,97 @@
<!-- 一个让 SVG 图片跟随主题的组件,只对特定 svg 图片生效,不建议开发者使用 -->
<!-- 图片地址 https://iconpark.oceanengine.com/illustrations/13 -->
<template>
<div class="theme-svg" :style="sizeStyle">
<div v-if="src" class="svg-container" v-html="svgContent"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from "vue";
interface Props {
size?: string | number;
themeColor?: string;
src?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: 500,
themeColor: "var(--el-color-primary)",
});
const svgContent = ref("");
// 计算样式
const sizeStyle = computed(() => {
const sizeValue = typeof props.size === "number" ? `${props.size}px` : props.size;
return {
width: sizeValue,
height: sizeValue,
};
});
// 颜色映射配置
const COLOR_MAPPINGS = {
"#C7DEFF": "var(--el-color-primary-light-6)",
"#071F4D": "var(--el-color-primary-dark-2)",
"#00E4E5": "var(--el-color-primary-light-1)",
"#006EFF": "var(--el-color-primary)",
"#fff": "var(--default-box-color)",
"#ffffff": "var(--default-box-color)",
"#DEEBFC": "var(--el-color-primary-light-7)",
} as const;
// 将主题色应用到 SVG 内容
const applyThemeToSvg = (content: string): string => {
return Object.entries(COLOR_MAPPINGS).reduce((processedContent, [originalColor, themeColor]) => {
const fillRegex = new RegExp(`fill="${originalColor}"`, "gi");
const strokeRegex = new RegExp(`stroke="${originalColor}"`, "gi");
return processedContent
.replace(fillRegex, `fill="${themeColor}"`)
.replace(strokeRegex, `stroke="${themeColor}"`);
}, content);
};
// 加载 SVG 文件内容
const loadSvgContent = async () => {
if (!props.src) {
svgContent.value = "";
return;
}
try {
const response = await fetch(props.src);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const content = await response.text();
svgContent.value = applyThemeToSvg(content);
} catch (error) {
console.error("Failed to load SVG:", error);
svgContent.value = "";
}
};
watchEffect(() => {
loadSvgContent();
});
</script>
<style lang="scss" scoped>
.theme-svg {
display: inline-block;
.svg-container {
width: 100%;
height: 100%;
:deep(svg) {
width: 100%;
height: 100%;
}
}
}
</style>
@@ -0,0 +1,55 @@
<template>
<div class="page-content !border-0 !bg-transparent min-h-screen flex-cc">
<div class="flex-cc max-md:!block max-md:text-center">
<ThemeSvg :src="data.imgUrl" size="100%" class="!w-100" />
<div class="ml-15 w-75 max-md:mx-auto max-md:mt-10 max-md:w-full max-md:text-center">
<p class="text-xl leading-7 text-g-600 max-md:text-lg">{{ data.desc }}</p>
<ElButton type="primary" size="large" @click="backHome" v-ripple class="mt-5">
{{ data.btnText }}
</ElButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useCommon } from "@/hooks/core/useCommon";
import { useUserStore } from "@stores/modules/user.store";
const router = useRouter();
const userStore = useUserStore();
interface ExceptionData {
/** 标题 */
title: string;
/** 描述 */
desc: string;
/** 按钮文本 */
btnText: string;
/** 图片地址 */
imgUrl: string;
}
withDefaults(
defineProps<{
data: ExceptionData;
}>(),
{}
);
const { homePath } = useCommon();
const backHome = () => {
const targetHomePath = homePath.value || "/";
if (!userStore.isLogin) {
router.push({
name: "Login",
query: { redirect: targetHomePath },
});
return;
}
router.push(targetHomePath);
};
</script>
@@ -0,0 +1,332 @@
<!-- 授权页顶栏:左上 Logo / 标题 / 版本(固定),右上操作(固定);切换布局时仅下方主体变化 -->
<template>
<header
class="auth-top-bar pointer-events-none fixed left-0 right-0 top-0 z-[100] flex items-center justify-between gap-3 bg-transparent px-5 py-[1.125rem] md:gap-4 md:px-10"
>
<div class="pointer-events-auto flex min-w-0 flex-1 items-center gap-3">
<ArtLogo class="icon shrink-0" size="46" :src="webLogoSrc" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h1 class="auth-top-bar__site-title">{{ siteTitle }}</h1>
<div class="logo-version-badge shrink-0" :title="displayVersion">
<span class="logo-version-pill">{{ displayVersion }}</span>
</div>
</div>
</div>
</div>
<div
class="auth-top-bar-actions-panel pointer-events-auto flex shrink-0 flex-cc gap-1.5 px-2 py-1.5 max-sm:mr-1"
>
<div class="color-picker-expandable relative flex-c max-sm:!hidden">
<div
class="color-dots absolute right-0 rounded-full flex-c gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0"
>
<div
v-for="(_color, index) in mainColors"
:key="_color"
class="color-dot relative size-5 c-p flex-cc rounded-full opacity-0"
:class="{ active: _color === systemThemeColor }"
:style="{ background: _color, '--index': index }"
@click="changeThemeColor(_color)"
>
<ArtSvgIcon
v-if="_color === systemThemeColor"
icon="ri:check-fill"
class="text-white"
/>
</div>
</div>
<div
class="btn palette-btn auth-top-bar__action relative z-[2] h-8 w-8 c-p flex-cc tad-300"
>
<ArtSvgIcon icon="ri:palette-line" class="text-xl transition-colors duration-300" />
</div>
</div>
<ElDropdown
v-if="panelAlign != null"
@command="onPanelAlign"
popper-class="langDropDownStyle"
>
<div
class="btn layout-align-btn auth-top-bar__action h-8 w-8 c-p flex-cc tad-300"
:title="$t('login.panelAlign.label')"
>
<ArtSvgIcon
:icon="panelAlignTriggerIcon"
class="text-xl text-g-800 transition-colors duration-300"
/>
</div>
<template #dropdown>
<ElDropdownMenu>
<div v-for="opt in layoutAlignOptions" :key="opt.value" class="lang-btn-item">
<ElDropdownItem
:command="opt.value"
:class="{ 'is-selected': panelAlign === opt.value }"
>
<ArtSvgIcon :icon="opt.icon" class="mr-2 text-base" />
<span class="menu-txt">{{ $t(opt.labelKey) }}</span>
<ArtSvgIcon
icon="ri:check-fill"
class="text-base"
v-if="panelAlign === opt.value"
/>
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<ElDropdown
v-if="shouldShowLanguage"
@command="changeLanguage"
popper-class="langDropDownStyle"
>
<div class="btn language-btn auth-top-bar__action h-8 w-8 c-p flex-cc tad-300">
<ArtSvgIcon
icon="ri:translate-2"
class="text-[19px] text-g-800 transition-colors duration-300"
/>
</div>
<template #dropdown>
<ElDropdownMenu>
<div v-for="lang in languageOptions" :key="lang.value" class="lang-btn-item">
<ElDropdownItem
:command="lang.value"
:class="{ 'is-selected': locale === lang.value }"
>
<span class="menu-txt">{{ lang.label }}</span>
<ArtSvgIcon icon="ri:check-fill" class="text-base" v-if="locale === lang.value" />
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<div
v-if="shouldShowThemeToggle"
class="btn theme-btn auth-top-bar__action h-8 w-8 c-p flex-cc tad-300"
@click="themeAnimation"
>
<ArtSvgIcon
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
class="text-xl text-g-800 transition-colors duration-300"
/>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useUserStore } from "@stores/modules/user.store";
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
import { themeAnimation } from "@utils/ui";
import { languageOptions } from "@/locales";
import { LanguageEnum } from "@/enums/appEnum";
import AppConfig from "@/config";
import { useConfigStore } from "@stores/modules/config.store";
import type { LoginPanelAlign } from "@views/module_system/auth/composables/useLoginPanelAlign";
defineOptions({ name: "AuthTopBar" });
const DEFAULT_APP_VERSION = "2.0.0";
const props = defineProps<{
/** 登录区表单水平对齐;未传入时不展示布局切换 */
panelAlign?: LoginPanelAlign | null;
}>();
const emit = defineEmits<{
"update:panelAlign": [value: LoginPanelAlign];
}>();
const layoutAlignOptions: {
value: LoginPanelAlign;
icon: string;
labelKey: string;
}[] = [
{ value: "left", icon: "ri:layout-left-2-line", labelKey: "login.panelAlign.left" },
{ value: "center", icon: "ri:layout-column-line", labelKey: "login.panelAlign.center" },
{ value: "right", icon: "ri:layout-right-2-line", labelKey: "login.panelAlign.right" },
];
/** 与当前选中项同一套 icon,避免触发器与菜单不一致 */
const panelAlignTriggerIcon = computed(() => {
const opt = layoutAlignOptions.find((o) => o.value === props.panelAlign);
return opt?.icon ?? "ri:layout-column-line";
});
function onPanelAlign(cmd: string) {
if (cmd === "left" || cmd === "center" || cmd === "right") {
emit("update:panelAlign", cmd);
}
}
const configStore = useConfigStore();
const settingStore = useSettingsStore();
const userStore = useUserStore();
const { isDark, systemThemeColor } = storeToRefs(settingStore);
const { shouldShowThemeToggle, shouldShowLanguage } = useHeaderBar();
const { locale } = useI18n();
const mainColors = AppConfig.systemMainColor;
/** 与 Element 主题主色同步,供调色盘图标与展开态使用 */
const themeColorForCss = computed(() => systemThemeColor.value);
const webLogoSrc = computed(
() => configStore.configData.sys_web_logo?.config_value?.trim() || undefined
);
const siteTitle = computed(
() => configStore.configData.sys_web_title?.config_value?.trim() || AppConfig.systemInfo.name
);
const displayVersion = computed(() => {
const raw = configStore.configData.sys_web_version?.config_value?.trim();
const ver = raw || DEFAULT_APP_VERSION;
return ver.startsWith("v") || ver.startsWith("V") ? ver : `v${ver}`;
});
const changeLanguage = (lang: LanguageEnum) => {
if (locale.value === lang) return;
locale.value = lang;
userStore.setLanguage(lang);
};
const changeThemeColor = (color: string) => {
if (systemThemeColor.value === color) return;
settingStore.setElementTheme(color);
settingStore.reload();
};
</script>
<style scoped>
.auth-top-bar__site-title {
max-width: min(52vw, 28rem);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: clamp(1rem, 2.2vw, 1.25rem);
font-weight: 600;
line-height: 1.35;
color: var(--el-text-color-primary);
letter-spacing: -0.02em;
white-space: nowrap;
}
.logo-version-pill {
display: inline-block;
padding: 0.28rem 0.6rem;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.15;
color: var(--el-color-primary);
letter-spacing: 0.02em;
background: color-mix(in srgb, var(--el-color-primary) 11%, transparent);
border: 1px solid color-mix(in srgb, var(--el-color-primary) 28%, transparent);
border-radius: 999px;
}
/* 右上角操作的整体衬底 */
.auth-top-bar-actions-panel {
background-color: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
/* 胶囊形:左右两端为半圆弧 */
border-radius: 9999px;
box-shadow: 0 2px 12px rgb(0 0 0 / 6%);
backdrop-filter: blur(10px);
}
.dark .auth-top-bar-actions-panel {
background-color: rgb(255 255 255 / 8%);
border-color: rgb(255 255 255 / 12%);
box-shadow: 0 4px 20px rgb(0 0 0 / 35%);
}
/* 右上角三个操作按钮:悬浮抬升 + 浅底 + 图标随主色 */
.auth-top-bar__action {
border-radius: 10px;
transition:
background-color 0.22s ease,
transform 0.22s ease,
box-shadow 0.22s ease;
}
.auth-top-bar__action:hover {
background-color: var(--el-fill-color-light);
box-shadow: 0 4px 14px rgb(0 0 0 / 8%);
transform: translateY(-2px);
}
.auth-top-bar__action:hover :deep(.art-svg-icon) {
color: var(--el-color-primary);
}
.auth-top-bar__action:active {
box-shadow: 0 2px 6px rgb(0 0 0 / 6%);
transform: translateY(0);
transition-duration: 0.12s;
}
.dark .auth-top-bar__action:hover {
background-color: rgb(255 255 255 / 10%);
box-shadow: 0 4px 18px rgb(0 0 0 / 45%);
}
.color-dots {
pointer-events: none;
box-shadow: 0 2px 12px var(--art-gray-300);
backdrop-filter: blur(10px);
transform: translateX(10px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.color-dot {
box-shadow: 0 2px 4px rgb(0 0 0 / 15%);
transform: translateX(20px) scale(0.8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: calc(var(--index) * 0.05s);
}
/* 仅展开调色条后,单颗色块悬浮:描边 + 略放大 */
.color-picker-expandable:hover .color-dot:hover {
z-index: 1;
box-shadow:
0 4px 12px rgb(0 0 0 / 28%),
0 0 0 2px rgb(255 255 255 / 88%);
transform: translateX(0) scale(1.16);
}
.color-picker-expandable:hover .color-dots {
pointer-events: auto;
opacity: 1;
transform: translateX(0);
}
.color-picker-expandable:hover .color-dot {
opacity: 1;
transform: translateX(0) scale(1);
}
.dark .color-dots {
background-color: var(--art-gray-200);
box-shadow: none;
}
/* 调色盘:图标颜色与当前主题主色一致(含单独悬浮、整块调色区悬浮) */
.palette-btn :deep(.art-svg-icon) {
color: v-bind("themeColorForCss");
}
.auth-top-bar__action.palette-btn:hover :deep(.art-svg-icon) {
color: v-bind("themeColorForCss");
}
.color-picker-expandable:hover .palette-btn :deep(.art-svg-icon) {
color: v-bind("themeColorForCss");
}
</style>
@@ -0,0 +1,84 @@
<!-- 居中布局:与左侧栏一致的浅色底 + 中部淡化插画(不参与交互) -->
<template>
<div
class="login-center-backdrop"
:class="{ 'login-center-backdrop--viewport-fixed': viewportFixed }"
aria-hidden="true"
>
<div class="login-center-backdrop__bg" />
<div class="login-center-backdrop__hero-wrap">
<ThemeSvg :src="loginIcon" size="100%" class="login-center-backdrop__hero" />
</div>
</div>
</template>
<script setup lang="ts">
import loginIcon from "@imgs/svg/login_icon.svg";
defineOptions({ name: "LoginCenterBackdrop" });
withDefaults(
defineProps<{
/** 铺满视口并置于底层(与固定顶栏配合) */
viewportFixed?: boolean;
}>(),
{ viewportFixed: false }
);
</script>
<style scoped lang="scss">
.login-center-backdrop {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.login-center-backdrop--viewport-fixed {
position: fixed;
inset: 0;
z-index: 0;
}
.login-center-backdrop__bg {
position: absolute;
inset: 0;
background-color: color-mix(
in srgb,
var(--el-color-primary-light-9) 100%,
var(--default-box-color)
);
}
.login-center-backdrop__hero-wrap {
position: absolute;
top: 12%;
left: 50%;
width: min(420px, 52vw);
height: min(340px, 38vh);
opacity: 0.22;
transform: translateX(-50%);
}
.login-center-backdrop__hero {
width: 100%;
height: 100%;
}
html.dark .login-center-backdrop__bg {
background-color: color-mix(in srgb, var(--el-color-primary-light-9) 60%, #070707);
}
html.dark .login-center-backdrop__hero-wrap {
opacity: 0.14;
}
@media only screen and (width <= 1180px) {
.login-center-backdrop__hero-wrap {
top: 10%;
width: min(320px, 72vw);
height: min(240px, 28vh);
}
}
</style>
@@ -0,0 +1,679 @@
<!-- 登录、注册、忘记密码左侧背景 -->
<template>
<div class="login-left-view">
<div v-if="!hideTopBranding" class="logo">
<ArtLogo class="icon" size="46" :src="webLogoSrc" />
<div class="logo-title-wrap">
<div class="logo-title-inline">
<h1 class="title">{{ siteTitle }}</h1>
<div class="logo-version-badge" :title="displayVersion">
<span class="logo-version-pill">{{ displayVersion }}</span>
</div>
</div>
</div>
</div>
<div class="left-img">
<ThemeSvg :src="loginIcon" size="100%" />
</div>
<div class="text-wrap">
<h1>{{ $t("login.leftView.title") }}</h1>
<p>{{ $t("login.leftView.subTitle") }}</p>
</div>
<!-- 几何装饰元素 -->
<div class="geometric-decorations">
<!-- 基础几何形状 -->
<div class="geo-element circle-outline animate-fade-in-up" style="animation-delay: 0s"></div>
<div
class="geo-element square-rotated animate-fade-in-left"
style="animation-delay: 0s"
></div>
<div class="geo-element circle-small animate-fade-in-up" style="animation-delay: 0.3s"></div>
<div
class="geo-element square-bottom-right animate-fade-in-right"
style="animation-delay: 0s"
></div>
<!-- 背景泡泡 -->
<div class="geo-element bg-bubble animate-scale-in" style="animation-delay: 0.5"></div>
<!-- 太阳/月亮 -->
<div
class="geo-element circle-top-right animate-fade-in-down"
style="animation-delay: 0.5"
@click="themeAnimation"
></div>
<!-- 装饰点 -->
<div class="geo-element dot dot-top-left animate-bounce-in" style="animation-delay: 0s"></div>
<div
class="geo-element dot dot-top-right animate-bounce-in"
style="animation-delay: 0s"
></div>
<div
class="geo-element dot dot-center-right animate-bounce-in"
style="animation-delay: 0s"
></div>
<!-- 叠加方块组 -->
<div class="squares-group">
<i
class="geo-element square square-blue animate-fade-in-left-rotated-blue"
style="animation-delay: 0.2s"
></i>
<i
class="geo-element square square-pink animate-fade-in-left-rotated-pink"
style="animation-delay: 0.4s"
></i>
<i
class="geo-element square square-purple animate-fade-in-left-no-rotation"
style="animation-delay: 0.6s"
></i>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from "@/config";
import loginIcon from "@imgs/svg/login_icon.svg";
import { useConfigStore } from "@stores/modules/config.store";
import { themeAnimation } from "@utils/ui";
defineProps<{
hideContent?: boolean;
/** 顶栏已展示 Logo/标题/版本时隐藏左侧重复顶栏 */
hideTopBranding?: boolean;
}>();
const configStore = useConfigStore();
/** 接口 sys_web_logo,空则 ArtLogo 内置默认图 */
const webLogoSrc = computed(
() => configStore.configData.sys_web_logo?.config_value?.trim() || undefined
);
const siteTitle = computed(
() => configStore.configData.sys_web_title?.config_value?.trim() || AppConfig.systemInfo.name
);
const DEFAULT_APP_VERSION = "2.0.0";
const displayVersion = computed(() => {
const raw = configStore.configData.sys_web_version?.config_value?.trim();
const ver = raw || DEFAULT_APP_VERSION;
return ver.startsWith("v") || ver.startsWith("V") ? ver : `v${ver}`;
});
</script>
<style lang="scss" scoped>
// 颜色变量定义
$primary-light-7: var(--el-color-primary-light-7);
$primary-light-8: var(--el-color-primary-light-8);
$primary-light-9: var(--el-color-primary-light-9);
$primary-base: var(--el-color-primary);
$main-bg: var(--default-box-color);
// 混合颜色函数
$bg-mix-light-9: color-mix(in srgb, $primary-light-9 100%, $main-bg);
$bg-mix-light-8: color-mix(in srgb, $primary-light-8 80%, $main-bg);
$bg-mix-light-7: color-mix(in srgb, $primary-light-7 80%, $main-bg);
.login-left-view {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 15px;
overflow: hidden;
background-color: $bg-mix-light-9;
.logo {
position: relative;
z-index: 100;
display: flex;
align-items: center;
.logo-title-wrap {
flex: 1;
min-width: 0;
margin-left: 10px;
}
/** 标题与版本:垂直居中对齐(上下在同一水平中线),长标题可自然换行 */
.logo-title-inline {
display: flex;
flex-flow: row wrap;
gap: 0.45rem;
align-items: center;
min-width: 0;
max-width: 100%;
}
.title {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
margin: 0;
font-size: clamp(1.05rem, 1.6vw, 1.25rem);
font-weight: 600;
line-height: 1.35;
color: var(--el-text-color-primary);
letter-spacing: -0.02em;
}
.logo-version-badge {
flex-shrink: 0;
}
.logo-version-pill {
display: inline-block;
padding: 0.28rem 0.6rem;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.15;
color: var(--el-color-primary);
letter-spacing: 0.02em;
background: color-mix(in srgb, var(--el-color-primary) 11%, transparent);
border: 1px solid color-mix(in srgb, var(--el-color-primary) 28%, transparent);
border-radius: 999px;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
transition:
border-color 0.2s ease,
background 0.2s ease;
}
}
.left-img {
position: absolute;
inset: 0 0 10.5%;
z-index: 10;
width: 40%;
margin: auto;
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
.text-wrap {
position: absolute;
bottom: 80px;
width: 100%;
text-align: center;
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
h1 {
font-size: 24px;
font-weight: 400;
color: var(--art-gray-900) !important;
}
p {
margin-top: 10px;
font-size: 14px;
color: var(--art-gray-600) !important;
}
}
.geometric-decorations {
.geo-element {
position: absolute;
opacity: 0;
animation-fill-mode: forwards;
animation-duration: 0.8s;
animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
// 动画 mixin
@mixin fadeAnimation($direction: "", $rotation: 0deg) {
from {
opacity: 0;
@if $direction == "up" {
transform: translateY(30px) rotate($rotation);
}
@else if $direction == "down" {
transform: translateY(-30px) rotate($rotation);
}
@else if $direction == "left" {
transform: translateX(-30px) rotate($rotation);
}
@else if $direction == "right" {
transform: translateX(30px) rotate($rotation);
}
}
to {
opacity: 1;
@if $direction == "up" or $direction == "down" {
transform: translateY(0) rotate($rotation);
}
@else {
transform: translateX(0) rotate($rotation);
}
}
}
// 动画定义
@keyframes fadeInUp {
@include fadeAnimation("up");
}
@keyframes fadeInDown {
@include fadeAnimation("down");
}
@keyframes fadeInLeft {
@include fadeAnimation("left");
}
@keyframes fadeInLeftRotated {
@include fadeAnimation("left", -25deg);
}
@keyframes fadeInRight {
@include fadeAnimation("right");
}
@keyframes fadeInRightRotated {
@include fadeAnimation("right", 45deg);
}
@keyframes fadeInLeftRotatedBlue {
@include fadeAnimation("left", -10deg);
}
@keyframes fadeInLeftRotatedPink {
@include fadeAnimation("left", 10deg);
}
@keyframes fadeInLeftNoRotation {
@include fadeAnimation("left");
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
opacity: 1;
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes lineGrow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
// 动画类
.animate-fade-in-up {
animation-name: fadeInUp;
}
.animate-fade-in-down {
animation-name: fadeInDown;
}
.animate-fade-in-left {
animation-name: fadeInLeft;
}
.animate-fade-in-right {
animation-name: fadeInRight;
}
.animate-scale-in {
animation-name: scaleIn;
animation-duration: 1.2s;
}
.animate-bounce-in {
animation-name: bounceIn;
animation-duration: 0.6s;
}
.animate-fade-in-left-rotated-blue {
animation-name: fadeInLeftRotatedBlue;
}
.animate-fade-in-left-rotated-pink {
animation-name: fadeInLeftRotatedPink;
}
.animate-fade-in-left-no-rotation {
animation-name: fadeInLeftNoRotation;
}
// 基础几何形状
.circle-outline {
top: 10%;
left: 25%;
width: 42px;
height: 42px;
border: 2px solid $primary-light-8;
border-radius: 50%;
}
.square-rotated {
top: 50%;
left: 16%;
width: 60px;
height: 60px;
background-color: $bg-mix-light-8;
&.animate-fade-in-left {
animation-name: fadeInLeftRotated;
}
}
.circle-small {
bottom: 26%;
left: 30%;
width: 18px;
height: 18px;
background-color: $primary-light-8;
border-radius: 50%;
}
// 太阳/月亮效果
.circle-top-right {
top: 3%;
right: 3%;
z-index: 100;
width: 50px;
height: 50px;
cursor: pointer;
background: $bg-mix-light-7;
border-radius: 50%;
transition: all 0.3s;
&::after {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
content: "";
background: linear-gradient(to right, #fcbb04, #fffc00);
border-radius: 50%;
opacity: 0;
transform: translate(-50%, -50%);
transition: all 0.5s;
}
&:hover {
box-shadow: 0 0 36px #fffc00;
&::after {
opacity: 1;
}
}
}
.square-bottom-right {
right: 10%;
bottom: 10%;
width: 50px;
height: 50px;
background-color: $primary-light-8;
&.animate-fade-in-right {
animation-name: fadeInRightRotated;
}
}
// 背景泡泡
.bg-bubble {
top: -120px;
right: -120px;
width: 360px;
height: 360px;
background-color: $bg-mix-light-8;
border-radius: 50%;
}
// 装饰点
.dot {
width: 14px;
height: 14px;
background-color: $primary-light-7;
border-radius: 50%;
&.dot-top-left {
top: 140px;
left: 100px;
}
&.dot-top-right {
top: 140px;
right: 120px;
}
&.dot-center-right {
top: 46%;
right: 22%;
background-color: $primary-light-8;
}
}
// 叠加方块组
.squares-group {
position: absolute;
bottom: 18px;
left: 20px;
width: 140px;
height: 140px;
pointer-events: none;
.square {
position: absolute;
display: block;
border-radius: 8px;
box-shadow: 0 8px 24px rgb(64 87 167 / 12%);
&.square-blue {
top: 12px;
left: 30px;
z-index: 2;
width: 50px;
height: 50px;
background-color: rgb(from $primary-base r g b / 30%);
}
&.square-pink {
top: 30px;
left: 48px;
z-index: 1;
width: 70px;
height: 70px;
background-color: rgb(from $primary-base r g b / 15%);
}
&.square-purple {
top: 66px;
left: 86px;
z-index: 3;
width: 32px;
height: 32px;
background-color: rgb(from $primary-base r g b / 45%);
}
}
// 装饰线条
&::after {
position: absolute;
top: 86px;
left: 72px;
width: 80px;
height: 1px;
content: "";
background: linear-gradient(90deg, var(--el-color-primary-light-6), transparent);
opacity: 0;
transform: rotate(50deg);
animation: lineGrow 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
animation-delay: 1.2s;
}
}
}
@media only screen and (width <= 1600px) {
.text-wrap {
bottom: 40px;
}
}
@media only screen and (width <= 1180px) {
width: auto;
height: auto;
padding: 0;
// 隐藏背景和其他内容,只保留 logo
background: transparent;
.left-img,
.text-wrap,
.geometric-decorations {
display: none;
}
.logo {
display: none;
}
}
}
// 暗色主题
.dark .login-left-view {
background-color: color-mix(in srgb, $primary-light-9 60%, #070707);
@media only screen and (width <= 1180px) {
background: transparent;
}
.geometric-decorations {
// 月亮效果
.circle-top-right {
background-color: $bg-mix-light-8;
box-shadow: 0 0 25px #333 inset;
rotate: -48deg;
transition: all 0.3s ease-in-out 0.1s;
&::before {
position: absolute;
top: 0;
left: 15px;
width: 50px;
height: 50px;
content: "";
background-color: $bg-mix-light-9;
border-radius: 50%;
transition: all 0.3s ease-in-out;
}
&:hover {
background-color: transparent;
box-shadow: 0 40px 25px #ddd inset;
&::before {
left: 18px;
}
&::after {
opacity: 0;
}
}
}
.bg-bubble {
background-color: $bg-mix-light-9;
}
// 其他元素颜色调整
.square-rotated {
background-color: $bg-mix-light-9;
}
.circle-small,
.dot {
background-color: $primary-light-8;
}
.square-bottom-right {
background-color: $primary-light-9;
}
.dot.dot-top-right {
background-color: $primary-light-8;
}
}
// 方块组暗色调整
.squares-group {
.square {
box-shadow: none;
&.square-blue {
background-color: rgb(from $primary-base r g b / 18%);
}
&.square-pink {
background-color: rgb(from $primary-base r g b / 10%);
}
&.square-purple {
background-color: rgb(from $primary-base r g b / 20%);
}
}
&::after {
background: linear-gradient(90deg, $primary-light-8, transparent);
}
}
}
</style>
@@ -0,0 +1,43 @@
<template>
<div class="page-content box-border !px-20 py-3.5 text-center max-md:!px-5" :class="type">
<ArtSvgIcon
class="icon size-22 p-2 mt-16 block rounded-full !text-white"
:icon="iconCode"
:class="type === 'success' ? 'bg-[#19BE6B]' : 'bg-[#ED4014]'"
/>
<h1 class="title mt-8 text-3xl font-medium !text-g-900 max-md:mt-2.5 max-md:text-2xl">
{{ title }}
</h1>
<p class="msg mt-5 text-base text-g-600">{{ message }}</p>
<div
class="res mt-7.5 rounded bg-g-200/80 dark:bg-g-300/40 px-7.5 py-5.5 text-left max-md:px-7.5 max-md:py-2.5 [&_p]:flex [&_p]:items-center [&_p]:py-2 [&_p]:text-sm [&_p]:text-[#808695] [&_p_i]:mr-1.5"
>
<slot name="content"></slot>
</div>
<div class="btn-group mt-12.5">
<slot name="buttons"></slot>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "ArtResultPage" });
interface ResultPageProps {
/** 成功/失败 */
type: "success" | "fail";
/** 标题 */
title: string;
/** 消息 */
message: string;
/** 图标 */
iconCode: string;
}
withDefaults(defineProps<ResultPageProps>(), {
type: "success",
title: "",
message: "",
iconCode: "",
});
</script>
@@ -0,0 +1,23 @@
<!-- 按钮组件 -->
<template>
<div
class="size-8.5 inline-flex flex-cc c-p text-g-600 dark:text-g-800 text-xl rounded tad-300 hover:bg-hover-color"
:class="{ 'rounded-full': circle }"
>
<ArtSvgIcon :icon="icon"></ArtSvgIcon>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: "ArtIconButton" });
interface Props {
/** 图标名称 */
icon: string;
/** 圆角按钮 */
circle?: boolean;
}
withDefaults(defineProps<Props>(), {});
</script>
@@ -1,7 +1,7 @@
<!-- 日期选择器 -->
<template>
<div class="custom-date-picker">
<el-date-picker
<ElDatePicker
:model-value="modelValue"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
@@ -1,11 +1,14 @@
<!-- 全屏切换按钮 -->
<template>
<div @click="toggle">
<div :class="`i-svg:` + (isFullscreen ? 'fullscreen-exit' : 'fullscreen')" />
<ArtSvgIcon :icon="resolveIconForArtSvgIcon(isFullscreen ? 'fullscreen-exit' : 'fullscreen')" />
</div>
</template>
<script setup lang="ts">
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
const { isFullscreen, toggle } = useFullscreen();
</script>
+120 -66
View File
@@ -1,37 +1,72 @@
<!-- 引导页 -->
<!-- 新手引导:目标用函数延迟解析并带回退节点,避免找不到节点时浮层居中变成「纯弹窗」 -->
<template>
<el-tour v-model="open" :show-close="false" @change="handleChange">
<el-tour-step
v-for="(step, index) in steps"
:key="index"
:target="step.target"
:title="step.title"
:description="step.description"
<ElTour
v-model="open"
:show-close="false"
:mask="true"
:z-index="3100"
append-to="body"
@change="handleChange"
@finish="handleTourFinish"
@close="handleTourClose"
>
<ElTourStep
:target="targetMenu"
:title="t('common.menu')"
:description="t('common.menuDes')"
:placement="placementMenu"
:prev-button-props="{
children: t('common.prevLabel'),
onClick: handlePrevClick,
}"
:next-button-props="{
children: nextBtnName(index),
children: t('common.nextLabel'),
onClick: handleNextClick,
}"
/>
<ElTourStep
:target="targetToolbar"
:title="t('common.tool')"
:description="t('common.toolDes')"
placement="bottom"
:prev-button-props="{
children: t('common.prevLabel'),
onClick: handlePrevClick,
}"
:next-button-props="{
children: t('common.nextLabel'),
onClick: handleNextClick,
}"
/>
<ElTourStep
:target="targetTags"
:title="t('common.tagsView')"
:description="t('common.tagsViewDes')"
placement="bottom"
:prev-button-props="{
children: t('common.prevLabel'),
onClick: handlePrevClick,
}"
:next-button-props="{
children: lastStepNextLabel,
onClick: handleNextClick,
}"
:placement="step.placement"
/>
<template #indicators>
<el-button size="small" @click="handleSkip">{{ t("common.skipLabel") }}</el-button>
<ElButton size="small" @click="handleSkip">{{ t("common.skipLabel") }}</ElButton>
</template>
</el-tour>
</ElTour>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useSettingsStore } from "@/store";
import { computed, type PropType } from "vue";
import { useSettingsStore } from "@stores";
import { MenuTypeEnum } from "@/enums/appEnum";
const settingStore = useSettingsStore();
const { t } = useI18n();
const props = defineProps({
// 是否可见
modelValue: {
type: Boolean,
default: false,
@@ -49,83 +84,102 @@ const open = computed({
set: (val) => emit("update:modelValue", val),
});
interface TourStep {
target: string;
title: string;
description: string;
placement: "top" | "bottom" | "left" | "right";
function hasUsefulRect(el: HTMLElement): boolean {
const r = el.getBoundingClientRect();
return r.width > 4 && r.height > 4;
}
const layout = settingStore.layout;
const menuTarget = (): string => {
if (layout === "left") {
return ".layout__sidebar";
} else if (layout === "top") {
return ".layout__header-left";
} else {
return ".layout__header-menu";
function firstPresent(selectors: string[]): HTMLElement | null {
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el instanceof HTMLElement && hasUsefulRect(el)) {
return el;
}
}
};
return null;
}
// 内置引导步骤数据
const steps: TourStep[] = [
{
target: menuTarget(),
title: t("common.menu"),
description: t("common.menuDes"),
placement: layout === "left" ? "right" : "bottom",
},
{
target: ".navbar-actions",
title: t("common.tool"),
description: t("common.toolDes"),
placement: "bottom",
},
{
target: ".tags-container",
title: t("common.tagsView"),
description: t("common.tagsViewDes"),
placement: "bottom",
},
];
// 当前步数
const currentStep = ref(0);
// 动态设置下一步按钮名称
const nextBtnName = computed(() => (index: number) => {
if (index === steps.length - 1) {
return t("common.doneLabel");
/** 菜单高亮:按菜单类型优先,其次回退到侧栏 / 顶栏菜单 / 主内容区 */
function resolveMenuEl(): HTMLElement | null {
const mt = settingStore.menuType as MenuTypeEnum;
let order: string[] = [];
switch (mt) {
case MenuTypeEnum.TOP:
order = ["#app-menu-top", "#app-menu-top-left", "#app-sidebar", "#app-main"];
break;
case MenuTypeEnum.TOP_LEFT:
order = ["#app-menu-top-left", "#app-menu-top", "#app-sidebar", "#app-main"];
break;
case MenuTypeEnum.DUAL_MENU:
case MenuTypeEnum.LEFT:
default:
order = ["#app-sidebar", "#app-menu-top", "#app-menu-top-left", "#app-main"];
break;
}
return t("common.nextLabel");
return firstPresent(order) ?? (document.querySelector("#app-main") as HTMLElement | null);
}
function resolveToolbarEl(): HTMLElement | null {
return (
firstPresent(["#app-header-toolbar", "#app-header"]) ??
(document.querySelector("#app-main") as HTMLElement | null)
);
}
/** 标签栏关闭时可落到内容区,保证仍有镂空指引 */
function resolveTagsEl(): HTMLElement | null {
return (
firstPresent([".worktab-tags-shell", "#app-header", "#app-content"]) ??
(document.querySelector("#app-main") as HTMLElement | null)
);
}
/** Element Plus Tour:target 支持函数,在打开时解析 DOM,避免初始渲染阶段节点未就绪 */
function targetMenu(): HTMLElement | null {
return resolveMenuEl();
}
function targetToolbar(): HTMLElement | null {
return resolveToolbarEl();
}
function targetTags(): HTMLElement | null {
return resolveTagsEl();
}
const placementMenu = computed((): "top" | "bottom" | "left" | "right" => {
const mt = settingStore.menuType as MenuTypeEnum;
return mt === MenuTypeEnum.LEFT || mt === MenuTypeEnum.DUAL_MENU ? "right" : "bottom";
});
// 步数切换时触发
const lastStepNextLabel = computed(() => t("common.doneLabel"));
function handleChange(step: number) {
currentStep.value = step;
emit("change", step);
}
// 点击跳过按钮时触发
function handleSkip() {
open.value = false;
emit("skip");
}
// 点击上一步按钮时触发
function handleTourFinish() {
open.value = false;
emit("skip");
}
function handleTourClose() {
emit("skip");
}
function handlePrevClick() {
emit("prev");
}
// 点击下一步按钮时触发
function handleNextClick() {
emit("next");
}
</script>
<style scoped>
/* 可根据需要添加自定义样式 */
.el-tour__content .el-tour-indicators {
display: flex;
justify-content: flex-end;
@@ -1,11 +1,17 @@
<!-- 折叠按钮 -->
<template>
<div class="hamburger-wrapper" @click="toggleClick">
<div :class="['i-svg:collapse', { hamburger: true, 'is-active': isActive }]" />
<ArtSvgIcon
:icon="resolveIconForArtSvgIcon('collapse')"
:class="[{ hamburger: true, 'is-active': isActive }]"
/>
</div>
</template>
<script setup lang="ts">
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
defineProps({
isActive: { type: Boolean, required: true },
});
@@ -1,50 +1,51 @@
<!-- 图标选择器 -->
<template>
<div ref="iconSelectRef" :style="{ width: props.width }">
<el-popover :visible="popoverVisible" :width="props.width" placement="bottom-end">
<ElPopover :visible="popoverVisible" :width="props.width" placement="bottom-end">
<template #reference>
<div @click="popoverVisible = !popoverVisible">
<slot>
<el-input v-model="selectedIcon" readonly placeholder="点击选择图标" class="reference">
<ElInput v-model="selectedIcon" readonly placeholder="点击选择图标" class="reference">
<template #prepend>
<!-- 根据图标类型展示 -->
<el-icon v-if="isElementIcon">
<component :is="selectedIcon.replace('el-icon-', '')" />
</el-icon>
<template v-else>
<div :class="`i-svg:${selectedIcon}`" />
</template>
<!-- EP(含 el-icon- 前缀或与 icons-vue 同名的裸值) / Iconify / 自定义 SVG 文件名 -->
<ElIcon v-if="elementIconComp">
<component :is="elementIconComp" />
</ElIcon>
<ArtSvgIcon
v-else-if="selectedIcon"
:icon="resolveIconForArtSvgIcon(selectedIcon)"
/>
</template>
<template #suffix>
<!-- 清空按钮 -->
<el-icon
<ElIcon
v-if="selectedIcon"
style="margin-right: 8px"
@click.stop="clearSelectedIcon"
>
<CircleClose />
</el-icon>
</ElIcon>
<el-icon
<ElIcon
:style="{
transform: popoverVisible ? 'rotate(180deg)' : 'rotate(0)',
transition: 'transform .5s',
}"
>
<ArrowDown @click.stop="togglePopover" />
</el-icon>
</ElIcon>
</template>
</el-input>
</ElInput>
</slot>
</div>
</template>
<!-- 图标选择弹窗 -->
<div ref="popoverContentRef">
<el-input v-model="filterText" placeholder="搜索图标" clearable @input="filterIcons" />
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="SVG 图标" name="svg">
<el-scrollbar height="300px">
<ElInput v-model="filterText" placeholder="搜索图标" clearable @input="filterIcons" />
<ElTabs v-model="activeTab" @tab-click="handleTabClick">
<ElTabPane label="SVG 图标" name="svg">
<ElScrollbar height="300px">
<ul class="icon-grid">
<li
v-for="icon in filteredSvgIcons"
@@ -52,15 +53,15 @@
class="icon-grid-item"
@click="selectIcon(icon)"
>
<el-tooltip :content="icon" placement="bottom" effect="light">
<div :class="`i-svg:${icon}`" />
</el-tooltip>
<ElTooltip :content="icon" placement="bottom" effect="light">
<ArtSvgIcon :icon="resolveIconForArtSvgIcon(icon)" />
</ElTooltip>
</li>
</ul>
</el-scrollbar>
</el-tab-pane>
<el-tab-pane label="Element 图标" name="element">
<el-scrollbar height="300px">
</ElScrollbar>
</ElTabPane>
<ElTabPane label="Element 图标" name="element">
<ElScrollbar height="300px">
<ul class="icon-grid">
<li
v-for="icon in filteredElementIcons"
@@ -68,21 +69,25 @@
class="icon-grid-item"
@click="selectIcon(icon)"
>
<el-icon>
<ElIcon>
<component :is="icon" />
</el-icon>
</ElIcon>
</li>
</ul>
</el-scrollbar>
</el-tab-pane>
</el-tabs>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
</el-popover>
</ElPopover>
</div>
</template>
<script setup lang="ts">
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
import { listLocalIconBasenames } from "@utils/icons";
import { isIconifyStoredIcon, resolveElementPlusIconComponent } from "@utils/menuIcon";
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
const props = defineProps({
modelValue: {
@@ -113,16 +118,11 @@ const selectedIcon = defineModel("modelValue", {
const filterText = ref("");
const filteredSvgIcons = ref<string[]>([]);
const filteredElementIcons = ref<string[]>(elementIcons.value);
const isElementIcon = computed(() => {
return selectedIcon.value && selectedIcon.value.startsWith("el-icon");
});
const elementIconComp = computed(() => resolveElementPlusIconComponent(selectedIcon.value));
function loadIcons() {
const icons = import.meta.glob("../../assets/icons/*.svg");
for (const path in icons) {
const iconName = path.replace(/.*\/(.*)\.svg$/, "$1");
svgIcons.value.push(iconName);
}
svgIcons.value = listLocalIconBasenames();
filteredSvgIcons.value = svgIcons.value;
}
@@ -169,8 +169,12 @@ function clearSelectedIcon() {
onMounted(() => {
loadIcons();
if (selectedIcon.value) {
if (elementIcons.value.includes(selectedIcon.value.replace("el-icon-", ""))) {
const raw = selectedIcon.value.trim();
const epKey = raw.replace(/^el-icon-/i, "");
if (elementIcons.value.includes(epKey)) {
activeTab.value = "element";
} else if (isIconifyStoredIcon(raw)) {
activeTab.value = "svg";
} else {
activeTab.value = "svg";
}
@@ -1,7 +1,7 @@
<template>
<el-scrollbar>
<ElScrollbar>
<div class="flex-y-center gap-2">
<el-tag
<ElTag
v-for="tag in tags"
:key="tag"
closable
@@ -10,8 +10,8 @@
@close="handleClose(tag)"
>
{{ tag }}
</el-tag>
<el-input
</ElTag>
<ElInput
v-if="inputVisible"
ref="inputRef"
v-model.trim="inputValue"
@@ -19,11 +19,11 @@
@keyup.enter.stop.prevent="handleInputConfirm"
@blur.stop.prevent="handleInputConfirm"
/>
<el-button v-else v-bind="config.buttonAttrs" @click="showInput">
<ElButton v-else v-bind="config.buttonAttrs" @click="showInput">
{{ config.buttonAttrs.btnText ? config.buttonAttrs.btnText : "+ New Tag" }}
</el-button>
</ElButton>
</div>
</el-scrollbar>
</ElScrollbar>
</template>
<script setup lang="ts">
import type { InputInstance } from "element-plus";
@@ -1,5 +1,5 @@
<template>
<el-form
<ElForm
ref="formRef"
:model="crontabValueObj"
label-width="auto"
@@ -7,60 +7,60 @@
:inline="true"
class="interval-tab-form"
>
<el-form-item label="秒" prop="second" class="form-item">
<el-select v-model="crontabValueObj.second" placeholder="秒" clearable>
<el-option label="每秒" value="*">*</el-option>
<el-option
<ElFormItem label="秒" prop="second" class="form-item">
<ElSelect v-model="crontabValueObj.second" placeholder="秒" clearable>
<ElOption label="每秒" value="*">*</ElOption>
<ElOption
v-for="second in seconds"
:key="second"
:label="second"
:value="second.toString()"
>
{{ second }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="分" prop="min" class="form-item">
<el-select v-model="crontabValueObj.min" placeholder="分" clearable>
<el-option label="每分" value="*">*</el-option>
<el-option v-for="min in minutes" :key="min" :label="min" :value="min.toString()">
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="分" prop="min" class="form-item">
<ElSelect v-model="crontabValueObj.min" placeholder="分" clearable>
<ElOption label="每分" value="*">*</ElOption>
<ElOption v-for="min in minutes" :key="min" :label="min" :value="min.toString()">
{{ min }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="时" prop="hour" class="form-item">
<el-select v-model="crontabValueObj.hour" placeholder="时" clearable>
<el-option label="每时" value="*">*</el-option>
<el-option v-for="hour in hours" :key="hour" :label="hour" :value="hour.toString()">
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="时" prop="hour" class="form-item">
<ElSelect v-model="crontabValueObj.hour" placeholder="时" clearable>
<ElOption label="每时" value="*">*</ElOption>
<ElOption v-for="hour in hours" :key="hour" :label="hour" :value="hour.toString()">
{{ hour }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="天" prop="day" class="form-item">
<el-select v-model="crontabValueObj.day" placeholder="天" clearable>
<el-option label="每天" value="*">*</el-option>
<el-option v-for="day in days" :key="day" :label="day" :value="day">{{ day }}</el-option>
</el-select>
</el-form-item>
<el-form-item label="周" prop="week" class="form-item">
<el-select v-model="crontabValueObj.week" placeholder="周" clearable>
<el-option label="每周" value="*">*</el-option>
<el-option
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="天" prop="day" class="form-item">
<ElSelect v-model="crontabValueObj.day" placeholder="天" clearable>
<ElOption label="每天" value="*">*</ElOption>
<ElOption v-for="day in days" :key="day" :label="day" :value="day">{{ day }}</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="周" prop="week" class="form-item">
<ElSelect v-model="crontabValueObj.week" placeholder="周" clearable>
<ElOption label="每周" value="*">*</ElOption>
<ElOption
v-for="week in weekOptions"
:key="week.value"
:label="week.label"
:value="week.value"
>
{{ week.label }}
</el-option>
</el-select>
</el-form-item>
</ElOption>
</ElSelect>
</ElFormItem>
<div class="form-actions">
<el-button @click="emit('cancel')">取消</el-button>
<el-button type="primary" @click="handleConfirm">确认</el-button>
<ElButton @click="emit('cancel')">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确认</ElButton>
</div>
</el-form>
</ElForm>
</template>
<script lang="ts" setup>
@@ -185,13 +185,13 @@ defineExpose({ setCron });
}
// 响应式调整
@media (max-width: 768px) {
@media (width <= 768px) {
.form-item {
width: calc(33.33% - 8px);
}
}
@media (max-width: 480px) {
@media (width <= 480px) {
.form-item {
width: calc(50% - 8px);
}
@@ -1,25 +1,29 @@
<!-- 语言切换 -->
<template>
<el-dropdown trigger="click" @command="handleLanguageChange">
<div class="i-svg:language" :class="size" />
<ElDropdown trigger="click" @command="handleLanguageChange">
<div class="navbar-lang-trigger flex-cc">
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('language')" :class="size" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
<ElDropdownMenu>
<ElDropdownItem
v-for="item in langOptions"
:key="item.value"
:disabled="appStore.language === item.value"
:command="item.value"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</el-dropdown>
</ElDropdown>
</template>
<script setup lang="ts">
import { useAppStore } from "@/store/modules/app.store";
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
import { useAppStore } from "@stores/modules/app.store";
import { LanguageEnum } from "@/enums/settings/locale.enum";
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
defineProps({
size: {
@@ -0,0 +1,75 @@
<!-- 路由 / 侧栏菜单图标:与 IconSelect 存值规则一致 -->
<template>
<template v-if="!trimmedIcon" />
<ElIcon
v-else-if="elementComponent"
:class="iconClass"
:style="mergedStyle"
class="menu-route-icon"
>
<component :is="elementComponent" />
</ElIcon>
<ArtSvgIcon
v-else-if="isInvalidElementPrefix"
:icon="epFallback"
:color="color"
:class="iconClass"
:style="mergedStyle"
/>
<ArtSvgIcon
v-else
:icon="resolvedArtIcon"
:color="color"
:class="iconClass"
:style="mergedStyle"
/>
</template>
<script setup lang="ts">
import { computed } from "vue";
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
import {
elementMenuIconToEpIconify,
isElementPlusStoredIcon,
resolveElementPlusIconComponent,
} from "@utils/menuIcon";
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
defineOptions({ name: "MenuRouteIcon", inheritAttrs: false });
const props = defineProps<{
icon?: string;
color?: string;
/** 与 Vue `class` 一致(含条件 `false`) */
class?: string | string[] | Record<string, boolean> | null | undefined | false;
style?: Record<string, unknown> | string;
}>();
const trimmedIcon = computed(() => props.icon?.trim() ?? "");
const elementComponent = computed(() => resolveElementPlusIconComponent(trimmedIcon.value));
/** 仅带 `el-icon-` 却解析不出 EP 组件时走 ep 兜底,裸名无效则仍按自定义 SVG 处理 */
const isInvalidElementPrefix = computed(
() => !!trimmedIcon.value && isElementPlusStoredIcon(trimmedIcon.value) && !elementComponent.value
);
const epFallback = computed(() => elementMenuIconToEpIconify(trimmedIcon.value));
/** Iconify(含历史本地 SVG 文件名 → Remix `ri:`) */
const resolvedArtIcon = computed(() => resolveIconForArtSvgIcon(trimmedIcon.value));
const iconClass = computed(() =>
props.class === false || props.class == null ? undefined : props.class
);
const mergedStyle = computed(() => {
const base = typeof props.style === "object" && props.style !== null ? { ...props.style } : {};
if (props.color) {
(base as Record<string, string>).color = props.color;
}
return base as Record<string, string>;
});
</script>
@@ -1,14 +1,13 @@
<template>
<div @click="openSearchModal">
<!-- <div class="i-svg:search" /> -->
<div class="command-palette-trigger" role="button" tabindex="0" aria-label="打开搜索面板">
<div class="command-palette-trigger__left">
<div class="i-svg:search" />
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('search')" />
<span class="command-palette-trigger__text">搜索菜单</span>
</div>
<kbd class="command-palette-trigger__kbd">Ctrl K</kbd>
</div>
<el-dialog
<ElDialog
v-model="isModalVisible"
width="30%"
:append-to-body="true"
@@ -16,7 +15,7 @@
@close="closeSearchModal"
>
<template #header>
<el-input
<ElInput
ref="searchInputRef"
v-model="searchKeyword"
size="large"
@@ -29,9 +28,9 @@
@keydown.esc="closeSearchModal"
>
<template #prepend>
<el-button icon="Search" />
<ElButton icon="Search" />
</template>
</el-input>
</ElInput>
</template>
<div class="search-result">
@@ -40,15 +39,15 @@
<div class="search-history">
<div class="search-history__title">
搜索历史
<el-button
<ElButton
type="primary"
text
size="small"
class="search-history__clear"
@click="clearHistory"
>
<el-icon><Delete /></el-icon>
</el-button>
<ElIcon><Delete /></ElIcon>
</ElButton>
</div>
<ul class="search-history__list">
<li
@@ -58,11 +57,11 @@
@click="navigateToRoute(item)"
>
<div class="search-history__icon">
<el-icon><Clock /></el-icon>
<ElIcon><Clock /></ElIcon>
</div>
<span class="search-history__name">{{ item.title }}</span>
<div class="search-history__action">
<el-icon @click.stop="removeHistoryItem(index)"><Close /></el-icon>
<ElIcon @click.stop="removeHistoryItem(index)"><Close /></ElIcon>
</div>
</li>
</ul>
@@ -83,11 +82,8 @@
]"
@click="navigateToRoute(item)"
>
<el-icon v-if="item.icon && item.icon.startsWith('el-icon')">
<component :is="item.icon.replace('el-icon-', '')" />
</el-icon>
<div v-else-if="item.icon" :class="`i-svg:${item.icon}`" />
<div v-else class="i-svg:menu" />
<!-- 与 MenuRouteIcon / 旧版 MenuItemContent 一致:EP + 自定义 SVG + Iconify -->
<MenuRouteIcon :icon="item.icon || 'menu'" class="flex-shrink-0" />
<span class="ml-2">{{ item.title }}</span>
</li>
</ul>
@@ -111,10 +107,10 @@
<div class="arrow-box">
<div class="arrow-up-down">
<div class="key-btn">
<div class="i-svg:up" />
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('up')" />
</div>
<div class="key-btn ml-1">
<div class="i-svg:down" />
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('down')" />
</div>
</div>
<span class="key-text">切换</span>
@@ -126,21 +122,27 @@
</div>
</div>
</template>
</el-dialog>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import router from "@/router";
import { usePermissionStore } from "@/store";
import { isExternal } from "@/utils";
import { RouteRecordRaw, LocationQueryRaw } from "vue-router";
import { Clock, Close, Delete } from "@element-plus/icons-vue";
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
import MenuRouteIcon from "@/components/MenuRouteIcon/index.vue";
import { router } from "@/router";
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
import { useMenuStore } from "@stores";
import type { AppRouteRecord } from "@/types/router";
import { isExternal } from "@utils";
import { LocationQueryRaw } from "vue-router";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
const { Clock, Close, Delete } = ElementPlusIconsVue;
const HISTORY_KEY = "menu_search_history";
const MAX_HISTORY = 5;
const permissionStore = usePermissionStore();
const menuStore = useMenuStore();
const isModalVisible = ref(false);
const searchKeyword = ref("");
const searchInputRef = ref();
@@ -219,9 +221,17 @@ function handleKeyDown(e: KeyboardEvent) {
}
}
watch(
() => menuStore.menuList,
(list) => {
menuItems.value = [];
loadRoutesFromMenu(list ?? []);
},
{ deep: true, immediate: true }
);
// 添加键盘事件监听
onMounted(() => {
loadRoutes(permissionStore.routes);
loadSearchHistory();
document.addEventListener("keydown", handleKeyDown);
});
@@ -295,48 +305,41 @@ function navigateToRoute(item: SearchItem) {
}
}
function loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
function loadRoutesFromMenu(routes: AppRouteRecord[], parentPath = "") {
routes.forEach((route) => {
// 计算完整路径
const path = route.path.startsWith("/")
? route.path
: `${parentPath}${parentPath.endsWith("/") ? "" : "/"}${route.path}`;
const rawPath = route.path ?? "";
const path = rawPath.startsWith("/")
? rawPath
: `${parentPath}${parentPath.endsWith("/") ? "" : "/"}${rawPath}`;
// 检查是否需要排除
if (excludedRoutes.value.includes(route.path) || isExternal(route.path) || route.meta?.hidden)
return;
const meta = route.meta;
const hidden = meta?.hidden === true || meta?.isHide === true;
if (excludedRoutes.value.includes(route.path ?? "") || isExternal(path) || hidden) return;
// 处理有子路由的情况
if (route.children) {
// 如果父路由本身有title,也添加到menuItems中
if (route.meta?.title) {
const title = route.meta.title === "dashboard" ? "首页" : route.meta.title;
if (route.children?.length) {
if (meta?.title) {
const title = meta.title === "dashboard" ? "首页" : meta.title;
const params = (meta as { params?: unknown }).params;
menuItems.value.push({
title,
path,
name: typeof route.name === "string" ? route.name : undefined,
icon: route.meta.icon,
icon: meta.icon,
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
params: route.meta.params
? JSON.parse(JSON.stringify(toRaw(route.meta.params)))
: undefined,
params: params ? JSON.parse(JSON.stringify(toRaw(params))) : undefined,
});
}
// 递归处理子路由
loadRoutes(route.children, path);
}
// 处理没有子路由但有title的情况
else if (route.meta?.title) {
const title = route.meta.title === "dashboard" ? "首页" : route.meta.title;
loadRoutesFromMenu(route.children, path);
} else if (meta?.title) {
const title = meta.title === "dashboard" ? "首页" : meta.title;
const params = (meta as { params?: unknown }).params;
menuItems.value.push({
title,
path,
name: typeof route.name === "string" ? route.name : undefined,
icon: route.meta.icon,
icon: meta.icon,
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
params: route.meta.params
? JSON.parse(JSON.stringify(toRaw(route.meta.params)))
: undefined,
params: params ? JSON.parse(JSON.stringify(toRaw(params))) : undefined,
});
}
});
@@ -363,7 +366,7 @@ function loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
align-items: center;
}
.command-palette-trigger__left :deep([class^="i-svg:"]) {
.command-palette-trigger__left :deep(.art-svg-icon) {
color: var(--el-text-color-secondary) !important;
}
@@ -554,7 +557,7 @@ function loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
}
.esc-btn {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 11px;
}
@@ -1,25 +1,25 @@
<!-- 顶部通知公告 -->
<template>
<el-dropdown trigger="click">
<el-badge v-if="noticeList.length > 0" :value="noticeList.length" :max="99">
<div class="i-svg:bell" />
</el-badge>
<ElDropdown trigger="click">
<ElBadge v-if="noticeList.length > 0" :value="noticeList.length" :max="99">
<ArtSvgIcon :icon="resolveIconForArtSvgIcon('bell')" />
</ElBadge>
<div v-else class="i-svg:bell" />
<ArtSvgIcon v-else :icon="resolveIconForArtSvgIcon('bell')" />
<template #dropdown>
<div class="p-5">
<template v-if="noticeList.length > 0">
<div v-for="(item, index) in noticeList" :key="index" class="w-400px py-3">
<div class="flex-y-center">
<el-tag :type="item.notice_type === '1' ? 'primary' : 'warning'">
<ElTag :type="item.notice_type === '1' ? 'primary' : 'warning'">
{{ item.notice_type === "1" ? "通知" : "公告" }}
</el-tag>
</ElTag>
<!-- truncated: 超出部分省略 -->
<el-text size="small" class="w-200px cursor-pointer !ml-2 !flex-1" truncated>
<ElText size="small" class="w-200px cursor-pointer !ml-2 !flex-1" truncated>
{{ item.notice_content }}
</el-text>
</ElText>
<!-- 时间 -->
<div class="text-xs text-gray">
@@ -27,35 +27,35 @@
</div>
</div>
</div>
<el-divider />
<ElDivider />
<div class="flex-x-between">
<el-link type="primary" underline="never" @click="handleViewMoreNotice">
<ElLink type="primary" underline="never" @click="handleViewMoreNotice">
<span class="text-xs">查看更多</span>
<el-icon class="text-xs">
<ElIcon class="text-xs">
<ArrowRight />
</el-icon>
</el-link>
<el-link
</ElIcon>
</ElLink>
<ElLink
v-if="noticeList.length > 0"
type="primary"
underline="never"
@click="handleMarkAllAsRead"
>
<span class="text-xs">全部已读</span>
</el-link>
</ElLink>
</div>
</template>
<template v-else>
<div class="flex-center h-150px w-350px">
<el-empty :image-size="50" description="暂无消息" />
<ElEmpty :image-size="50" description="暂无消息" />
</div>
</template>
</div>
</template>
</el-dropdown>
</ElDropdown>
<el-dialog
<ElDialog
v-model="noticeDialogVisible"
:title="noticeDetail?.notice_title ?? '通知详情'"
width="800px"
@@ -64,15 +64,15 @@
<div v-if="noticeDetail" class="p-x-20px">
<div class="flex-y-center mb-16px text-13px text-color-secondary">
<span class="flex-y-center">
<el-icon>
<ElIcon>
<User />
</el-icon>
</ElIcon>
{{ noticeDetail.created_by?.name }}
</span>
<span class="ml-2 flex-y-center">
<el-icon>
<ElIcon>
<Timer />
</el-icon>
</ElIcon>
{{ noticeDetail.created_time }}
</span>
</div>
@@ -81,13 +81,16 @@
<div v-html="noticeDetail.notice_content"></div>
</div>
</div>
</el-dialog>
</ElDialog>
</template>
<script setup lang="ts">
import ArtSvgIcon from "@/components/Core/base/art-svg-icon/index.vue";
import NoticeAPI, { NoticeTable } from "@/api/module_system/notice";
import router from "@/router";
import { useNoticeStore } from "@/store";
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
import { ArrowRight, Timer, User } from "@element-plus/icons-vue";
import { router } from "@/router";
import { useNoticeStore } from "@stores";
const noticeStore = useNoticeStore();
@@ -1,6 +1,6 @@
<!-- 操作列自适应宽度 -->
<template>
<el-table-column
<ElTableColumn
:label="label"
:fixed="fixed"
:align="align"
@@ -12,7 +12,7 @@
<slot v-bind="{ row, column, $index }"></slot>
</div>
</template>
</el-table-column>
</ElTableColumn>
</template>
<script setup lang="ts">
@@ -1,22 +1,27 @@
<!-- 分页组件 -->
<template>
<el-scrollbar>
<ElScrollbar>
<div :class="{ hidden: hidden }" class="pagination">
<el-pagination
<ElPagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:background="background"
:disabled="disabled"
:layout="layout"
:page-sizes="pageSizes"
:pager-count="pagerCount"
:size="size"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-scrollbar>
</ElScrollbar>
</template>
<script setup lang="ts">
import { watch, type PropType } from "vue";
const props = defineProps({
total: {
type: Number as PropType<number>,
@@ -25,7 +30,7 @@ const props = defineProps({
pageSizes: {
type: Array as PropType<number[]>,
default() {
return [10, 20, 30, 50];
return [10, 20, 30, 50, 100];
},
},
layout: {
@@ -36,6 +41,20 @@ const props = defineProps({
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
/** 页码按钮数量(透传 ElPagination) */
pagerCount: {
type: Number as PropType<number>,
default: undefined,
},
/** 分页器尺寸(透传 ElPagination) */
size: {
type: String as PropType<"" | "default" | "small" | "large">,
default: undefined,
},
autoScroll: {
type: Boolean,
default: true,
@@ -1,27 +1,35 @@
<!-- 布局大小 -->
<!-- 布局大小(触发器与顶栏 ArtIconButton 一致,避免与其它图标尺寸/悬停不一致) -->
<template>
<el-tooltip :content="t('sizeSelect.tooltip')" effect="dark" placement="bottom">
<el-dropdown trigger="click" @command="handleSizeChange">
<div class="i-svg:size" />
<ElTooltip :content="t('sizeSelect.tooltip')" effect="dark" placement="bottom">
<ElDropdown trigger="click" @command="handleSizeChange">
<span class="inline-flex outline-none leading-none">
<ArtIconButton
:icon="resolveIconForArtSvgIcon('size')"
class="size-select-btn text-[19px]"
/>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
<ElDropdownMenu>
<ElDropdownItem
v-for="item of sizeOptions"
:key="item.value"
:disabled="appStore.size == item.value"
:command="item.value"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</el-dropdown>
</el-tooltip>
</ElDropdown>
</ElTooltip>
</template>
<script setup lang="ts">
import ArtIconButton from "@/components/Core/widget/art-icon-button/index.vue";
import { ComponentSize } from "@/enums/settings/layout.enum";
import { useAppStore } from "@/store/modules/app.store";
import { useAppStore } from "@stores/modules/app.store";
import { resolveIconForArtSvgIcon } from "@utils/menuIcon/remix";
import { computed } from "vue";
const { t } = useI18n();
const sizeOptions = computed(() => {
@@ -1,6 +1,6 @@
<template>
<div ref="tableSelectRef" :style="'width:' + width">
<el-popover
<ElPopover
:visible="popoverVisible"
:width="selectConfig.popover?.width ?? popoverWidth"
placement="bottom-end"
@@ -10,8 +10,9 @@
<template #reference>
<div @click="popoverVisible = !popoverVisible">
<slot>
<el-input
<ElInput
class="reference"
style="width: 100%"
:model-value="text"
:readonly="true"
:placeholder="placeholder"
@@ -19,36 +20,36 @@
@clear="handleClear"
>
<template #suffix>
<el-icon
<ElIcon
:style="{
transform: popoverVisible ? 'rotate(180deg)' : 'rotate(0)',
transition: 'transform .5s',
}"
>
<ArrowDown />
</el-icon>
</ElIcon>
</template>
</el-input>
</ElInput>
</slot>
</div>
</template>
<!-- 弹出框内容 -->
<div ref="popoverContentRef">
<!-- 表单 -->
<el-form ref="formRef" :model="queryParams" :inline="true">
<ElForm ref="formRef" :model="queryParams" :inline="true">
<template v-for="item in selectConfig.formItems" :key="item.prop">
<el-form-item :label="item.label" :prop="item.prop">
<ElFormItem :label="item.label" :prop="item.prop">
<!-- Input 输入框 -->
<template v-if="item.type === 'input'">
<template v-if="item.attrs?.type === 'number'">
<el-input
<ElInput
v-model.number="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<template v-else>
<el-input
<ElInput
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
@@ -57,46 +58,46 @@
</template>
<!-- Select 选择器 -->
<template v-else-if="item.type === 'select'">
<el-select v-model="queryParams[item.prop]" v-bind="item.attrs">
<ElSelect v-model="queryParams[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-option :label="option.label" :value="option.value" />
<ElOption :label="option.label" :value="option.value" />
</template>
</el-select>
</ElSelect>
</template>
<!-- TreeSelect 树形选择 -->
<template v-else-if="item.type === 'tree-select'">
<el-tree-select v-model="queryParams[item.prop]" v-bind="item.attrs" />
<ElTreeSelect v-model="queryParams[item.prop]" v-bind="item.attrs" />
</template>
<!-- DatePicker 日期选择器 -->
<template v-else-if="item.type === 'date-picker'">
<el-date-picker v-model="queryParams[item.prop]" v-bind="item.attrs" />
<ElDatePicker v-model="queryParams[item.prop]" v-bind="item.attrs" />
</template>
<!-- Input 输入框 -->
<template v-else>
<template v-if="item.attrs?.type === 'number'">
<el-input
<ElInput
v-model.number="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<template v-else>
<el-input
<ElInput
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
</template>
</el-form-item>
</ElFormItem>
</template>
<el-form-item>
<el-button type="primary" icon="search" @click="handleQuery">搜索</el-button>
<el-button icon="refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<ElFormItem>
<ElButton type="primary" icon="search" @click="handleQuery">搜索</ElButton>
<ElButton icon="refresh" @click="handleReset">重置</ElButton>
</ElFormItem>
</ElForm>
<!-- 列表 -->
<el-table
<ElTable
ref="tableRef"
v-loading="loading"
:data="pageData"
@@ -110,18 +111,18 @@
<template v-for="col in selectConfig.tableColumns" :key="col.prop">
<!-- 自定义 -->
<template v-if="col.templet === 'custom'">
<el-table-column v-bind="col">
<ElTableColumn v-bind="col">
<template #default="scope">
<slot :name="col.slotName ?? col.prop" :prop="col.prop" v-bind="scope" />
</template>
</el-table-column>
</ElTableColumn>
</template>
<!-- 其他 -->
<template v-else>
<el-table-column v-bind="col" />
<ElTableColumn v-bind="col" />
</template>
</template>
</el-table>
</ElTable>
<!-- 分页 -->
<pagination
v-model:total="total"
@@ -130,14 +131,14 @@
@pagination="handlePagination"
/>
<div class="feedback">
<el-button type="primary" size="small" @click="handleConfirm">
<ElButton type="primary" size="small" @click="handleConfirm">
{{ confirmText }}
</el-button>
<el-button size="small" @click="handleClear">清 空</el-button>
<el-button size="small" @click="handleClose">关 闭</el-button>
</ElButton>
<ElButton size="small" @click="handleClear">清 空</ElButton>
<ElButton size="small" @click="handleClose">关 闭</ElButton>
</div>
</div>
</el-popover>
</ElPopover>
</div>
</template>
@@ -18,7 +18,7 @@
>
<!-- 左侧图标 -->
<div class="left-icon">
<el-icon><Bell /></el-icon>
<ElIcon><Bell /></ElIcon>
</div>
<!-- 滚动内容包装器 -->
<div class="scroll-wrapper">
@@ -35,7 +35,7 @@
</div>
<!-- 可选的关闭按钮 -->
<div v-if="showClose" class="right-icon" @click="handleRightIconClick">
<el-icon><Close /></el-icon>
<ElIcon><Close /></ElIcon>
</div>
</div>
</template>
@@ -1,27 +1,27 @@
<template>
<el-dropdown trigger="click" @command="handleDarkChange">
<el-icon :size="20">
<ElDropdown trigger="click" @command="handleDarkChange">
<ElIcon :size="20">
<component :is="settingsStore.theme === ThemeMode.DARK ? Moon : Sunny" />
</el-icon>
</ElIcon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
<ElDropdownMenu>
<ElDropdownItem
v-for="item in theneList"
:key="item.value"
:command="item.value"
:disabled="settingsStore.theme === item.value"
>
<el-icon>
<ElIcon>
<component :is="item.component" />
</el-icon>
</ElIcon>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</el-dropdown>
</ElDropdown>
</template>
<script setup lang="ts">
import { useSettingsStore } from "@/store";
import { useSettingsStore } from "@stores";
import { ThemeMode } from "@/enums";
import { Moon, Sunny, Monitor } from "@element-plus/icons-vue";
@@ -1,7 +1,36 @@
<!-- 单图上传组件 -->
<template>
<div class="single-image-upload">
<el-upload
<ElDialog
v-model="cropVisible"
:title="cropDialogTitle"
width="640px"
append-to-body
destroy-on-close
class="single-image-upload__crop-dialog"
@closed="onCropDialogClosed"
>
<ArtCutterImg
v-if="cropVisible && cropSourceUrl"
:key="cropSourceUrl"
:img-url="cropSourceUrl"
:box-width="cropBoxWidth"
:box-height="cropBoxHeight"
:cut-width="cropCutWidth"
:cut-height="cropCutHeight"
:quality="cropQuality"
:tool="true"
:show-preview="true"
:original-graph="false"
:file-type="cropFileType"
:title="cropInnerTitle"
:preview-title="cropPreviewTitle"
@update:img-url="onCropConfirm"
@error="onCropError"
/>
</ElDialog>
<ElUpload
v-model:file-list="internalFileList"
class="single-upload"
list-type="picture-card"
@@ -16,7 +45,7 @@
>
<template #default>
<template v-if="internalFileList && internalFileList.length > 0 && internalFileList[0].url">
<el-image
<ElImage
:key="internalFileList[0].url"
class="single-upload__image"
:src="internalFileList[0].url"
@@ -25,21 +54,21 @@
:preview-teleported="true"
@click.stop="handleImageClick"
/>
<el-icon
<ElIcon
v-if="!props.disabled"
class="single-upload__delete-btn"
@click.stop="handleDelete"
>
<CircleCloseFilled />
</el-icon>
</ElIcon>
</template>
<template v-else>
<el-icon class="single-upload__add-btn">
<ElIcon class="single-upload__add-btn">
<Plus />
</el-icon>
</ElIcon>
</template>
</template>
</el-upload>
</ElUpload>
<div v-if="props.showTip" class="el-upload__tip">
{{ props.tipText || `支持 ${props.accept} 格式,文件大小不超过 ${props.maxFileSize}MB` }}
</div>
@@ -50,6 +79,8 @@
import { ref, watch } from "vue";
import { UploadRawFile, UploadRequestOptions, ElMessage, type UploadUserFile } from "element-plus";
import ParamsAPI from "@/api/module_system/params";
import ArtCutterImg from "@/components/Core/media/art-cutter-img/index.vue";
import { dataURLToFile } from "@utils/file/dataUrl";
const props = defineProps({
/**
@@ -128,6 +159,57 @@ const props = defineProps({
type: Boolean,
default: true,
},
/** 选图后先裁剪再上传(用于站点 Logo / 背景等) */
enableCrop: {
type: Boolean,
default: false,
},
cropBoxWidth: {
type: Number,
default: 520,
},
cropBoxHeight: {
type: Number,
default: 360,
},
cropCutWidth: {
type: Number,
default: 400,
},
cropCutHeight: {
type: Number,
default: 300,
},
cropQuality: {
type: Number,
default: 0.92,
},
cropFileType: {
type: String as () => "png" | "jpeg" | "webp",
default: "jpeg",
},
cropDialogTitle: {
type: String,
default: "裁剪图片",
},
cropInnerTitle: {
type: String,
default: "调整图片",
},
cropPreviewTitle: {
type: String,
default: "预览",
},
});
// 接收字符串类型的modelValue,保持与现有代码的兼容性
@@ -138,6 +220,58 @@ const modelValue = defineModel<string>({
// 内部使用的文件列表
const internalFileList = ref<UploadUserFile[]>([]);
const cropVisible = ref(false);
const cropSourceUrl = ref("");
function revokeCropUrl() {
if (cropSourceUrl.value.startsWith("blob:")) {
URL.revokeObjectURL(cropSourceUrl.value);
}
cropSourceUrl.value = "";
}
function onCropDialogClosed() {
revokeCropUrl();
}
function onCropError() {
ElMessage.error("图片加载失败,请换一张图重试");
}
async function onCropConfirm(dataURL: string) {
try {
const ext =
props.cropFileType === "png" ? "png" : props.cropFileType === "webp" ? "webp" : "jpg";
const file = dataURLToFile(dataURL, `upload.${ext}`);
await uploadFileInternal(file);
cropVisible.value = false;
revokeCropUrl();
} catch (e) {
console.error(e);
ElMessage.error("裁剪结果上传失败,请重试");
}
}
async function uploadFileInternal(file: File | UploadRawFile) {
const formData = new FormData();
formData.append(props.name, file);
for (const [key, value] of Object.entries(props.data)) {
formData.append(key, String(value));
}
const response = await ParamsAPI.uploadFile(formData);
if (response.data.code === 0 && response.data) {
const fileInfo: UploadFilePath = response.data.data;
onSuccess(fileInfo);
return fileInfo;
}
const errorMsg = response.data.msg || "上传失败";
ElMessage.error(errorMsg);
throw new Error(errorMsg);
}
// 监听modelValue变化,同步到internalFileList
watch(
() => modelValue.value,
@@ -210,6 +344,14 @@ function handleBeforeUpload(file: UploadRawFile) {
ElMessage.warning(`上传图片不能大于 ${props.maxFileSize}MB`);
return false;
}
if (props.enableCrop) {
revokeCropUrl();
cropSourceUrl.value = URL.createObjectURL(file);
cropVisible.value = true;
return false;
}
return true;
}
@@ -218,28 +360,7 @@ function handleBeforeUpload(file: UploadRawFile) {
*/
async function handleUpload(options: UploadRequestOptions) {
try {
const file = options.file;
const formData = new FormData();
formData.append(props.name, file);
// 处理附加参数
for (const [key, value] of Object.entries(props.data)) {
formData.append(key, String(value));
}
const response = await ParamsAPI.uploadFile(formData);
if (response.data.code === 0 && response.data) {
const fileInfo: UploadFilePath = response.data.data;
// 调用成功回调
onSuccess(fileInfo);
return fileInfo;
} else {
const errorMsg = response.data.msg || "上传失败";
ElMessage.error(errorMsg);
throw new Error(errorMsg);
}
return await uploadFileInternal(options.file);
} catch (error) {
onError(error instanceof Error ? error : new Error(String(error)));
throw error;