From 168c76ed0e347100671785fbb86b087cd46262b4 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Wed, 30 Sep 2026 09:02:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(pc):=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E6=8D=A2=E6=88=90=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=20+=20=E3=80=8C=E5=BE=85=E5=BB=BA=E3=80=8D=E9=A1=B5?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=AF=B4=E8=B0=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作台底部原来是一张写着「M0 后端已完成,界面建设中」的进度卡,而项目已是 120/121 个 接口可用——首页上说一件不真的事比空着更糟。改成真实看板:选任务 → 进度四格 + 分班进度 + 审核情况 + 驳回原因分布;提交率与通过率分两列;平均审核时长按量级换单位。 名单管理/材料规则/表单设计三个菜单项对应的功能早就做好了(入口在「收集任务」每行的 链接里),却一直显示「这个页面还没做」。给 XtMenu 加 entryAt:有入口就指路 + 给跳转, 没有才说没做(角色与授权确实没做,属二期)。并删掉那句会静默过期的「已实现页面」清单。 验证:浏览器实测四个菜单与看板出数;pnpm typecheck 通过、eslint 0 错。 --- frontend-pc/src/apis/xt/task.ts | 67 +++++ frontend-pc/src/components.d.ts | 12 + frontend-pc/src/config/xt-routes.ts | 83 +++++- frontend-pc/src/views/coming-soon/index.vue | 92 +++++-- frontend-pc/src/views/dashboard/index.vue | 270 ++++++++++++++++++-- 5 files changed, 476 insertions(+), 48 deletions(-) diff --git a/frontend-pc/src/apis/xt/task.ts b/frontend-pc/src/apis/xt/task.ts index 119be86..e69ed18 100644 --- a/frontend-pc/src/apis/xt/task.ts +++ b/frontend-pc/src/apis/xt/task.ts @@ -195,6 +195,20 @@ export const TASK_STATUS_CLOSED = 2 export const TASK_STATUS_ARCHIVED = 3 export const TASK_STATUS_PAUSED = 4 +/** + * 任务状态 → 文案(与后端 `CollectTask` 的常量同口径)。 + * + * ⚠️ 放在这里而不是各页面里:任务列表、状态筛选、工作台下拉都要用, + * 三份各写一遍迟早在某次改动后不一致。 + */ +export const TASK_STATUS_TEXT: Record = { + [TASK_STATUS_DRAFT]: '草稿', + [TASK_STATUS_COLLECTING]: '收集中', + [TASK_STATUS_CLOSED]: '已截止', + [TASK_STATUS_ARCHIVED]: '已归档', + [TASK_STATUS_PAUSED]: '已暂停' +} + /** * 命名模板可用变量(与 PRD 的"可用变量"一栏逐字对齐)。 * @@ -279,3 +293,56 @@ export async function getTeacherOptions(): Promise ({ id, label })) } + +// ── 任务统计(§7.11,工作台看板用)───────────────── + +/** `stat` 是契约 §3.3 的全字段。 */ +export interface TaskStatVO { + rosterTotal: number + submittedCount: number + approvedCount: number + rejectedCount?: number + pendingCount?: number + /** 形如 `91.5%`;**分母为 0 时是 `—` 而不是 `0%`**。 */ + submitRate: string + passRate: string + /** 异常(校验有问题)的填报单数。 */ + abnormalCount?: number +} + +/** 分班进度。 */ +export interface TaskStatOrgRow { + orgId: number + orgName: string + rosterTotal: number + submittedCount: number + approvedCount: number + submitRate: string +} + +/** 分职业/工种/等级。 */ +export interface TaskStatOccRow { + occupation: string + worktype: string + jobLevel: string + count: number + approvedCount: number +} + +export interface TaskStatisticsVO { + stat: TaskStatVO + /** 分班 —— 班主任最关心这一块。 */ + byOrg: TaskStatOrgRow[] + /** 分职业,走**正式列**(实时且走索引)。 */ + byOccupation: TaskStatOccRow[] +} + +/** + * 任务统计。 + * + * ⚠️ `submitRate`(已提交/应填)与 `passRate`(已通过/应填)**必须分开显示**: + * 已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。 + */ +export function getTaskStatistics(taskId: number) { + return http.get(`/tasks/${taskId}/statistics`) +} diff --git a/frontend-pc/src/components.d.ts b/frontend-pc/src/components.d.ts index 80e9f0b..5de5c04 100644 --- a/frontend-pc/src/components.d.ts +++ b/frontend-pc/src/components.d.ts @@ -34,7 +34,10 @@ declare module 'vue' { AEmpty: typeof import('@arco-design/web-vue')['Empty'] AForm: typeof import('@arco-design/web-vue')['Form'] AFormItem: typeof import('@arco-design/web-vue')['FormItem'] + AGrid: typeof import('@arco-design/web-vue')['Grid'] + AGridItem: typeof import('@arco-design/web-vue')['GridItem'] AInput: typeof import('@arco-design/web-vue')['Input'] + AInputGroup: typeof import('@arco-design/web-vue')['InputGroup'] AInputNumber: typeof import('@arco-design/web-vue')['InputNumber'] AInputPassword: typeof import('@arco-design/web-vue')['InputPassword'] AInputSearch: typeof import('@arco-design/web-vue')['InputSearch'] @@ -47,8 +50,10 @@ declare module 'vue' { AMenuItem: typeof import('@arco-design/web-vue')['MenuItem'] AModal: typeof import('@arco-design/web-vue')['Modal'] AOption: typeof import('@arco-design/web-vue')['Option'] + AOverflowList: typeof import('@arco-design/web-vue')['OverflowList'] APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm'] APopover: typeof import('@arco-design/web-vue')['Popover'] + AProgress: typeof import('@arco-design/web-vue')['Progress'] ARadio: typeof import('@arco-design/web-vue')['Radio'] ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup'] ARow: typeof import('@arco-design/web-vue')['Row'] @@ -68,6 +73,7 @@ declare module 'vue' { ATooltip: typeof import('@arco-design/web-vue')['Tooltip'] ATree: typeof import('@arco-design/web-vue')['Tree'] ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect'] + AUpload: typeof import('@arco-design/web-vue')['Upload'] GiButton: typeof import('./components/GiButton/GiButton.vue')['default'] GiCellAvatar: typeof import('./components/GiCell/GiCellAvatar.vue')['default'] GiCellGender: typeof import('./components/GiCell/GiCellGender.vue')['default'] @@ -116,7 +122,10 @@ declare global { const AEmpty: typeof import('@arco-design/web-vue')['Empty'] const AForm: typeof import('@arco-design/web-vue')['Form'] const AFormItem: typeof import('@arco-design/web-vue')['FormItem'] + const AGrid: typeof import('@arco-design/web-vue')['Grid'] + const AGridItem: typeof import('@arco-design/web-vue')['GridItem'] const AInput: typeof import('@arco-design/web-vue')['Input'] + const AInputGroup: typeof import('@arco-design/web-vue')['InputGroup'] const AInputNumber: typeof import('@arco-design/web-vue')['InputNumber'] const AInputPassword: typeof import('@arco-design/web-vue')['InputPassword'] const AInputSearch: typeof import('@arco-design/web-vue')['InputSearch'] @@ -129,8 +138,10 @@ declare global { const AMenuItem: typeof import('@arco-design/web-vue')['MenuItem'] const AModal: typeof import('@arco-design/web-vue')['Modal'] const AOption: typeof import('@arco-design/web-vue')['Option'] + const AOverflowList: typeof import('@arco-design/web-vue')['OverflowList'] const APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm'] const APopover: typeof import('@arco-design/web-vue')['Popover'] + const AProgress: typeof import('@arco-design/web-vue')['Progress'] const ARadio: typeof import('@arco-design/web-vue')['Radio'] const ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup'] const ARow: typeof import('@arco-design/web-vue')['Row'] @@ -150,6 +161,7 @@ declare global { const ATooltip: typeof import('@arco-design/web-vue')['Tooltip'] const ATree: typeof import('@arco-design/web-vue')['Tree'] const ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect'] + const AUpload: typeof import('@arco-design/web-vue')['Upload'] const GiButton: typeof import('./components/GiButton/GiButton.vue')['default'] const GiCellAvatar: typeof import('./components/GiCell/GiCellAvatar.vue')['default'] const GiCellGender: typeof import('./components/GiCell/GiCellGender.vue')['default'] diff --git a/frontend-pc/src/config/xt-routes.ts b/frontend-pc/src/config/xt-routes.ts index de11514..c4dc92c 100644 --- a/frontend-pc/src/config/xt-routes.ts +++ b/frontend-pc/src/config/xt-routes.ts @@ -30,6 +30,20 @@ export interface XtMenu { ready: boolean /** 待建说明 */ note?: string + /** + * 这个功能**其实已经有了**,只是入口不在左侧菜单里(例如"名单管理"是按任务的, + * 入口在「收集任务」每一行的链接里)。 + * + * ⚠️ 有它的时候,「待建」页就不再显示"还没做",而是给出真实入口的指路 + 跳转按钮。 + * 没有它 = 确实还没做。两者的区别很重要:说"还没做"而功能其实在隔壁, + * 会让人**得出错误结论**(比找不到更糟)。 + */ + entryAt?: { + /** 跳到哪个路由 */ + path: string + /** 到了之后该点哪里(要具体到"点哪一行里的哪个链接") */ + hint: string + } } /** 分组顺序(决定左侧菜单顺序) */ @@ -62,8 +76,12 @@ export const XT_MENUS: XtMenu[] = [ group: 'collect', sort: 12, ready: false, - note: '名册是**按任务**的,所以入口在「收集任务」的每一行里:点「名单管理」。' - + '导入、体检报告、名册列表都在那里;报考范围确认(M2-04/05)会接着做进同一个抽屉。' + note: '名册是按任务的:入口在「收集任务」每一行里点「名单管理」。' + + '导入、体检报告、名册列表都在那里;报考范围确认(M2-04/05)会接着做进同一个抽屉。', + entryAt: { + path: '/collect/task', + hint: '在「收集任务」里找到那个任务,点它这一行的「名单管理」' + } }, { key: 'join', @@ -73,8 +91,7 @@ export const XT_MENUS: XtMenu[] = [ component: 'join/index', group: 'collect', sort: 13, - ready: false, - note: '门禁未匹配的人提交加入申请,管理员在此审批。对应里程碑 M2。' + ready: true }, { key: 'audit', @@ -84,8 +101,7 @@ export const XT_MENUS: XtMenu[] = [ component: 'audit/index', group: 'collect', sort: 14, - ready: false, - note: '班主任初审(右滑通过)、管理员终审、批量操作。对应里程碑 M5。' + ready: true }, { key: 'export', @@ -95,8 +111,20 @@ export const XT_MENUS: XtMenu[] = [ component: 'export/index', group: 'collect', sort: 15, - ready: false, - note: '按目录规范打包汇总,不合格文件禁止进包。对应里程碑 M6。' + ready: true + }, + { + // 通知记录:把「通知到底发出去没有」变成可回答的(§13.3)。 + // 刻意**不**并到审核页里 —— 它是运营视角的排查工具, + // 而审核页是老师视角的操作台,两者的"该看什么"完全不同 + key: 'notify', + title: '通知记录', + icon: 'icon-park-outline:remind', + path: '/collect/notify', + component: 'notify/index', + group: 'collect', + sort: 17, + ready: true }, // ── 配置 ───────────────────────────────────────── @@ -109,7 +137,11 @@ export const XT_MENUS: XtMenu[] = [ group: 'setting', sort: 21, ready: false, - note: '内置字段锁定、扩展字段自由。对应里程碑 M1。' + note: '内置字段锁定、扩展字段自由 —— 按任务设计,入口在「收集任务」每一行的「表单设计」。', + entryAt: { + path: '/collect/task', + hint: '在「收集任务」里找到那个任务,点它这一行的「表单设计」' + } }, { key: 'material', @@ -120,7 +152,11 @@ export const XT_MENUS: XtMenu[] = [ group: 'setting', sort: 22, ready: false, - note: '命名模板、别名、格式白名单、体积上限、压缩档位。对应里程碑 M1。' + note: '命名模板、别名、格式白名单、体积上限、压缩档位 —— 按任务配置,入口在「收集任务」每一行的「材料规则」。', + entryAt: { + path: '/collect/task', + hint: '在「收集任务」里找到那个任务,点它这一行的「材料规则」' + } }, { key: 'cleanup', @@ -130,8 +166,19 @@ export const XT_MENUS: XtMenu[] = [ component: 'cleanup/index', group: 'setting', sort: 23, - ready: false, - note: '把已有的乱文件夹/压缩包批量规范化并出体检报告。对应里程碑 M3。' + ready: true + }, + { + // 字典管理(§13.3)。内置项照抄官方表格、不可改名(只可停用单项), + // 所以这一屏的主要用途是"追加客户自己的取值"(例如多了个学历写法) + key: 'dict', + title: '字典管理', + icon: 'icon-park-outline:list-two', + path: '/setting/dict', + component: 'dict/index', + group: 'setting', + sort: 24, + ready: true }, // ── 组织与账号 ──────────────────────────────────── @@ -186,7 +233,7 @@ export const XT_MENUS: XtMenu[] = [ component: 'log/index', group: 'platform', sort: 42, - ready: false, + ready: true, note: '登录、查看明文、下载、导出、代传、删除的审计记录。' } ] @@ -296,6 +343,16 @@ export function findMenu(key: string): XtMenu | undefined { return XT_MENUS.find((m) => m.key === key) } +/** + * 按路由路径取菜单定义(「待建」页要显示"去哪个页面")。 + * + * ⚠️ 按钮上的目标页名**必须从这里取**,不要在页面里写死 —— + * 菜单哪天改名,写死的那个名字就会指向一个不存在的页面。 + */ +export function findMenuByPath(path: string): XtMenu | undefined { + return XT_MENUS.find((m) => m.path === path) +} + /** 角色码 → 中文名(与后端 BuiltinRole 对齐) */ export const ROLE_NAMES: Record = { PLATFORM_ADMIN: '平台运营', diff --git a/frontend-pc/src/views/coming-soon/index.vue b/frontend-pc/src/views/coming-soon/index.vue index a65ca0c..270e5c7 100644 --- a/frontend-pc/src/views/coming-soon/index.vue +++ b/frontend-pc/src/views/coming-soon/index.vue @@ -3,32 +3,58 @@ - + + + + + -

+

+ + + 去「{{ entryTitle }}」 + +
{{ menu?.entryAt?.hint }}
+
+ +

左侧菜单是按 服务端返回的角色权限 渲染的,所以你能看到它, 说明你的角色确实有这个权限 —— 只是界面还没排到。 - 当前已实现的页面:工作台、组织架构、租户管理(以及本页)。

@@ -248,6 +435,57 @@ onMounted(async () => { color: var(--color-text-3); } + &__board-card { + padding: 12px 14px; + margin-bottom: 12px; + background: var(--color-fill-1); + border-radius: 4px; + } + + &__board-label { + font-size: 12px; + color: var(--color-text-3); + } + + &__board-num { + margin-top: 6px; + font-size: 24px; + font-weight: 700; + line-height: 1; + color: var(--color-text-1); + + &.is-success { + color: rgb(var(--success-6)); + } + + &.is-warn { + color: rgb(var(--warning-6)); + } + } + + &__board-foot { + margin-top: 6px; + font-size: 11px; + color: var(--color-text-3); + } + + &__board-tables { + margin-top: 6px; + } + + &__board-title { + margin: 12px 0 8px; + font-size: 13px; + font-weight: 600; + } + + &__board-note { + margin-left: 8px; + font-size: 11px; + font-weight: 400; + color: var(--color-text-3); + } + &__note:not(:last-child) { margin-bottom: 10px; }