From 1fa62d10e13d75b6349bbe455cb4c407e4701fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Tue, 29 Sep 2026 10:58:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 55 +- package.json | 4 +- pnpm-lock.yaml | 676 +++++++++ src/api/request.js | 8 + src/api/request/admin.js | 31 + src/api/request/coupon.js | 72 + src/api/request/event.js | 155 ++ src/api/request/order.js | 33 + src/api/request/promo.js | 56 + src/api/request/tool.js | 9 + src/api/request/user.js | 19 + src/components/AppDrawer.vue | 86 ++ src/components/AppModal.vue | 76 + src/components/GlassCard.vue | 45 + src/components/PageShell.vue | 23 + src/components/StateBlock.vue | 46 + src/components/TrendChart.vue | 23 +- src/components/user/GrantCouponModal.vue | 128 ++ src/components/user/LevelModal.vue | 167 +++ src/components/user/UserDetailDrawer.vue | 183 +++ src/layouts/AdminLayout.vue | 248 +++- src/router/index.js | 21 + src/style.css | 7 +- src/style/tokens.css | 455 ++++++ src/utils/employee-template.js | 125 ++ src/utils/export.js | 63 + src/utils/permissions.js | 57 + src/views/Login.vue | 140 +- src/views/admins/AdminManage.vue | 154 ++ src/views/audit/AuditLog.vue | 247 ++++ src/views/coupon/CouponRecord.vue | 223 +++ src/views/coupon/CouponTemplate.vue | 361 +++++ src/views/dashboard/Dashboard.vue | 273 +++- src/views/event/Designer.vue | 1689 ++++++++++++++++++++++ src/views/event/EnterpriseManage.vue | 584 ++++++++ src/views/event/EventList.vue | 321 ++++ src/views/event/ImportPreview.vue | 509 +++++++ src/views/event/QueryLog.vue | 140 ++ src/views/feedbacks/FeedbackList.vue | 97 +- src/views/levels/LevelManage.vue | 191 ++- src/views/modules/ModuleManage.vue | 172 ++- src/views/orders/OrderList.vue | 265 ++++ src/views/promo/PromoList.vue | 414 ++++++ src/views/tools/ToolManage.vue | 517 +++++-- src/views/users/UserList.vue | 687 ++++++++- vite.config.js | 7 + 47 files changed, 9455 insertions(+), 408 deletions(-) create mode 100644 src/api/request/admin.js create mode 100644 src/api/request/coupon.js create mode 100644 src/api/request/event.js create mode 100644 src/api/request/order.js create mode 100644 src/api/request/promo.js create mode 100644 src/components/AppDrawer.vue create mode 100644 src/components/AppModal.vue create mode 100644 src/components/GlassCard.vue create mode 100644 src/components/PageShell.vue create mode 100644 src/components/StateBlock.vue create mode 100644 src/components/user/GrantCouponModal.vue create mode 100644 src/components/user/LevelModal.vue create mode 100644 src/components/user/UserDetailDrawer.vue create mode 100644 src/style/tokens.css create mode 100644 src/utils/employee-template.js create mode 100644 src/utils/export.js create mode 100644 src/utils/permissions.js create mode 100644 src/views/admins/AdminManage.vue create mode 100644 src/views/audit/AuditLog.vue create mode 100644 src/views/coupon/CouponRecord.vue create mode 100644 src/views/coupon/CouponTemplate.vue create mode 100644 src/views/event/Designer.vue create mode 100644 src/views/event/EnterpriseManage.vue create mode 100644 src/views/event/EventList.vue create mode 100644 src/views/event/ImportPreview.vue create mode 100644 src/views/event/QueryLog.vue create mode 100644 src/views/orders/OrderList.vue create mode 100644 src/views/promo/PromoList.vue diff --git a/.gitignore b/.gitignore index b3e8174..95cb1fd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +dist_verify *.local # Editor directories and files diff --git a/README.md b/README.md index 1511959..bfce085 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,54 @@ -# Vue 3 + Vite +# 工具百宝箱 · 管理后台(qitongxue-admin) -This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + + + diff --git a/src/components/AppModal.vue b/src/components/AppModal.vue new file mode 100644 index 0000000..d45aa0f --- /dev/null +++ b/src/components/AppModal.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/components/GlassCard.vue b/src/components/GlassCard.vue new file mode 100644 index 0000000..c491814 --- /dev/null +++ b/src/components/GlassCard.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/components/PageShell.vue b/src/components/PageShell.vue new file mode 100644 index 0000000..e8c1960 --- /dev/null +++ b/src/components/PageShell.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/StateBlock.vue b/src/components/StateBlock.vue new file mode 100644 index 0000000..f665fdc --- /dev/null +++ b/src/components/StateBlock.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/components/TrendChart.vue b/src/components/TrendChart.vue index 15cfbc8..27da0e1 100644 --- a/src/components/TrendChart.vue +++ b/src/components/TrendChart.vue @@ -3,11 +3,12 @@ defineProps({ points: { type: Array, default: () => [] }, // [{date:'2026-08-29', count:0}] color: { type: String, default: '#FF6B35' }, + /** + * 数值展示格式化(如收入按「分 → 元」换算)。返回字符串。 + * 缺省时直接展示原值(用于「次数」类趋势)。 + */ + valueFormatter: { type: Function, default: null }, }) - -function scaleY() { - return 1 -} diff --git a/src/components/user/GrantCouponModal.vue b/src/components/user/GrantCouponModal.vue new file mode 100644 index 0000000..b9c3887 --- /dev/null +++ b/src/components/user/GrantCouponModal.vue @@ -0,0 +1,128 @@ + + + diff --git a/src/components/user/LevelModal.vue b/src/components/user/LevelModal.vue new file mode 100644 index 0000000..0b76de9 --- /dev/null +++ b/src/components/user/LevelModal.vue @@ -0,0 +1,167 @@ + + + diff --git a/src/components/user/UserDetailDrawer.vue b/src/components/user/UserDetailDrawer.vue new file mode 100644 index 0000000..80a3608 --- /dev/null +++ b/src/components/user/UserDetailDrawer.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/src/layouts/AdminLayout.vue b/src/layouts/AdminLayout.vue index 1025c07..8e81b3e 100644 --- a/src/layouts/AdminLayout.vue +++ b/src/layouts/AdminLayout.vue @@ -1,26 +1,96 @@ + + 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} opts.rows 数据行 + * @param {string} [opts.filename='export'] 文件名(不含扩展名与时间戳) + * @param {'xlsx'|'csv'} [opts.format='xlsx'] 导出格式 + * @param {Recordany>} [opts.formatter] 按列 key 定制格式化 + */ +export function exportRows({ columns, rows, filename = 'export', format = 'xlsx', formatter = {} }) { + const keyOf = (c) => c.dataIndex || c.key + const header = columns.map((c) => c.title) + const aoa = [header] + + for (const row of rows || []) { + aoa.push( + columns.map((c) => { + const key = c.key || keyOf(c) + const raw = row[keyOf(c)] + if (typeof formatter[key] === 'function') return formatter[key](raw, row) + return raw === null || raw === undefined ? '' : raw + }), + ) + } + + const ws = XLSX.utils.aoa_to_sheet(aoa) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') + + const stamp = formatTimestamp(new Date()) + if (format === 'csv') { + const csv = XLSX.utils.sheet_to_csv(ws) + // BOM 保证 Excel 打开中文不乱码 + downloadBlob(new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }), `${filename}_${stamp}.csv`) + return + } + XLSX.writeFile(wb, `${filename}_${stamp}.xlsx`) +} + +/** 生成 yyyyMMdd_HHmmss 时间戳,便于文件名区分。 */ +export function formatTimestamp(d) { + const p = (n) => String(n).padStart(2, '0') + return ( + `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_` + + `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}` + ) +} + +/** 触发浏览器下载。 */ +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) +} diff --git a/src/utils/permissions.js b/src/utils/permissions.js new file mode 100644 index 0000000..f708f10 --- /dev/null +++ b/src/utils/permissions.js @@ -0,0 +1,57 @@ +/** + * 前端权限镜像(T17)。 + * + * 仅用于「隐藏无权入口」的体验优化——真正的拦截在服务端 AdminPerm 中间件。 + * 这里的角色→权限点映射必须与后端 internal/logic/admin_perm.go 保持一致。 + */ +export const PERM = { + USER_WRITE: 'user:write', + TOOL_WRITE: 'tool:write', + MODULE_WRITE: 'module:write', + LEVEL_WRITE: 'level:write', + FEEDBACK_WRITE: 'feedback:write', + QUOTA_WRITE: 'quota:write', + EVENT_WRITE: 'event:write', + ADMIN_MANAGE: 'admin:manage', + AUDIT_READ: 'audit:read', + ORDER_READ: 'order:read', + ORDER_WRITE: 'order:write', +} + +const ROLE_PERMISSIONS = { + super: [ + PERM.USER_WRITE, PERM.TOOL_WRITE, PERM.MODULE_WRITE, PERM.LEVEL_WRITE, + PERM.FEEDBACK_WRITE, PERM.QUOTA_WRITE, PERM.EVENT_WRITE, + PERM.ADMIN_MANAGE, PERM.AUDIT_READ, PERM.ORDER_READ, PERM.ORDER_WRITE, + ], + operator: [ + PERM.USER_WRITE, PERM.TOOL_WRITE, PERM.MODULE_WRITE, PERM.LEVEL_WRITE, + PERM.FEEDBACK_WRITE, PERM.QUOTA_WRITE, PERM.EVENT_WRITE, + PERM.ORDER_READ, PERM.ORDER_WRITE, + ], + readonly: [ + PERM.ORDER_READ, + ], +} + +/** 角色的权限点列表(未知角色 → 空,按最小权限)。 */ +export function permissionsOf(role) { + return ROLE_PERMISSIONS[role] || [] +} + +/** 是否拥有权限点;perm 为空表示「仅需登录」。 */ +export function hasPermission(role, perm) { + if (!perm) return true + return permissionsOf(role).includes(perm) +} + +/** 角色值 → 展示名。 */ +export const ROLE_OPTIONS = [ + { value: 'super', label: '超级管理员' }, + { value: 'operator', label: '运营' }, + { value: 'readonly', label: '只读' }, +] + +export function roleLabel(role) { + return ROLE_OPTIONS.find((r) => r.value === role)?.label || role || '-' +} diff --git a/src/views/Login.vue b/src/views/Login.vue index 21c8efb..bb7e0c3 100644 --- a/src/views/Login.vue +++ b/src/views/Login.vue @@ -30,36 +30,37 @@ async function onSubmit() { + + diff --git a/src/views/admins/AdminManage.vue b/src/views/admins/AdminManage.vue new file mode 100644 index 0000000..81bd1bc --- /dev/null +++ b/src/views/admins/AdminManage.vue @@ -0,0 +1,154 @@ + + + diff --git a/src/views/audit/AuditLog.vue b/src/views/audit/AuditLog.vue new file mode 100644 index 0000000..4726e1b --- /dev/null +++ b/src/views/audit/AuditLog.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/src/views/coupon/CouponRecord.vue b/src/views/coupon/CouponRecord.vue new file mode 100644 index 0000000..6ce140f --- /dev/null +++ b/src/views/coupon/CouponRecord.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/src/views/coupon/CouponTemplate.vue b/src/views/coupon/CouponTemplate.vue new file mode 100644 index 0000000..3a48026 --- /dev/null +++ b/src/views/coupon/CouponTemplate.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/src/views/dashboard/Dashboard.vue b/src/views/dashboard/Dashboard.vue index 9803542..3b7c862 100644 --- a/src/views/dashboard/Dashboard.vue +++ b/src/views/dashboard/Dashboard.vue @@ -1,57 +1,252 @@ + + diff --git a/src/views/event/Designer.vue b/src/views/event/Designer.vue new file mode 100644 index 0000000..8d4ec62 --- /dev/null +++ b/src/views/event/Designer.vue @@ -0,0 +1,1689 @@ + + + + + diff --git a/src/views/event/EnterpriseManage.vue b/src/views/event/EnterpriseManage.vue new file mode 100644 index 0000000..e42cff8 --- /dev/null +++ b/src/views/event/EnterpriseManage.vue @@ -0,0 +1,584 @@ + + + + + diff --git a/src/views/event/EventList.vue b/src/views/event/EventList.vue new file mode 100644 index 0000000..82aebe0 --- /dev/null +++ b/src/views/event/EventList.vue @@ -0,0 +1,321 @@ + + + diff --git a/src/views/event/ImportPreview.vue b/src/views/event/ImportPreview.vue new file mode 100644 index 0000000..7b4c8b2 --- /dev/null +++ b/src/views/event/ImportPreview.vue @@ -0,0 +1,509 @@ + + + + + diff --git a/src/views/event/QueryLog.vue b/src/views/event/QueryLog.vue new file mode 100644 index 0000000..6add304 --- /dev/null +++ b/src/views/event/QueryLog.vue @@ -0,0 +1,140 @@ + + + diff --git a/src/views/feedbacks/FeedbackList.vue b/src/views/feedbacks/FeedbackList.vue index d7ec3b3..dc0924b 100644 --- a/src/views/feedbacks/FeedbackList.vue +++ b/src/views/feedbacks/FeedbackList.vue @@ -2,26 +2,35 @@ import { onMounted, reactive, ref } from 'vue' import { message } from 'ant-design-vue' import { feedbackListApi, feedbackHandleApi } from '@/api/request/feedback' +import PageShell from '@/components/PageShell.vue' +import GlassCard from '@/components/GlassCard.vue' +import StateBlock from '@/components/StateBlock.vue' const loading = ref(false) +const loadError = ref('') const list = ref([]) const total = ref(0) const query = reactive({ page: 1, pageSize: 20, status: undefined }) const columns = [ - { title: '内容', dataIndex: 'content', key: 'content' }, - { title: '联系方式', dataIndex: 'contact', key: 'contact', width: 160 }, + { title: '内容', dataIndex: 'content', key: 'content', ellipsis: true }, + { title: '联系方式', dataIndex: 'contact', key: 'contact', width: 160, ellipsis: true }, { title: '状态', key: 'status', width: 100 }, { title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 180 }, - { title: '操作', key: 'action', width: 110 }, + { title: '操作', key: 'action', width: 120, align: 'right' }, ] async function load() { loading.value = true + loadError.value = '' try { const res = await feedbackListApi({ ...query, status: query.status ?? undefined }) - list.value = res.list - total.value = res.total + if (res) { + list.value = res.list || [] + total.value = res.total || 0 + } + } catch { + loadError.value = '加载失败,请稍后重试' } finally { loading.value = false } @@ -30,9 +39,18 @@ async function load() { onMounted(load) async function handle(record) { - await feedbackHandleApi(record.id) - record.status = 1 - message.success('已标记处理') + try { + await feedbackHandleApi(record.id) + record.status = 1 + message.success('已标记处理') + } catch { + /* 拦截器已提示 */ + } +} + +function onFilterChange() { + query.page = 1 + load() } function onTableChange(pag) { @@ -43,26 +61,47 @@ function onTableChange(pag) { diff --git a/src/views/levels/LevelManage.vue b/src/views/levels/LevelManage.vue index 1c6f8af..171fb97 100644 --- a/src/views/levels/LevelManage.vue +++ b/src/views/levels/LevelManage.vue @@ -1,10 +1,15 @@ diff --git a/src/views/modules/ModuleManage.vue b/src/views/modules/ModuleManage.vue index acb5290..e514e8d 100644 --- a/src/views/modules/ModuleManage.vue +++ b/src/views/modules/ModuleManage.vue @@ -1,9 +1,14 @@ + + diff --git a/src/views/orders/OrderList.vue b/src/views/orders/OrderList.vue new file mode 100644 index 0000000..7f0d23d --- /dev/null +++ b/src/views/orders/OrderList.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/src/views/promo/PromoList.vue b/src/views/promo/PromoList.vue new file mode 100644 index 0000000..9ad991b --- /dev/null +++ b/src/views/promo/PromoList.vue @@ -0,0 +1,414 @@ + + + + + diff --git a/src/views/tools/ToolManage.vue b/src/views/tools/ToolManage.vue index c934a30..b067a4e 100644 --- a/src/views/tools/ToolManage.vue +++ b/src/views/tools/ToolManage.vue @@ -1,43 +1,124 @@ + + diff --git a/src/views/users/UserList.vue b/src/views/users/UserList.vue index 2f87402..634f74a 100644 --- a/src/views/users/UserList.vue +++ b/src/views/users/UserList.vue @@ -1,96 +1,657 @@