mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-27 14:52:56 +00:00
feat: 迁移前端资源文件并重构项目结构
refactor: 优化前端代码结构和资源管理 style: 调整前端代码格式和样式 chore: 更新.gitignore和构建配置 fix: 修复前端资源路径和引用问题 docs: 更新前端文档和注释 perf: 优化前端性能和加载速度 test: 更新前端测试用例 build: 调整前端构建配置 ci: 更新CI/CD配置
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
<ArtSvgIcon icon="ri:home-line" />
|
||||
</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">
|
||||
<ArtSvgIcon icon="ri:user-line" class="text-2xl" />
|
||||
</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">
|
||||
<ArtSvgIcon icon="ri:heart-fill" class="text-red-500" />
|
||||
</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">
|
||||
<ArtSvgIcon icon="ri:star-fill" class="text-4xl text-yellow-500" />
|
||||
</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(/ /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(/ /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> -->
|
||||
+34
-20
@@ -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>
|
||||
+25
-15
@@ -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>
|
||||
+69
-36
@@ -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 ?? [];
|
||||
+50
-31
@@ -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";
|
||||
+68
-14
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user