feat(web): 升级至 v3.0 并重构组件与引导流程

- 提取应用引导逻辑至 useAppBootstrap 组合式函数
- 增强日期选择器组件,支持全类型与格式自动推导
- 修复资源泄漏:清理 RAF、定时器、事件与 mitt 监听
- 修复退出登录逻辑与异步导出流程
- 迁移 ::v-deep 至 Vue 3 标准 :deep 语法
- 移除调试日志并改进类型定义与注释文档
This commit is contained in:
zhangtao
2026-05-11 23:37:11 +08:00
parent 7e7a5c97cb
commit 9a231aa58c
120 changed files with 2282 additions and 1931 deletions
@@ -1,39 +1,91 @@
<!-- 日期选择器 -->
<!-- 封装日期选择器,支持所有 ElDatePicker 类型,带快捷选项 -->
<template>
<div class="custom-date-picker">
<ElDatePicker
:model-value="modelValue"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:shortcuts="shortcuts"
:type="pickerType"
:format="pickerFormat"
:value-format="pickerFormat"
:range-separator="rangeSeparator"
:start-placeholder="startPlaceholder"
:end-placeholder="endPlaceholder"
:shortcuts="isRangeType ? shortcuts : undefined"
v-bind="$attrs"
@update:model-value="(val) => emit('update:model-value', val)"
/>
</div>
</template>
<script setup lang="ts">
// 定义组件属性
defineProps({
modelValue: {
type: Array as unknown as () => [Date, Date] | [],
default: () => [],
},
});
import { computed } from "vue";
import { ElDatePicker } from "element-plus";
type DatePickerType =
| "year"
| "month"
| "date"
| "dates"
| "week"
| "datetime"
| "datetimerange"
| "daterange"
| "monthrange";
const props = withDefaults(
defineProps<{
modelValue?: any;
type?: DatePickerType;
format?: string;
rangeSeparator?: string;
startPlaceholder?: string;
endPlaceholder?: string;
}>(),
{
type: "datetimerange",
rangeSeparator: "至",
startPlaceholder: "开始日期",
endPlaceholder: "结束日期",
}
);
// 定义事件
const emit = defineEmits(["update:model-value"]);
// 快捷选项配置
/** 是否为范围选择模式 */
const isRangeType = computed(() => {
return ["datetimerange", "daterange", "monthrange"].includes(props.type);
});
/** 根据 type 推导默认格式 */
const pickerFormat = computed(() => {
if (props.format) return props.format;
switch (props.type) {
case "datetime":
case "datetimerange":
return "YYYY-MM-DD HH:mm:ss";
case "date":
case "daterange":
return "YYYY-MM-DD";
case "month":
case "monthrange":
return "YYYY-MM";
case "year":
return "YYYY";
case "week":
return "YYYY-wo";
default:
return "YYYY-MM-DD";
}
});
const pickerType = computed(() => props.type);
/** 快捷选项(仅范围选择器展示) */
const shortcuts = [
{
text: "近一天",
value: () => {
const end = new Date();
const start = new Date();
// 修正:起始时间为当前时间往前 24 小时
start.setTime(start.getTime() - 3600 * 1000 * 24);
return [start, end];
},
@@ -53,13 +53,12 @@
<!-- default slot -->
<slot></slot>
<!-- background image -->
<!-- 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>
@@ -51,6 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
const animationDuration = 500;
const currentPercentage = ref(0);
let animFrameId: number | null = null;
const animateProgress = () => {
const startTime = Date.now();
@@ -65,17 +66,26 @@ const animateProgress = () => {
currentPercentage.value = startValue + (endValue - startValue) * progress;
if (progress < 1) {
requestAnimationFrame(animate);
animFrameId = requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
// 取消旧动画后启动新动画
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
animFrameId = requestAnimationFrame(animate);
};
onMounted(() => {
animateProgress();
});
onBeforeUnmount(() => {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
});
// 当 percentage 属性变化时重新执行动画
watch(
() => props.percentage,
@@ -222,8 +222,6 @@ const handleMapClick = (params: Record<string, unknown>) => {
level: (data?.level as string) || "",
};
console.log(`选中区域: ${params.name}`, params);
// 高亮选中区域
chartInstance.value?.dispatchAction({
type: "select",
@@ -240,8 +238,14 @@ const resizeChart = () => {
chartInstance.value?.resize();
};
let initTimer: ReturnType<typeof setTimeout> | null = null;
// 处理组件销毁
const cleanupChart = () => {
if (initTimer !== null) {
clearTimeout(initTimer);
initTimer = null;
}
if (chartInstance.value) {
chartInstance.value.off("click", handleMapClick);
chartInstance.value.dispose();
@@ -253,9 +257,10 @@ const cleanupChart = () => {
// 生命周期钩子
onMounted(() => {
if (!isEmpty.value) {
initMap().then(() => {
setTimeout(resizeChart, 100);
});
(async () => {
await initMap();
initTimer = setTimeout(resizeChart, 100);
})();
}
window.addEventListener("resize", resizeChart);
});
@@ -25,28 +25,12 @@
<script setup lang="ts">
import { useAuth } from "@/hooks/core/useAuth";
import type { ButtonMoreItem } from "./types";
defineOptions({ name: "FaButtonMore" });
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[];
@@ -0,0 +1,18 @@
/** FaButtonMore 组件相关类型 */
export interface ButtonMoreItem {
/** 按钮标识,可用于点击事件 */
key: string | number;
/** 按钮文本 */
label: string;
/** 是否禁用 */
disabled?: boolean;
/** 权限标识 */
auth?: string;
/** 图标组件 */
icon?: string;
/** 文本颜色 */
color?: string;
/** 图标颜色(优先级高于 color) */
iconColor?: string;
}
@@ -147,10 +147,6 @@ const onTouchMove = (e: any) => {
}
};
// 全局事件监听器添加
document.addEventListener("touchstart", onTouchStart);
document.addEventListener("touchmove", onTouchMove, { passive: false });
// 获取数值形式的宽度
const getNumericWidth = (): number => {
if (typeof props.width === "string") {
@@ -180,7 +176,7 @@ onMounted(() => {
dragVerify.value?.style.setProperty("--pwidth", -Math.floor(numericWidth / 2) + "px");
});
// 重复添加事件监听器(确保事件绑定)
// 注册 touch 事件监听器,由 onBeforeUnmount 统一清理
document.addEventListener("touchstart", onTouchStart);
document.addEventListener("touchmove", onTouchMove, { passive: false });
});
@@ -97,14 +97,22 @@
</template>
<script setup lang="ts">
/**
* FaForm —— 动态表单组件。
*
* 渲染策略:items 配置项数组 → componentMap[type] 映射组件 → 动态 :is 渲染。
* 支持所有 Element Plus 表单组件类型、自定义 render、插槽插写。
*
* @see SearchFormItem 接口定义
*/
import { useWindowSize } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { toRaw, type Component } from "vue";
import DatePicker from "@/components/DatePicker/index.vue";
import {
ElCascader,
ElCheckbox,
ElCheckboxGroup,
ElDatePicker,
ElInput,
ElInputTag,
ElInputNumber,
@@ -131,10 +139,10 @@ const componentMap = {
checkbox: ElCheckbox, // 复选框
checkboxgroup: ElCheckboxGroup, // 复选框组
radiogroup: ElRadioGroup, // 单选框组
date: ElDatePicker, // 日期选择器
daterange: ElDatePicker, // 日期范围选择器
datetime: ElDatePicker, // 日期时间选择器
datetimerange: ElDatePicker, // 日期时间范围选择器
date: DatePicker, // 日期选择器
daterange: DatePicker, // 日期范围选择器
datetime: DatePicker, // 日期时间选择器
datetimerange: DatePicker, // 日期时间范围选择器
rate: ElRate, // 评分
slider: ElSlider, // 滑块
cascader: ElCascader, // 级联选择器
@@ -113,11 +113,11 @@ import { ArrowUpBold, ArrowDownBold, Refresh, Search } from "@element-plus/icons
import { useWindowSize } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { toRaw, type Component } from "vue";
import DatePicker from "@/components/DatePicker/index.vue";
import {
ElCascader,
ElCheckbox,
ElCheckboxGroup,
ElDatePicker,
ElInput,
ElInputTag,
ElInputNumber,
@@ -144,10 +144,10 @@ const componentMap = {
checkbox: ElCheckbox, // 复选框
checkboxgroup: ElCheckboxGroup, // 复选框组
radiogroup: ElRadioGroup, // 单选框组
date: ElDatePicker, // 日期选择器
daterange: ElDatePicker, // 日期范围选择器
datetime: ElDatePicker, // 日期时间选择器
datetimerange: ElDatePicker, // 日期时间范围选择器
date: DatePicker, // 日期选择器
daterange: DatePicker, // 日期范围选择器
datetime: DatePicker, // 日期时间选择器
datetimerange: DatePicker, // 日期时间范围选择器
rate: ElRate, // 评分
slider: ElSlider, // 滑块
cascader: ElCascader, // 级联选择器
@@ -156,7 +156,7 @@ if (uploadConfig?.isCustomUpload && uploadConfig.server && editorConfig.MENU_CON
},
});
const { url, alt = "", href = "" } = response.data.data ?? ({} as any);
const { url, alt = "", href = "" } = response.data.data ?? ({} as Record<string, string>);
if (!url) {
throw new Error("上传失败,请检查服务端配置");
@@ -177,7 +177,7 @@ const onCreateEditor = (editor: IDomEditor) => {
// 监听全屏事件
editor.on("fullScreen", () => {
console.log("编辑器进入全屏模式");
// 全屏状态由 wangEditor 内部管理
});
// 确保在编辑器创建后应用自定义图标
@@ -128,6 +128,7 @@ onMounted(() => {
});
onUnmounted(() => {
mittBus.off("openSearchDialog", openSearchDialog);
document.removeEventListener("keydown", handleKeydown);
});
@@ -367,27 +368,27 @@ const highlightOnHoverHistory = (index: number) => {
</script>
<style lang="scss" scoped>
.layout-search {
::v-deep(.search-modal) {
:deep(.search-modal) {
background-color: rgb(0 0 0 / 20%);
}
::v-deep(.el-dialog__body) {
:deep(.el-dialog__body) {
padding: 5px 0 0 !important;
}
::v-deep(.el-dialog__header) {
:deep(.el-dialog__header) {
padding: 0;
}
.el-input {
::v-deep(.el-input__wrapper) {
:deep(.el-input__wrapper) {
background-color: var(--fa-gray-200);
border: 1px solid var(--default-border-dashed);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
box-shadow: none;
}
::v-deep(.el-input__inner) {
:deep(.el-input__inner) {
color: var(--fa-gray-800) !important;
}
}
@@ -395,18 +396,18 @@ const highlightOnHoverHistory = (index: number) => {
.dark .layout-search {
.el-input {
::v-deep(.el-input__wrapper) {
:deep(.el-input__wrapper) {
background-color: #333;
border: 1px solid #4c4d50;
}
}
::v-deep(.search-modal) {
:deep(.search-modal) {
background-color: rgb(23 23 26 / 60%);
backdrop-filter: none;
}
::v-deep(.el-dialog) {
:deep(.el-dialog) {
background-color: #252526;
}
}
@@ -463,35 +463,35 @@ const openChat = (): void => {
}
/* Hover animation classes */
.refresh-btn:hover ::v-deep(.fa-svg-icon) {
.refresh-btn:hover :deep(.fa-svg-icon) {
animation: rotate180 0.5s;
}
.language-btn:hover ::v-deep(.fa-svg-icon) {
.language-btn:hover :deep(.fa-svg-icon) {
animation: moveUp 0.4s;
}
.setting-btn:hover ::v-deep(.fa-svg-icon) {
.setting-btn:hover :deep(.fa-svg-icon) {
animation: rotate180 0.5s;
}
.full-screen-btn:hover ::v-deep(.fa-svg-icon) {
.full-screen-btn:hover :deep(.fa-svg-icon) {
animation: expand 0.6s forwards;
}
::v-deep(.size-select-btn:hover .fa-svg-icon) {
:deep(.size-select-btn:hover .fa-svg-icon) {
animation: expand 0.6s forwards;
}
.exit-full-screen-btn:hover ::v-deep(.fa-svg-icon) {
.exit-full-screen-btn:hover :deep(.fa-svg-icon) {
animation: shrink 0.6s forwards;
}
.notice-button:hover ::v-deep(.fa-svg-icon) {
.notice-button:hover :deep(.fa-svg-icon) {
animation: shake 0.5s ease-in-out;
}
.chat-button:hover ::v-deep(.fa-svg-icon) {
.chat-button:hover :deep(.fa-svg-icon) {
animation: shake 0.5s ease-in-out;
}
@@ -196,14 +196,17 @@ watch(
function handleLogout(): void {
closeUserMenu();
setTimeout(() => {
ElMessageBox.confirm(t("common.logoutTips"), t("common.tips"), {
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
customClass: "login-out-dialog",
}).then(() => {
userStore.logout();
});
setTimeout(async () => {
try {
await ElMessageBox.confirm(t("common.logoutTips"), t("common.tips"), {
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
customClass: "login-out-dialog",
});
await userStore.logout();
} catch {
// 用户取消
}
}, 200);
}
@@ -92,18 +92,18 @@ const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
<style scoped>
/* Remove el-menu bottom border */
::v-deep(.el-menu) {
:deep(.el-menu) {
border-bottom: none !important;
}
/* Remove default styles for first-level menu items */
::v-deep(.el-menu-item[tabindex="0"]) {
:deep(.el-menu-item[tabindex="0"]) {
background-color: transparent !important;
border: none !important;
}
/* Remove bottom border from submenu titles */
::v-deep(.el-menu--horizontal .el-sub-menu__title) {
:deep(.el-menu--horizontal .el-sub-menu__title) {
padding: 0 30px 0 10px !important;
border: 0 !important;
}
@@ -106,7 +106,7 @@ const closeMenu = () => {
</script>
<style scoped>
::v-deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
right: 10px !important;
}
</style>
@@ -248,13 +248,13 @@ onMounted(initScrollState);
</style>
<style scoped>
::v-deep(.el-scrollbar__bar.is-horizontal) {
:deep(.el-scrollbar__bar.is-horizontal) {
bottom: 5px;
display: none;
height: 2px;
}
::v-deep(.scrollbar-wrapper) {
:deep(.scrollbar-wrapper) {
flex: 1;
min-width: 0;
margin: 0 50px 0 30px;
@@ -273,7 +273,7 @@ onMounted(initScrollState);
}
@media (width <= 1440px) {
::v-deep(.scrollbar-wrapper) {
:deep(.scrollbar-wrapper) {
margin: 0 45px;
}
}
@@ -375,18 +375,18 @@ const useTabManagement = (
// 业务逻辑处理
const useBusinessLogic = () => {
const handleNoticeAll = () => {
// 处理查看全部通知
console.log("查看全部通知");
// TODO: 实现查看全部通知逻辑
console.info("[TODO] 查看全部通知");
};
const handleMsgAll = () => {
// 处理查看全部消息
console.log("查看全部消息");
// TODO: 实现查看全部消息逻辑
console.info("[TODO] 查看全部消息");
};
const handlePendingAll = () => {
// 处理查看全部待办
console.log("查看全部待办");
// TODO: 实现查看全部待办逻辑
console.info("[TODO] 查看全部待办");
};
return {
@@ -209,6 +209,7 @@ export function useSettingsPanel() {
const cleanupSettings = () => {
stopWatch();
mittBus.off("openSetting", openSetting);
themeCleanup?.();
cleanup();
};
@@ -83,7 +83,7 @@ const getSettingValue = (key: string) => {
// 统一的设置变更处理
const handleSettingChange = (handlerName: string, value: any) => {
const handler = (basicHandlers as any)[handlerName];
const handler = (basicHandlers as Record<string, (...args: any[]) => any>)[handlerName];
if (typeof handler === "function") {
handler(value);
} else {
@@ -1,4 +1,4 @@
<!-- 三种模式与 art-design-pro 一致:tab-default / tab-card / tab-google(参考 art-design-pro/src/components/core/layouts/art-work-tab) -->
<!-- 三种模式:tab-default / tab-card / tab-google -->
<template>
<div
v-if="showWorkTab"
@@ -156,6 +156,17 @@
</template>
<script setup lang="ts">
/**
* 工作栏标签页组件:多标签导航 + 右键菜单 + KeepAlive 缓存管理。
*
* 三种模式(通过 settingStore.tabStyle 切换):
* tab-default —— 默认模式,独立标签卡
* tab-card —— 卡片模式,标签有圆角边框
* tab-google —— 谷歌模式,连体标签
*
* 核心流程:路由切换 → setWorktab (utils/navigation) 同步 → 本组件响应式渲染。
* 关闭/切换/Pin 操作全部通过 worktabStore 管理。
*/
import { computed, onMounted, ref, watch, nextTick, onUnmounted } from "vue";
import { LocationQueryRaw, useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
+24 -1
View File
@@ -1,10 +1,19 @@
<!-- 布局容器 -->
<!--
布局根容器:三区域结构
- #app-sidebar ← 左侧菜单导航(收起/展开)
- #app-main ← 右侧主区域(顶栏 + 页面内容)
- #app-header 顶栏(面包屑、搜索、通知、用户菜单)
- #app-content 页面内容(RouterView + 页签)
- #app-global ← 全局浮层层(Toast、Modal、新手引导)
-->
<template>
<div class="app-layout">
<!-- 左侧菜单导航 -->
<aside id="app-sidebar">
<FaSidebarMenu />
</aside>
<!-- 右侧主区域 -->
<main id="app-main">
<div id="app-header">
<FaHeaderBar />
@@ -14,6 +23,7 @@
</div>
</main>
<!-- 全局浮层层(引导、通知等跨页面组件) -->
<div id="app-global">
<FaGlobalComponent />
<Guide v-if="guideVisible" v-model="guideVisible" @skip="onGuideFinished" />
@@ -22,6 +32,17 @@
</template>
<script setup lang="ts">
/**
* 布局根组件 —— 组装侧栏、顶栏、页面内容、全局浮层。
*
* 依赖:
* appStore.guideVisible ← 控制新手指引显隐(session 级状态)
* settingStore.showGuide ← 用户是否关闭指引(持久化)
*
* 流程:
* 首次访问 → guideVisible=true → 用户完成/跳过指引 → onGuideFinished()
* → settingStore.showGuide=false → 后续不再显示
*/
import { computed } from "vue";
import Guide from "@/components/others/fa-guide/index.vue";
import { useAppStore } from "@stores/modules/app.store";
@@ -32,11 +53,13 @@ defineOptions({ name: "AppLayout" });
const appStore = useAppStore();
const settingStore = useSettingsStore();
/** 新手指引显隐 —— session 级状态,首次登录/注册后自动弹出 */
const guideVisible = computed({
get: () => appStore.guideVisible,
set: (v: boolean) => appStore.showGuide(v),
});
/** 指引完成后持久化标记「不再显示」 */
function onGuideFinished(): void {
settingStore.updateSetting("showGuide", false);
}
@@ -243,7 +243,6 @@ function handleClearAll() {
// 下载图片
function downloadImg() {
console.log("下载图片");
const a = document.createElement("a");
a.href = temImgPath.value;
a.download = "image.png";
@@ -283,11 +282,11 @@ function downloadImg() {
}
}
::v-deep(.toolBoxControl) {
:deep(.toolBoxControl) {
z-index: 100;
}
::v-deep(.dockMain) {
:deep(.dockMain) {
right: 0;
bottom: -40px;
left: 0;
@@ -297,15 +296,15 @@ function downloadImg() {
opacity: 1;
}
::v-deep(.copyright) {
:deep(.copyright) {
display: none !important;
}
::v-deep(.i-dialog-footer) {
:deep(.i-dialog-footer) {
margin-top: 60px !important;
}
::v-deep(.dockBtn) {
:deep(.dockBtn) {
height: 26px;
padding: 0 10px;
font-size: 12px;
@@ -315,31 +314,31 @@ function downloadImg() {
border: 1px solid var(--el-color-primary-light-4) !important;
}
::v-deep(.dockBtnScrollBar) {
:deep(.dockBtnScrollBar) {
margin: 0 10px 0 6px;
background-color: var(--el-color-primary-light-1);
}
::v-deep(.scrollBarControl) {
:deep(.scrollBarControl) {
border-color: var(--el-color-primary);
}
::v-deep(.closeIcon) {
:deep(.closeIcon) {
line-height: 15px !important;
}
}
.dark {
.cutter-container {
::v-deep(.toolBox) {
:deep(.toolBox) {
border: transparent;
}
::v-deep(.dialogMain) {
:deep(.dialogMain) {
background-color: transparent !important;
}
::v-deep(.i-dialog-footer) {
:deep(.i-dialog-footer) {
.btn {
background-color: var(--el-color-primary) !important;
border: transparent;
@@ -119,7 +119,7 @@ const dialogAttrs = computed(() => {
align-items: center;
margin-left: auto;
::v-deep(.core-overlay-icon-btn.el-button) {
:deep(.core-overlay-icon-btn.el-button) {
min-width: 32px;
padding: 6px;
border-radius: var(--el-border-radius-base);
@@ -99,7 +99,7 @@ const drawerAttrs = computed(() => {
align-items: center;
margin-left: auto;
::v-deep(.core-overlay-icon-btn.el-button) {
:deep(.core-overlay-icon-btn.el-button) {
min-width: 32px;
padding: 6px;
border-radius: var(--el-border-radius-base);
@@ -185,7 +185,7 @@ function handleCloseExportsModal() {
}
// 导出
function handleExports() {
async function handleExports() {
try {
const filename = exportsFormData.filename
? exportsFormData.filename
@@ -204,64 +204,29 @@ function handleExports() {
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("导出远程文件失败");
});
const blob = await props.contentConfig.exportsBlobAction(lastFormData);
saveBlobDownload(blob, filename as string);
ElMessage.success("导出成功");
return;
}
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.error("导出远程数据失败:", error);
ElMessage.error("导出远程数据失败");
});
})
.catch((error) => {
console.error("获取远程数据失败:", error);
ElMessage.error("获取远程数据失败");
});
const res = await props.contentConfig.exportsAction(lastFormData);
worksheet.addRows(res);
const buffer = await workbook.xlsx.writeBuffer();
saveXlsx(buffer, filename as string);
} else {
ElMessage.error("未配置 exportsAction 或 exportsBlobAction");
}
} else if (exportsFormData.origin === ExportsOriginEnum.SELECTED) {
const rows = props.selectionData ?? [];
worksheet.addRows(rows);
workbook.xlsx
.writeBuffer()
.then((buffer) => {
saveXlsx(buffer, filename as string);
})
.catch((error) => {
console.error("导出选中数据失败:", error);
ElMessage.error("导出选中数据失败");
});
const buffer = await workbook.xlsx.writeBuffer();
saveXlsx(buffer, filename as string);
} else {
const rows = props.pageData ?? [];
worksheet.addRows(rows);
workbook.xlsx
.writeBuffer()
.then((buffer) => {
saveXlsx(buffer, filename as string);
})
.catch((error) => {
console.error("导出当前数据失败:", error);
ElMessage.error("导出当前数据失败");
});
const buffer = await workbook.xlsx.writeBuffer();
saveXlsx(buffer, filename as string);
}
} catch (error) {
console.error("导出失败:", error);
@@ -254,29 +254,28 @@ function saveXlsx(fileData: any, fileName: string) {
}
// 下载导入模板
function handleDownloadTemplate() {
async function handleDownloadTemplate() {
try {
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 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 */
const response = await importTemplate();
const fileData = response.data;
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);
});
}
saveXlsx(fileData, fileName);
} else {
ElMessage.error("未配置importTemplate");
}
@@ -205,45 +205,45 @@ const handleDeleteEvent = () => {
min-width: 0;
}
::v-deep(.el-calendar__header) {
:deep(.el-calendar__header) {
padding: 6px 4px;
border-bottom: none;
}
::v-deep(.el-calendar__title) {
:deep(.el-calendar__title) {
font-size: 14px;
font-weight: 600;
}
::v-deep(.el-calendar__header .el-button) {
:deep(.el-calendar__header .el-button) {
padding: 4px 8px;
}
::v-deep(.el-calendar__body) {
:deep(.el-calendar__body) {
padding: 2px 0 4px;
}
::v-deep(.el-calendar-table thead th) {
:deep(.el-calendar-table thead th) {
padding: 4px 0;
font-size: 11px;
font-weight: 500;
}
::v-deep(.is-selected) {
:deep(.is-selected) {
background-color: var(--el-color-warning-light-9) !important;
}
::v-deep(.el-calendar-day) {
:deep(.el-calendar-day) {
height: auto;
min-height: 3rem;
padding: 0;
}
::v-deep(.el-calendar-day:hover) {
:deep(.el-calendar-day:hover) {
background-color: transparent !important;
}
::v-deep(.el-dialog__body) {
:deep(.el-dialog__body) {
padding-top: 20px;
}
</style>
@@ -28,18 +28,16 @@ const props = defineProps({
},
});
function handleClipboard() {
async function handleClipboard() {
if (navigator.clipboard && navigator.clipboard.writeText) {
// 使用 Clipboard API
navigator.clipboard
.writeText(props.text)
.then(() => {
ElMessage.success(t("common.copySuccess"));
})
.catch((error) => {
ElMessage.warning(t("common.copyFailed"));
console.log("[CopyButton] Copy failed", error);
});
try {
await navigator.clipboard.writeText(props.text);
ElMessage.success(t("common.copySuccess"));
} catch (error) {
ElMessage.warning(t("common.copyFailed"));
console.warn("[CopyButton] Copy failed:", error);
}
} else {
// 兼容性处理(useClipboard 有兼容性问题)
const input = document.createElement("input");
@@ -57,7 +55,7 @@ function handleClipboard() {
}
} catch (err) {
ElMessage.warning(t("common.copyFailed"));
console.log("[CopyButton] Copy failed.", err);
console.warn("[CopyButton] Copy failed:", err);
} finally {
document.body.removeChild(input);
}
@@ -70,7 +70,7 @@ const renderedContent = computed(() => {
color: var(--el-text-color-primary);
word-wrap: break-word;
::v-deep(pre) {
:deep(pre) {
padding: 12px;
margin: 12px 0;
overflow-x: auto;
@@ -83,7 +83,7 @@ const renderedContent = computed(() => {
}
}
::v-deep(code) {
:deep(code) {
padding: 2px 6px;
font-family: "Courier New", Courier, monospace;
font-size: 13px;
@@ -91,21 +91,21 @@ const renderedContent = computed(() => {
border-radius: 3px;
}
::v-deep(p) {
:deep(p) {
margin: 8px 0;
}
::v-deep(ul),
::v-deep(ol) {
:deep(ul),
:deep(ol) {
padding-left: 24px;
margin: 8px 0;
}
::v-deep(li) {
:deep(li) {
margin: 4px 0;
}
::v-deep(a) {
:deep(a) {
color: var(--el-color-primary);
text-decoration: none;
@@ -114,14 +114,14 @@ const renderedContent = computed(() => {
}
}
::v-deep(blockquote) {
:deep(blockquote) {
padding: 8px 16px;
margin: 12px 0;
background: var(--el-fill-color-light);
border-left: 4px solid var(--el-color-primary);
}
::v-deep(table) {
:deep(table) {
width: 100%;
margin: 12px 0;
border-collapse: collapse;
@@ -384,7 +384,7 @@ defineExpose({
}
.menu-item.is-disabled i:not(.submenu-arrow),
.menu-item.is-disabled ::v-deep(.fa-svg-icon) {
.menu-item.is-disabled :deep(.fa-svg-icon) {
color: var(--el-text-color-disabled) !important;
}
@@ -124,7 +124,7 @@
</template>
</ElTable>
<!-- 分页 -->
<pagination
<FaPagination
v-model:total="total"
v-model:page="queryParams.page_no"
v-model:limit="queryParams.page_size"
@@ -153,6 +153,7 @@ defineSlots<{
import { ref, reactive, computed } from "vue";
import { useResizeObserver } from "@vueuse/core";
import type { FormInstance, PopoverProps, TableInstance } from "element-plus";
import FaPagination from "@/components/others/fa-pagination/index.vue";
// 对象类型
export type IObject = Record<string, any>;
@@ -264,21 +265,19 @@ function handleQuery() {
}
// 获取分页数据
function fetchPageData(isRestart = false) {
async function fetchPageData(isRestart = false) {
loading.value = true;
if (isRestart) {
queryParams.page_no = 1;
queryParams.page_size = page_size;
}
props.selectConfig
.indexAction(queryParams)
.then((data) => {
total.value = data.total;
pageData.value = data.list;
})
.finally(() => {
loading.value = false;
});
try {
const data = await props.selectConfig.indexAction(queryParams);
total.value = data.total;
pageData.value = data.list;
} finally {
loading.value = false;
}
}
// 列表操作
@@ -352,8 +351,8 @@ const popoverContentRef = ref();
</script>
<style scoped lang="scss">
.reference ::v-deep(.el-input__wrapper),
.reference ::v-deep(.el-input__inner) {
.reference :deep(.el-input__wrapper),
.reference :deep(.el-input__inner) {
cursor: pointer;
}
@@ -363,7 +362,7 @@ const popoverContentRef = ref();
margin-top: 6px;
}
// 隐藏全选按钮
.radio ::v-deep(.el-table__header th.el-table__cell:nth-child(1) .el-checkbox) {
.radio :deep(.el-table__header th.el-table__cell:nth-child(1) .el-checkbox) {
visibility: hidden;
}
</style>
@@ -464,7 +464,7 @@ const onError = (error: any) => {
}
}
::v-deep(.el-upload--picture-card) {
:deep(.el-upload--picture-card) {
position: relative;
width: v-bind("props.style.width ?? '150px'");
height: v-bind("props.style.height ?? '150px'");
@@ -4,7 +4,7 @@
- 暴露 `elTableRef`、`scrollToTop`;formatter 列见 TableFormatterOutlet 注释。
-->
<template>
<div class="fa-table">
<div class="fa-table" :class="{ 'is-empty': isEmpty }">
<div class="fa-table__main">
<VueDraggable
class="fa-table__drag-wrap"
@@ -270,7 +270,7 @@ useResizeObserver(tableHeaderRef, (entries) => {
// 分页器与表格之间的间距常量(计算属性,响应 showTableHeader 变化)
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15));
// 使用表格高度计算 Hook
// 使用表格高度计算 Hook(返回含分页、表头偏移的精确高度)
useTableHeight({
showTableHeader: computed(() => props.showTableHeader),
paginationHeight,
@@ -286,7 +286,7 @@ const height = computed(() => {
if (isEmpty.value && !props.loading) return props.emptyHeight;
// 使用传入的高度
if (props.height) return props.height;
// 默认占满容器高度
// flex 布局下 .fa-table__main 已扣除分页空间,ElTable 用 100% 填满即可
return "100%";
});
@@ -345,7 +345,7 @@ const onRowDragEnd = () => {
};
// 是否显示分页器
const showPagination = computed(() => props.pagination && !isEmpty.value);
const showPagination = computed(() => !!props.pagination);
// Element Plus 在部分场景会先用 $index = -1 进行预渲染。
// 这对普通展示无影响,但会让 ElForm 错误注册出 lineList.-1.xxx 这类字段。
@@ -1,10 +1,24 @@
.fa-table {
position: relative;
height: 100%;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
.fa-table__main {
flex: 1;
min-height: 0;
overflow: hidden;
padding-top: 10px;
// VueDraggable 透传高度,确保 ElTable height: 100% 能正确解析
> * {
height: 100%;
}
}
.el-table {
height: 100%;
margin-top: 10px;
}
:deep(.el-loading-mask) {
@@ -23,14 +37,17 @@
// 空状态垂直居中
&.is-empty {
:deep(.el-scrollbar__wrap) {
:deep(.el-table__body-wrapper) {
display: flex;
align-items: center;
justify-content: center;
}
}
.pagination {
display: flex;
margin-top: 13px;
flex-shrink: 0;
padding-top: 13px;
:deep(.el-select) {
width: 102px !important;
@@ -88,7 +88,7 @@ watchEffect(() => {
width: 100%;
height: 100%;
::v-deep(svg) {
:deep(svg) {
width: 100%;
height: 100%;
}
@@ -256,7 +256,7 @@ const changeThemeColor = (color: string) => {
transform: translateY(-2px);
}
.auth-top-bar__action:hover ::v-deep(.fa-svg-icon) {
.auth-top-bar__action:hover :deep(.fa-svg-icon) {
color: var(--el-color-primary);
}
@@ -314,15 +314,15 @@ const changeThemeColor = (color: string) => {
}
/* 调色盘:图标颜色与当前主题主色一致(含单独悬浮、整块调色区悬浮) */
.palette-btn ::v-deep(.fa-svg-icon) {
.palette-btn :deep(.fa-svg-icon) {
color: v-bind("themeColorForCss");
}
.auth-top-bar__action.palette-btn:hover ::v-deep(.fa-svg-icon) {
.auth-top-bar__action.palette-btn:hover :deep(.fa-svg-icon) {
color: v-bind("themeColorForCss");
}
.color-picker-expandable:hover .palette-btn ::v-deep(.fa-svg-icon) {
.color-picker-expandable:hover .palette-btn :deep(.fa-svg-icon) {
color: v-bind("themeColorForCss");
}
</style>