diff --git a/frontend/README.md b/frontend/README.md index 2ef7f88..9aa850c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -188,15 +188,15 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py | 页面 | 说明 | | --- | --- | | `/reports/personal-detail` 个人绩效明细(新组「报表统计」order19) | 人员/月份/客户类型/产品类别/岗位筛选;需规 2.4.1 全部列;行展开懒加载台账快照;页脚**全筛选合计**(注明非当前页);导出/打印 | -| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司 5% 独立卡片;团队表(基数/12.5%/5%/占比/调整/净合计/月结冻结额);展开成员 → 跨页下钻个人明细;未分配桶 | +| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司分成独立卡片;团队表(基数/团队分成/全公司分成/占比/调整/净合计/月结冻结额),比例列头随在线参数变化;展开成员 → 跨页下钻个人明细(人员与月份带入筛选);未分配桶(按经理筛选时为空) | | `/reports/business-stats` 业务统计(order21) | 关键字/客户/归属人/状态/业务日期;发货额/回款额/绩效(累计口径提示);展开下钻回款与台账明细 | | `/reports/payment-stats` 回款统计(order22) | 月份/回款日期/客户类型/产品类别;8 档区间表(空桶 0,档位由后端下发);展开桶下钻分页明细;客户类型/产品类别对比表 | -| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(上限 2000 行截断提示);`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 | +| `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(以接口 total 判定超过 2000 行并提示截断);部门汇总按「团队分成 → 全公司 5% → 对账 → 成员明细 → 未分配」五块渲染;`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 | - 路由组「报表统计」四页 `allowedRoles: [admin, finance, leader]`(前端菜单 + 后端 `require_roles` 双层;admin 须显式列入)。 - api 封装 `src/api/reports.ts`(类型 + 查询 + `exportReport` 裸 axios blob;产品类别标签额外覆盖 `mixed`)。 - 权限:营销后勤「授权范围内」待业务权限清单 #6,当前不开放报表。 -- type-check 与 build 通过(2026-10-19)。e2e `report_flow.py` 待补(需 `E2E_ADMIN_PASSWORD` 环境)。 +- type-check 与 build 通过(2026-10-19)。e2e `report_flow.py` 已补 12 步:登录 → API 造数 → 四表筛选/导出下载 → 部门下钻筛选生效 → 回款多桶同时展开互不串行 → 打印视图 → 打印截断提示 → 部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 且前后端在跑。 ## 端到端测试(2026-10-19 追加 · M3 台账与月结) @@ -210,3 +210,19 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py ② 三页均无 `onMounted` 初始加载 → 进页不取数;补 `onMounted(() => void load())`。 ③ 三页 `AppPagination` 用 `v-model:page-num` + `@change`(组件无该事件、也未实现 `update:*`)→ 翻页失效;统一为 `:page-num/:page-size` + `@page-change/@size-change`(usePagedList 的 onPageChange/onSizeChange)。 **注意**:e2e 与 `pytest tests/integration` **不可并发**——conftest 每用例整体清空开发库,会清掉 e2e 造的数据造成假失败(本次排查耗时即源于此,两次假失败的报错均为「台账行消失 / 401 登录失效」)。 + +## 前端页面(2026-10-19 追加 · M4 ⑧ 工作台) + +`/dashboard` 由 Bento 占位升级为工作台四区块(**一次聚合请求** `/api/v1/dashboard`): + +| 区块 | 展示 | +| --- | --- | +| 经营绩效总览 | KPI 四卡(本月绩效/应付/核算人数/团队数 + 月结状态);近 6 个月绩效折线 + 回款柱状(ECharts) | +| 待办事项 | 待审批(跳审批管理)、回款未指定批次(跳回款管理)、T+ 同步失败(未启用显式占位) | +| 风险预警 | 回款逾期(danger)/ 审批超时(warning),点击跳处理页;总开关关闭时显示参数提示 | +| 最近动态 | 操作日志时间线,按模块本地筛选 | + +- **ECharts 5.6 按需引入**:`echarts/core` + `BarChart/LineChart/Grid/Tooltip` + `SVGRenderer`(与设计预览一致),只注册用到的模块。 +- **字体红线**:图表 `textStyle.fontFamily` 必须显式设为思源黑体栈——ECharts 在 Windows 默认微软雅黑(字体商用合规要求)。 +- 权限:路由 `allowedRoles: [admin, finance, leader]`,与后端 `require_roles` 双层;营销后勤待权限清单 #6。 +- `package.json` 新增依赖 `echarts@5.6.0`(MIT);type-check 与 build 通过(2026-10-19)。 diff --git a/frontend/package.json b/frontend/package.json index 0e6d432..5d1bc27 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "dependencies": { "@vue/devtools-api": "^8.1.5", "axios": "^1.20.0", + "echarts": "^5.6.0", "element-plus": "^2.14.7", "pinia": "^4.0.3", "vue": "^3.5.43", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 4e7a896..995c85e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: axios: specifier: ^1.20.0 version: 1.20.0(debug@4.4.3) + echarts: + specifier: ^5.6.0 + version: 5.6.0 element-plus: specifier: ^2.14.7 version: 2.14.7(vue@3.5.43(typescript@5.9.3)) @@ -399,6 +402,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + element-plus@2.14.7: resolution: {integrity: sha512-lrG7pZyCS+mpCNVpzxxyu5r35+T6V0hzkAeIwn7HA6to772XSTHKuYCZG51OfzlqaxGbNegLxs650zAF1j43gA==} peerDependencies: @@ -693,6 +699,9 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -828,6 +837,9 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + snapshots: '@babel/helper-string-parser@7.29.7': {} @@ -1155,6 +1167,11 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + element-plus@2.14.7(vue@3.5.43(typescript@5.9.3)): dependencies: '@ctrl/tinycolor': 4.2.1 @@ -1435,6 +1452,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tslib@2.3.0: {} + typescript@5.9.3: {} ufo@1.6.4: {} @@ -1521,3 +1540,7 @@ snapshots: typescript: 5.9.3 webpack-virtual-modules@0.6.2: {} + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..3dba29e --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -0,0 +1,96 @@ +import { request } from './request' + +/** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */ +export interface TrendPoint { + month: string + performance_total: string | null + receipt_total: string | null +} + +export interface SettlementBrief { + exists: boolean + status: number | null + total_performance: string | null + total_payable: string | null + locked_at: string | null +} + +export interface OverviewBlock { + month: string + performance_total: string | null + payable_total: string | null + person_count: number + team_count: number + settlement: SettlementBrief + trend: TrendPoint[] + note: string +} + +export interface ApprovalTodoItem { + order_id: number + order_no: string + order_type: number + submitter_name: string | null + create_time: string +} + +export interface ReceiptTodoItem { + receipt_id: number + receipt_batch_no: string + business_id: number + business_no: string | null + amount: string + receipt_date: string +} + +export interface TodoBlock { + pending_approval_total: number + pending_approvals: ApprovalTodoItem[] + unassigned_receipt_total: number + unassigned_receipts: ReceiptTodoItem[] + sync_available: boolean + sync_failure_total: number +} + +export interface WarningItem { + code: string + level: 'warning' | 'danger' + object_type: string + object_id: number + title: string + detail: string + suggest: string + link: string | null +} + +export interface WarningBlock { + enabled: boolean + receipt_overdue_days: number + approval_timeout_days: number + items: WarningItem[] + receipt_overdue_total: number + approval_timeout_total: number + sync_available: boolean +} + +export interface ActivityItem { + id: number + module: string + action: string + summary: string + username: string | null + success: number + create_time: string +} + +export interface DashboardData { + month: string + overview: OverviewBlock + todos: TodoBlock + warnings: WarningBlock + activities: ActivityItem[] +} + +export function getDashboard(params: { month?: string }): Promise { + return request({ url: '/v1/dashboard', method: 'get', params }) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ee8ea7c..8abf96e 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -38,10 +38,16 @@ const routes: RouteRecordRaw[] = [ redirect: '/dashboard', children: [ { + // 工作台(M4 ⑧):与报表/审批接口同权限(admin/finance/leader), + // 前后端双层;营销后勤可见性待权限清单 #6 答复 path: '/dashboard', name: 'dashboard', component: () => import('@/views/dashboard/index.vue'), - meta: { title: '首页', order: 1 }, + meta: { + title: '首页', + order: 1, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, }, { path: '/system/users', diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index 07815ca..05a081a 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -1,133 +1,372 @@ @@ -139,56 +378,62 @@ onMounted(() => { .page-title { margin: 0; - font-size: 18px; + font-size: 22px; font-weight: 600; - letter-spacing: -0.01em; color: var(--ys-ink); } .page-desc { - margin: 4px 0 0; - font-size: 12px; + margin: 6px 0 0; + font-size: 13px; color: var(--ys-ink-3); } .waterline { display: block; - width: 56px; + width: 46px; height: 2px; - margin-top: 10px; - border-radius: 1px; + margin-top: 12px; background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0)); } -/* Bento Grid:4 列基格,卡片按重要性混排大小 */ +.toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.toolbar-note { + font-size: 12px; + color: var(--ys-ink-3); +} + .bento { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: 102px; gap: 14px; } .bento-card { - display: flex; - flex-direction: column; - padding: 17px 20px; - overflow: hidden; + position: relative; + padding: 16px 18px; background: linear-gradient(180deg, #ffffff, #fcfcfe); border: 1px solid var(--ys-line); border-radius: 10px; box-shadow: 0 1px 2px rgba(28, 35, 51, 0.04), - 0 3px 10px rgba(28, 35, 51, 0.03); - transition: - transform 180ms cubic-bezier(0.25, 0.1, 0.25, 1), - box-shadow 180ms cubic-bezier(0.25, 0.1, 0.25, 1); + 0 10px 24px -18px rgba(28, 35, 51, 0.18); + transition: transform 0.18s ease, box-shadow 0.18s ease; + overflow: hidden; } .bento-card:hover { transform: translateY(-2px); box-shadow: - 0 1px 2px rgba(28, 35, 51, 0.05), - 0 6px 18px rgba(28, 35, 51, 0.07); + 0 2px 4px rgba(28, 35, 51, 0.05), + 0 16px 30px -18px rgba(28, 35, 51, 0.22); } .b-2x2 { @@ -196,149 +441,190 @@ onMounted(() => { grid-row: span 2; } -.b-2x1 { - grid-column: span 2; +.b-full { + grid-column: span 4; } .b-eyebrow { + display: flex; + align-items: center; + gap: 8px; font-size: 12px; - font-weight: 500; letter-spacing: 0.04em; - color: var(--ys-ink-2); + color: var(--ys-ink-3); +} + +.b-eyebrow-gap { + margin-top: 12px; +} + +.b-count { + min-width: 20px; + padding: 0 6px; + font-size: 11px; + line-height: 18px; + text-align: center; + color: var(--ys-indigo); + background: var(--ys-indigo-wash); + border-radius: 9px; } .b-num { - margin-top: auto; - font-size: 24px; + margin-top: 10px; + font-size: 26px; font-weight: 600; - font-variant-numeric: tabular-nums; - line-height: 1.2; - letter-spacing: -0.01em; color: var(--ys-ink); + font-variant-numeric: tabular-nums; } .b-num small { - margin-left: 3px; + margin-left: 4px; font-size: 12px; font-weight: 400; color: var(--ys-ink-3); } .b-sub { - margin-top: 3px; - font-size: 11px; - color: var(--ys-ink-3); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.b-state { - margin-top: auto; - font-size: 15px; - font-weight: 600; - color: var(--ys-ink-2); -} - -.b-empty { - display: flex; - flex: 1; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; -} - -.b-empty-title { - font-size: 14px; - font-weight: 500; - color: var(--ys-ink-2); -} - -.b-empty-desc { - max-width: 260px; - margin-top: 6px; + margin-top: 4px; font-size: 12px; color: var(--ys-ink-3); - line-height: 1.7; } .b-empty-mini { - margin-top: auto; - margin-bottom: auto; + margin-top: 8px; font-size: 12px; color: var(--ys-ink-3); - line-height: 1.7; +} + +.chart { + height: 150px; + margin-top: 6px; +} + +.todo-list, +.warning-list { + max-height: 178px; + margin-top: 8px; + overflow-y: auto; +} + +.todo-row, +.warning-row { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 6px 4px; + border: none; + border-bottom: 1px dashed var(--ys-line); + background: none; + text-align: left; + cursor: pointer; +} + +.todo-row:hover, +.warning-row:hover { + background: rgba(46, 75, 168, 0.04); +} + +.todo-title { + font-size: 13px; + color: var(--ys-ink); +} + +.todo-meta { + font-size: 11px; + color: var(--ys-ink-3); +} + +.warning-row { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + column-gap: 8px; + align-items: center; +} + +.warning-title { + font-size: 13px; + color: var(--ys-ink); +} + +.warning-detail { + grid-column: 2; + font-size: 11px; + color: var(--ys-ink-3); +} + +.activity-list { + max-height: 150px; + margin-top: 8px; + overflow-y: auto; +} + +.activity-row { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 0; + border-bottom: 1px dashed var(--ys-line); + font-size: 12px; +} + +.activity-time { + width: 118px; + color: var(--ys-ink-3); + font-variant-numeric: tabular-nums; +} + +.activity-summary { + flex: 1; + color: var(--ys-ink-2); +} + +.activity-user { + color: var(--ys-ink-3); } .b-links { display: flex; - gap: 8px; - align-items: center; - margin-top: auto; -} - -.b-link { - padding: 0; - margin-top: 6px; - font-size: 12px; - color: var(--ys-indigo); - text-align: left; - cursor: pointer; - background: none; - border: none; -} - -.b-link:hover { - color: var(--ys-indigo-deep); + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; } .b-op { - height: 30px; - padding: 0 13px; - font-size: 12px; - font-family: inherit; - color: var(--ys-ink); - cursor: pointer; - background: linear-gradient(180deg, #ffffff, #fbfcfe); - border: 1px solid var(--ys-line); - border-radius: 7px; - transition: - color 150ms cubic-bezier(0.25, 0.1, 0.25, 1), - border-color 150ms cubic-bezier(0.25, 0.1, 0.25, 1); -} - -button.b-op:hover { + padding: 6px 14px; + font-size: 13px; color: var(--ys-indigo); - border-color: rgba(46, 75, 168, 0.45); + background: var(--ys-indigo-wash); + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; } -.b-op-disabled { - color: var(--ys-ink-3); - cursor: not-allowed; +.b-op:hover { + border-color: var(--ys-indigo); } @media (max-width: 1024px) { .bento { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .b-full { + grid-column: span 2; } } @media (max-width: 640px) { .bento { grid-template-columns: 1fr; - grid-auto-rows: auto; - } - - .bento-card { - min-height: 90px; } .b-2x2, - .b-2x1 { + .b-full { grid-column: span 1; - grid-row: span 1; - min-height: 130px; } } @@ -346,5 +632,9 @@ button.b-op:hover { .bento-card { transition: none; } + + .bento-card:hover { + transform: none; + } }