+
-
-
+
-
-
- {{ route.meta.title }}
+
-
-
+
+
+
+
+
+
+
diff --git a/src/router/index.js b/src/router/index.js
index 71e75c6..e0ce832 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
+import { hasPermission } from '@/utils/permissions'
const routes = [
{
@@ -17,7 +18,22 @@ const routes = [
{ path: 'modules', name: 'ModuleManage', component: () => import('@/views/modules/ModuleManage.vue'), meta: { title: '模块管理' } },
{ path: 'levels', name: 'LevelManage', component: () => import('@/views/levels/LevelManage.vue'), meta: { title: '等级管理' } },
{ path: 'users', name: 'UserList', component: () => import('@/views/users/UserList.vue'), meta: { title: '用户管理' } },
+ // 订单管理 + 退款:查看挂 order:read(只读角色可见),退款动作挂 order:write
+ { path: 'orders', name: 'OrderList', component: () => import('@/views/orders/OrderList.vue'), meta: { title: '订单管理', perm: 'order:read' } },
{ path: 'feedbacks', name: 'FeedbackList', component: () => import('@/views/feedbacks/FeedbackList.vue'), meta: { title: '反馈管理' } },
+ // 优惠(N6 承前):优惠码 / 券模板 / 发券记录,挂 quota:write 权限点(与后端 admin_perm.go 对齐)
+ { path: 'promo/list', name: 'PromoList', component: () => import('@/views/promo/PromoList.vue'), meta: { title: '优惠码', group: '优惠', perm: 'quota:write' } },
+ { path: 'coupon/template', name: 'CouponTemplate', component: () => import('@/views/coupon/CouponTemplate.vue'), meta: { title: '券模板', group: '优惠', perm: 'quota:write' } },
+ { path: 'coupon/record', name: 'CouponRecord', component: () => import('@/views/coupon/CouponRecord.vue'), meta: { title: '发券记录', group: '优惠', perm: 'quota:write' } },
+ // 年会座次(T11):group 用于面包屑 / 菜单父级分组
+ { path: 'event/enterprises', name: 'EventEnterpriseManage', component: () => import('@/views/event/EnterpriseManage.vue'), meta: { title: '企业管理', group: '年会座次' } },
+ { path: 'event/list', name: 'EventList', component: () => import('@/views/event/EventList.vue'), meta: { title: '活动管理', group: '年会座次' } },
+ { path: 'event/designer', name: 'EventDesigner', component: () => import('@/views/event/Designer.vue'), meta: { title: '座次设计器', group: '年会座次' } },
+ { path: 'event/import', name: 'EventImport', component: () => import('@/views/event/ImportPreview.vue'), meta: { title: '员工导入', group: '年会座次' } },
+ { path: 'event/query-log', name: 'EventQueryLog', component: () => import('@/views/event/QueryLog.vue'), meta: { title: '查座审计', group: '年会座次' } },
+ // 系统(T17):管理员与操作审计,按角色权限隐藏入口
+ { path: 'admins', name: 'AdminManage', component: () => import('@/views/admins/AdminManage.vue'), meta: { title: '管理员', group: '系统', perm: 'admin:manage' } },
+ { path: 'audit', name: 'AuditLog', component: () => import('@/views/audit/AuditLog.vue'), meta: { title: '操作审计', group: '系统', perm: 'audit:read' } },
],
},
]
@@ -38,6 +54,11 @@ router.beforeEach((to) => {
if (to.name === 'Login' && userStore.isAuthenticated) {
return { path: '/' }
}
+ // 前端权限:无权访问的路由回落到看板(服务端 AdminPerm 仍会二次校验)
+ const perm = to.meta.perm
+ if (perm && !hasPermission(userStore.user?.role_value, perm)) {
+ return { path: '/dashboard' }
+ }
})
export default router
diff --git a/src/style.css b/src/style.css
index 187b126..78d9c4e 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,17 +1,20 @@
@import "tailwindcss";
+/* 设计 Token 层(T11 引入):所有新增页面/外壳统一引用 var(--xx) */
+@import "./style/tokens.css";
/* 「橘子汽水」全局底色:避免 layout 之外露出默认深色 */
html,
body,
#app {
min-height: 100%;
- background-color: #fff9f2;
+ background-color: var(--bg-page);
}
body {
margin: 0;
- color: #3d2a1e;
+ color: var(--text-1);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'MiSans', 'HarmonyOS Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
+ font-size: var(--font-md);
}
diff --git a/src/style/tokens.css b/src/style/tokens.css
new file mode 100644
index 0000000..079b54d
--- /dev/null
+++ b/src/style/tokens.css
@@ -0,0 +1,455 @@
+/* ============================================================================
+ * 设计 Token 层(迭代-2026-09-18 · T11)
+ * 目的:把「主色 / 中性色阶 / 圆角 / 阴影 / 间距 / 字号」收敛为 CSS 变量,
+ * 后续整体换肤只需改本文件,不必逐页改样式。
+ * 约束:新建页面(views/event/*)与 AdminLayout.vue 一律引用 var(--xx),
+ * 禁止硬编码色值 / 圆角 / 阴影。
+ * 说明:变量前缀 --brand/--text/--border/--bg/--radius/--shadow/--space/--font。
+ * ========================================================================== */
+
+:root {
+ /* ===== 品牌主色阶(「橘子汽水」)===== */
+ --brand-50: #fff6f0;
+ --brand-100: #fff1e8;
+ --brand-200: #ffd9c2;
+ --brand-300: #ffb98c;
+ --brand-400: #ff9f4d;
+ --brand-500: #fb6a1e;
+ --brand-600: #fb4e14;
+ --brand-700: #e8531a;
+ --brand-800: #c43f10;
+
+ /* ===== 语义色 ===== */
+ --color-primary: var(--brand-600);
+ --color-primary-hover: var(--brand-700);
+ --color-primary-active: var(--brand-800);
+ --color-primary-soft: var(--brand-100);
+ --color-primary-weak: var(--brand-50);
+
+ --color-success: #16a34a;
+ --color-success-soft: #e7f8ee;
+ --color-warning: #d97706;
+ --color-warning-soft: #fef3e2;
+ --color-danger: #dc2626;
+ --color-danger-soft: #fdecec;
+ --color-info: #2563eb;
+ --color-info-soft: #e8f0fe;
+
+ /* ===== 中性色阶 ===== */
+ --text-1: #2b1a10; /* 一级:标题 / 主文字 */
+ --text-2: #6b5647; /* 二级:正文 / 次级 */
+ --text-3: #9c8a7c; /* 三级:辅助 / 占位 */
+ --text-4: #c2b4a8; /* 四级:禁用 */
+ --text-inverse: #ffffff;
+
+ --border-1: #f0e2d5; /* 常规边框 */
+ --border-2: #e4d2c2; /* 强边框 / 输入框 */
+ --divider: #f5eae0; /* 分割线 */
+
+ --bg-page: #fff9f2; /* 页面底色 */
+ --bg-surface: #ffffff; /* 卡片 / 容器 */
+ --bg-surface-2: #fffbf7; /* 次级面(表头 / 底栏) */
+ --bg-hover: #fff4ea; /* 悬停底 */
+ --bg-mask: rgba(43, 26, 16, 0.45); /* 遮罩 */
+
+ /* ===== 圆角 ===== */
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 16px;
+ --radius-pill: 999px;
+
+ /* ===== 阴影(1 静置 / 2 悬浮 / 3 浮层)===== */
+ --shadow-1: 0 1px 2px rgba(80, 40, 10, 0.05);
+ --shadow-2: 0 4px 12px rgba(80, 40, 10, 0.08);
+ --shadow-3: 0 10px 30px rgba(80, 40, 10, 0.14);
+
+ /* ===== 间距刻度 ===== */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 20px;
+ --space-6: 24px;
+ --space-8: 32px;
+ --space-10: 40px;
+
+ /* ===== 字号阶梯 ===== */
+ --font-xs: 12px;
+ --font-sm: 13px;
+ --font-md: 14px;
+ --font-lg: 16px;
+ --font-xl: 20px;
+ --font-2xl: 24px;
+ --font-3xl: 30px;
+
+ /* ===== 布局尺度 ===== */
+ --layout-sider-width: 216px;
+ --layout-sider-collapsed: 64px;
+ --layout-header-height: 56px;
+ --layout-content-max: 1440px;
+ --layout-radius: var(--radius-lg);
+}
+
+/* ============================================================================
+ * 应用级语义类(全部引用 token,禁止硬编码)
+ * ========================================================================== */
+
+.app-page {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.app-page__head {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--space-4);
+ flex-wrap: wrap;
+}
+
+.app-page__title {
+ margin: 0;
+ font-size: var(--font-xl);
+ font-weight: 700;
+ color: var(--text-1);
+ letter-spacing: -0.01em;
+}
+
+.app-page__desc {
+ margin-top: var(--space-1);
+ font-size: var(--font-sm);
+ color: var(--text-3);
+}
+
+.app-toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.app-toolbar__grow {
+ flex: 1 1 auto;
+}
+
+.app-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-1);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-1);
+}
+
+.app-card--pad {
+ padding: var(--space-5);
+}
+
+.app-empty,
+.app-error {
+ padding: var(--space-10) var(--space-4);
+ text-align: center;
+ color: var(--text-3);
+}
+
+.app-error {
+ color: var(--color-danger);
+}
+
+.app-muted {
+ color: var(--text-3);
+}
+
+.app-mono {
+ font-family: 'SFMono-Regular', ui-monospace, Menlo, Consolas, monospace;
+}
+
+/* 行内操作按钮层级:主操作用文字链接,危险操作走 danger */
+.app-link-danger {
+ color: var(--color-danger);
+}
+
+/* ============================================================================
+ * 作用域内的 AntD 主题对齐(仅 .app-page / .app-shell 内生效,
+ * 不影响现有 dashboard/tools/modules/levels/users/feedbacks 等页面)
+ * ========================================================================== */
+
+.app-page .ant-btn-primary,
+.app-shell .ant-btn-primary {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+ box-shadow: none;
+}
+
+.app-page .ant-btn-primary:not(:disabled):hover,
+.app-shell .ant-btn-primary:not(:disabled):hover {
+ background: var(--color-primary-hover);
+ border-color: var(--color-primary-hover);
+}
+
+.app-page .ant-btn-primary:not(:disabled):active,
+.app-shell .ant-btn-primary:not(:disabled):active {
+ background: var(--color-primary-active);
+ border-color: var(--color-primary-active);
+}
+
+.app-page .ant-table-thead > tr > th,
+.app-shell .ant-table-thead > tr > th {
+ background: var(--bg-surface-2);
+ color: var(--text-2);
+ font-weight: 600;
+}
+
+.app-page .ant-table-tbody > tr.ant-table-row:hover > td,
+.app-shell .ant-table-tbody > tr.ant-table-row:hover > td {
+ background: var(--bg-hover);
+}
+
+.app-page .ant-pagination-item-active,
+.app-shell .ant-pagination-item-active {
+ border-color: var(--color-primary);
+}
+
+.app-page .ant-pagination-item-active a,
+.app-shell .ant-pagination-item-active a {
+ color: var(--color-primary);
+}
+
+.app-page .ant-switch.ant-switch-checked,
+.app-shell .ant-switch.ant-switch-checked {
+ background: var(--color-primary);
+}
+
+/* 画布网格底:设计器画布使用 */
+.app-canvas-grid {
+ background-color: var(--bg-surface);
+ background-image:
+ linear-gradient(to right, var(--divider) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--divider) 1px, transparent 1px);
+ background-size: 40px 40px;
+}
+
+/* ============================================================================
+ * 毛玻璃设计语言(T14:延续 C 端暖色 + Apple Glass)
+ * 与上方 token 同源;所有玻璃质感统一引用这些变量,禁止硬编码。
+ * ========================================================================== */
+:root {
+ --glass-bg: rgba(255, 255, 255, 0.62);
+ --glass-bg-strong: rgba(255, 255, 255, 0.8);
+ --glass-bg-soft: rgba(255, 255, 255, 0.42);
+ --glass-border: rgba(255, 255, 255, 0.6);
+ --glass-border-soft: rgba(255, 255, 255, 0.4);
+ --glass-blur: 18px;
+ --glass-blur-sm: 10px;
+ --glass-saturate: 1.7;
+ --glass-shadow: 0 8px 32px rgba(107, 47, 15, 0.1);
+ --glass-shadow-hover: 0 14px 40px rgba(107, 47, 15, 0.16);
+ --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.7);
+ --bg-blob-1: rgba(255, 159, 77, 0.38);
+ --bg-blob-2: rgba(62, 207, 178, 0.24);
+ --bg-blob-3: rgba(255, 123, 169, 0.22);
+}
+
+/* 玻璃表面:无 backdrop-filter 时回落为不透明面(可读性优先) */
+.glass {
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ box-shadow: var(--glass-shadow), var(--glass-highlight);
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+}
+
+.glass-strong {
+ background: var(--glass-bg-strong);
+}
+
+@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
+ .glass,
+ .app-card {
+ background: var(--bg-surface);
+ }
+}
+
+/* 升级 .app-card 为玻璃卡(T11 已建页面自动受益;圆角/边框/阴影仍走 token) */
+.app-card {
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ box-shadow: var(--glass-shadow), var(--glass-highlight);
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+}
+
+/* 暖色光斑背景(供外壳与登录页复用;纯装饰,pointer-events: none) */
+.app-bg-blobs {
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ overflow: hidden;
+ pointer-events: none;
+}
+
+.app-bg-blobs::before,
+.app-bg-blobs::after {
+ content: '';
+ position: absolute;
+ border-radius: 50%;
+ filter: blur(60px);
+}
+
+.app-bg-blobs::before {
+ width: 520px;
+ height: 520px;
+ top: -120px;
+ left: -80px;
+ background: var(--bg-blob-1);
+}
+
+.app-bg-blobs::after {
+ width: 460px;
+ height: 460px;
+ right: -120px;
+ bottom: -140px;
+ background: var(--bg-blob-2);
+}
+
+.app-bg-blobs__mid {
+ position: absolute;
+ width: 360px;
+ height: 360px;
+ top: 40%;
+ left: 46%;
+ border-radius: 50%;
+ filter: blur(70px);
+ background: var(--bg-blob-3);
+}
+
+/* ============================================================================
+ * AppModal 统一外观(组件 src/components/AppModal.vue + 命令式 appConfirm/appWarning)
+ * ========================================================================== */
+.app-modal-wrap .ant-modal-content {
+ background: var(--glass-bg-strong);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--glass-shadow), var(--glass-highlight);
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+}
+
+.app-modal-wrap .ant-modal-header {
+ background: transparent;
+ border-bottom: 1px solid var(--divider);
+ margin-bottom: var(--space-4);
+ padding-bottom: var(--space-3);
+}
+
+.app-modal-wrap .ant-modal-title {
+ color: var(--text-1);
+ font-weight: 700;
+ font-size: var(--font-lg);
+}
+
+.app-modal-wrap .ant-modal-body {
+ color: var(--text-2);
+}
+
+.app-modal-wrap .ant-modal-footer .ant-btn-primary {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+ box-shadow: none;
+}
+
+.app-modal-wrap .ant-modal-footer .ant-btn-primary:not(:disabled):hover {
+ background: var(--color-primary-hover);
+ border-color: var(--color-primary-hover);
+}
+
+.app-modal-wrap .ant-modal-close {
+ color: var(--text-3);
+}
+
+.app-modal-wrap .ant-modal-close:hover {
+ color: var(--color-primary);
+}
+
+@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
+ .app-modal-wrap .ant-modal-content {
+ background: var(--bg-surface);
+ }
+}
+
+/* ============================================================================
+ * AppDrawer 统一外观(T15c:组件 src/components/AppDrawer.vue)
+ * 注意:antd v4 的 a-drawer 没有 wrapClassName 属性,自定义类必须用 rootClassName——
+ * 它落在根节点 .ant-drawer 上,与方向类 .ant-drawer-{placement} 同层(同元素),
+ * 参考 node_modules/ant-design-vue/es/vc-drawer/src/DrawerChild.js。
+ * 选择器统一带上 .ant-drawer 提高权重,稳定压过 antd 运行时注入的样式。
+ * ========================================================================== */
+.app-drawer-wrap.ant-drawer .ant-drawer-content {
+ background: var(--glass-bg-strong);
+ border: 1px solid var(--glass-border);
+ box-shadow: var(--glass-shadow), var(--glass-highlight);
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
+}
+
+/* 抽屉是贴边的全高面板:只圆「朝内」的两个角,并去掉贴边那一侧的边框,
+ 避免在屏幕边缘露出圆角缝隙 / 边框线(方向类与根类同层,用复合选择器保证权重)。 */
+.app-drawer-wrap.ant-drawer-right .ant-drawer-content {
+ border-radius: var(--radius-lg) 0 0 var(--radius-lg);
+ border-right-width: 0;
+}
+
+.app-drawer-wrap.ant-drawer-left .ant-drawer-content {
+ border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
+ border-left-width: 0;
+}
+
+.app-drawer-wrap.ant-drawer-top .ant-drawer-content {
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
+ border-top-width: 0;
+}
+
+.app-drawer-wrap.ant-drawer-bottom .ant-drawer-content {
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
+ border-bottom-width: 0;
+}
+
+/* 玻璃面阴影统一走「外投影 --glass-shadow + 内高光 --glass-highlight」两条,
+ 与 .glass / .app-card 基线同形;挂在 .ant-drawer-content 上,与 .ant-modal-wrap 同源。
+ 去掉 antd 默认挂在 content-wrapper 上的方向外阴影:它和上面这层玻璃外投影会叠成
+ 「两层外投影」,故置 none(注意:这跟设计语言里的内高光是两回事)。 */
+.app-drawer-wrap.ant-drawer .ant-drawer-content-wrapper {
+ box-shadow: none;
+}
+
+.app-drawer-wrap.ant-drawer .ant-drawer-header {
+ background: transparent;
+ border-bottom: 1px solid var(--divider);
+}
+
+.app-drawer-wrap.ant-drawer .ant-drawer-title {
+ color: var(--text-1);
+ font-weight: 700;
+ font-size: var(--font-lg);
+}
+
+.app-drawer-wrap.ant-drawer .ant-drawer-close {
+ color: var(--text-3);
+}
+
+.app-drawer-wrap.ant-drawer .ant-drawer-close:hover {
+ color: var(--color-primary);
+}
+
+/* 内容区内滚由 .app-drawer__body 负责,去掉 antd 默认内边距避免双padding */
+.app-drawer-wrap.ant-drawer .ant-drawer-body {
+ padding: 0;
+ overflow: hidden;
+}
+
+@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
+ .app-drawer-wrap.ant-drawer .ant-drawer-content {
+ background: var(--bg-surface);
+ }
+}
+
+
diff --git a/src/utils/employee-template.js b/src/utils/employee-template.js
new file mode 100644
index 0000000..e70292f
--- /dev/null
+++ b/src/utils/employee-template.js
@@ -0,0 +1,125 @@
+// ============================================================================
+// 员工导入模板生成(T03:需求 3)
+//
+// 关键点:
+// 1) 用 exceljs **动态 import()** —— 仅点击「下载模板」时才加载(exceljs gzip 前约 +900KB),
+// Vite 自动拆分为独立 chunk,不进首屏(架构 §2.2 D-8)。
+// 2) 表头必须与后端解析列**逐字一致**:姓名 / 手机号 / 部门 / 备注。
+// 依据:internal/logic/event_import.go 的 importRow{ Name, Phone, Dept, Remark }。
+// 3) 手机号列必须为**文本格式**(numFmt='@'),否则 Excel 会把手机号显示为科学计数法。
+// 4) 含 1 行示例数据(浅灰斜体,备注列写「示例行,导入前请删除」)。
+// 5) 「填写说明」Sheet 复述后端分类规则(含「手机号必填且需通过校验」)。
+// ============================================================================
+
+const HEADERS = ['姓名', '手机号', '部门', '备注']
+const COL_WIDTHS = [14, 18, 16, 28]
+const PHONE_COL_INDEX = 2 // 1-based:手机号是第 2 列
+
+/** 生成 yyyyMMdd,用于文件名。 */
+function stampYmd(d = new Date()) {
+ const p = (n) => String(n).padStart(2, '0')
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
+}
+
+/** 触发浏览器下载。 */
+function downloadBlob(blob, filename) {
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+}
+
+/**
+ * 下载「员工导入模板」。动态加载 exceljs,生成两个 Sheet 后触发下载。
+ * @returns {Promise
} 实际下载的文件名
+ */
+export async function downloadEmployeeTemplate() {
+ // 动态导入:不进首屏主包
+ const mod = await import('exceljs')
+ const ExcelJS = mod.default || mod
+
+ const wb = new ExcelJS.Workbook()
+ wb.creator = '工具百宝箱'
+ wb.created = new Date()
+
+ // ===== Sheet 1:员工 =====
+ const ws = wb.addWorksheet('员工', {
+ views: [{ state: 'frozen', ySplit: 1 }], // 冻结首行
+ })
+ ws.columns = [
+ { header: HEADERS[0], key: 'name', width: COL_WIDTHS[0] },
+ { header: HEADERS[1], key: 'phone', width: COL_WIDTHS[1] },
+ { header: HEADERS[2], key: 'dept', width: COL_WIDTHS[2] },
+ { header: HEADERS[3], key: 'remark', width: COL_WIDTHS[3] },
+ ]
+
+ // 表头样式:加粗 + 底色 #FF6B35 + 白字 + 居中
+ const headerRow = ws.getRow(1)
+ headerRow.height = 22
+ headerRow.eachCell((cell) => {
+ cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF6B35' } }
+ cell.alignment = { vertical: 'middle', horizontal: 'center' }
+ cell.border = {
+ top: { style: 'thin', color: { argb: 'FFFF6B35' } },
+ left: { style: 'thin', color: { argb: 'FFFF6B35' } },
+ bottom: { style: 'thin', color: { argb: 'FFFF6B35' } },
+ right: { style: 'thin', color: { argb: 'FFFF6B35' } },
+ }
+ })
+
+ // 手机号整列文本格式(防止科学计数法 / 前导 0 丢失)
+ ws.getColumn(PHONE_COL_INDEX).numFmt = '@'
+
+ // 1 行示例数据(浅灰斜体)
+ const example = ws.addRow({
+ name: '张三',
+ phone: '13800000000',
+ dept: '市场部',
+ remark: '示例行,导入前请删除',
+ })
+ example.eachCell((cell) => {
+ cell.font = { italic: true, color: { argb: 'FF9CA3AF' } }
+ })
+ example.getCell(PHONE_COL_INDEX).numFmt = '@'
+
+ // ===== Sheet 2:填写说明 =====
+ const info = wb.addWorksheet('填写说明')
+ info.getColumn(1).width = 96
+ const lines = [
+ '员工导入模板 · 填写说明',
+ '',
+ '【表头】请勿修改:姓名 / 手机号 / 部门 / 备注',
+ '',
+ '【填写要求】',
+ '1. 手机号:必填,且需通过校验(数字,6~20 位,如 13800000000)。手机号是查座与去重的唯一标识。',
+ '2. 姓名:必填,1~64 字。',
+ '3. 部门、备注:选填。',
+ '4. 第 2 行是示例数据(浅灰斜体),导入前请删除。',
+ '',
+ '【导入分类规则(后端按手机号 / 姓名判定)】',
+ '· 手机号缺失或格式非法 → 异常行,将被跳过;',
+ '· 手机号命中现有员工 → 更新该员工(姓名 / 部门 / 备注);',
+ '· 姓名命中但手机号不同 → 冲突,需在导入预览中逐条裁决(合并 / 新建 / 忽略);',
+ '· 其余 → 新增。',
+ ]
+ lines.forEach((text, i) => {
+ const row = info.getRow(i + 1)
+ const cell = row.getCell(1)
+ cell.value = text
+ cell.alignment = { vertical: 'middle', wrapText: false }
+ if (i === 0) cell.font = { bold: true, size: 13 }
+ })
+
+ const buf = await wb.xlsx.writeBuffer()
+ const filename = `员工导入模板-${stampYmd()}.xlsx`
+ downloadBlob(
+ new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
+ filename,
+ )
+ return filename
+}
diff --git a/src/utils/export.js b/src/utils/export.js
new file mode 100644
index 0000000..8b05b56
--- /dev/null
+++ b/src/utils/export.js
@@ -0,0 +1,63 @@
+import * as XLSX from 'xlsx'
+
+/**
+ * 通用表格导出(前端导出,复用已引入的 xlsx,零新增依赖)。
+ *
+ * @param {Object} opts
+ * @param {Array<{title:string, key:string, dataIndex?:string}>} opts.columns 列定义
+ * (title 作表头,dataIndex 优先取值,缺省回退 key)
+ * @param {Array