feat(pc): 工作台换成真实数据看板 + 「待建」页不再说谎

工作台底部原来是一张写着「M0 后端已完成,界面建设中」的进度卡,而项目已是 120/121 个
接口可用——首页上说一件不真的事比空着更糟。改成真实看板:选任务 → 进度四格 +
分班进度 + 审核情况 + 驳回原因分布;提交率与通过率分两列;平均审核时长按量级换单位。

名单管理/材料规则/表单设计三个菜单项对应的功能早就做好了(入口在「收集任务」每行的
链接里),却一直显示「这个页面还没做」。给 XtMenu 加 entryAt:有入口就指路 + 给跳转,
没有才说没做(角色与授权确实没做,属二期)。并删掉那句会静默过期的「已实现页面」清单。

验证:浏览器实测四个菜单与看板出数;pnpm typecheck 通过、eslint 0 错。
This commit is contained in:
2026-09-30 09:02:56 +08:00
parent c8837ee602
commit 168c76ed0e
5 changed files with 476 additions and 48 deletions
+67
View File
@@ -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<number, string> = {
[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<Array<{ id: number, label: st
}
return Array.from(merged.entries(), ([id, label]) => ({ 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<TaskStatisticsVO>(`/tasks/${taskId}/statistics`)
}
+12
View File
@@ -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']
+70 -13
View File
@@ -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<string, string> = {
PLATFORM_ADMIN: '平台运营',
+73 -19
View File
@@ -3,32 +3,58 @@
<a-card :bordered="false">
<template #title>
<a-space>
<icon-clock-circle />
<span>{{ menu?.title ?? '功能' }}</span>
<IconClockCircle />
<span>{{ menu?.title ?? route.meta.title ?? '功能' }}</span>
</a-space>
</template>
<template #extra>
<a-tag color="orange">{{ milestone }}</a-tag>
<a-tag :color="menu?.entryAt ? 'green' : 'orange'">
{{ menu?.entryAt ? '功能已就绪' : milestone }}
</a-tag>
</template>
<a-alert type="info" :show-icon="false">
<!--
⚠️ 两种说法必须分开,不能都说「还没做」。
名单管理 / 材料规则 / 表单设计 这三个菜单项对应的功能**早就做好了**,
只是它们**按任务**(入口在「收集任务」每一行的链接里)。
以前这里一律说"这个页面还没做" + 说"已实现的页面只有工作台、组织架构、租户管理"——
而那句话早就过期了,且**没有任何机制会让它不过期**。
说"还没做"而功能其实在隔壁,比找不到更糟:**它让人得出错误结论。**
-->
<a-alert v-if="menu?.entryAt" type="success" :show-icon="false">
<template #title>
<strong>这个功能已经有了,只是入口不在这里。</strong>
{{ menu?.note }}
</template>
</a-alert>
<a-alert v-else type="info" :show-icon="false">
<template #title>
<strong>这个页面还没做。</strong>
{{ menu?.note ?? '该功能尚未实现。' }}
</template>
</a-alert>
<p class="coming-soon__why">
<div v-if="menu?.entryAt" class="coming-soon__go">
<a-button type="primary" @click="goEntry">
<template #icon><IconArrowRight /></template>
去「{{ entryTitle }}」
</a-button>
<div class="coming-soon__hint">{{ menu?.entryAt?.hint }}</div>
</div>
<p v-else class="coming-soon__why">
左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它,
说明你的角色确实有这个权限 —— 只是界面还没排到。
当前已实现的页面:<strong>工作台、组织架构、租户管理</strong>(以及本页)。
</p>
</a-card>
</GiPageLayout>
</template>
<script setup lang="ts">
import { findMenu } from '@/config/xt-routes'
import { IconArrowRight, IconClockCircle } from '@arco-design/web-vue/es/icon'
import { findMenuByPath } from '@/config/xt-routes'
/**
* 「待建」页。
@@ -37,26 +63,44 @@ import { findMenu } from '@/config/xt-routes'
* 1. 菜单本身就是要展示的东西(权限驱动的导航);
* 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息;
* 3. 一个页面服务所有占位路由,成本极低。
*
* ⚠️ 但这个页面**过去在说谎**:它对"功能已经有了、只是入口在别处"的菜单项
* 也说"还没做",还列了一句早已过期的"已实现的页面"清单。
* 现在按 `entryAt` 分两种说法,并**删掉了那句清单** ——
* 任何"现状清单"写死在代码里都会静默过期,而没人会记得回来改它。
*/
defineOptions({ name: 'ComingSoon' })
const route = useRoute()
const router = useRouter()
const menu = computed(() => findMenu((route.meta.menu as string) ?? ''))
/**
* ⚠️ 按**路径**找,不要按 `route.meta.menu`。
*
* 原来写的是 `findMenu(route.meta.menu ?? '')`,而 `formatAsyncRoutes` 构造的 meta 里
* **没有 `menu` 这个键**(只有 hidden/keepAlive/title/icon/... )——
* 于是这里永远拿到 `undefined`,页面永远显示兜底文案
* 「功能 / 规划中 / 该功能尚未实现」,**每个菜单项自己的 note 从来没显示出来过**。
* 而 note 恰恰是这一页最有用的东西(它写着"真实入口在哪")。
*/
const menu = computed(() => findMenuByPath(route.path))
/** 跳过去之后那个页面的标题(从路由表里取,免得两处各写一遍)。 */
const entryTitle = computed(() => {
const path = menu.value?.entryAt?.path
return path ? (findMenuByPath(path)?.title ?? '目标页面') : '目标页面'
})
function goEntry() {
const path = menu.value?.entryAt?.path
if (path) {
router.push(path)
}
}
/** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
const MILESTONES: Record<string, string> = {
task: 'M1',
form: 'M1',
material: 'M1',
roster: 'M2',
join: 'M2',
cleanup: 'M3',
audit: 'M5',
export: 'M6',
user: 'M0 剩余',
role: '二期',
log: 'M0 剩余'
role: '二期'
}
const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中')
@@ -73,4 +117,14 @@ const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中
color: var(--color-text-1);
}
}
.coming-soon__go {
margin-top: 20px;
}
.coming-soon__hint {
margin-top: 8px;
font-size: 13px;
color: var(--color-text-3);
}
</style>
+254 -16
View File
@@ -47,28 +47,98 @@
</a-space>
</a-card>
<!-- 进度 -->
<!--
数据看板(M7-01/02)。
⚠️ 这里原来是一张「开发进度」卡,写着「M0 后端已完成,界面建设中」——
而项目早已是 120/121 个接口可用。**首页上说一件不真的事**比空着更糟,
所以换成了真实的统计数据(两个接口都是现成的,见 §7.11 与 §11.6)。
-->
<a-card :bordered="false">
<template #title>开发进度</template>
<template #title>数据看板</template>
<template #extra>
<span class="dash__hint">M0 后端已完成,界面建设中</span>
<a-space>
<a-select
v-model="boardTaskId"
placeholder="选择一个任务"
allow-search
style="width: 260px"
:loading="taskLoading"
:options="taskOptions"
@change="loadBoard"
/>
<a-button :loading="boardLoading" @click="loadBoard">刷新</a-button>
</a-space>
</template>
<a-alert type="success" :show-icon="false" class="dash__note">
<template #title>
<strong>M0 后端已通过验收(36/36)。</strong>
租户开通、组织架构、登录鉴权、跨租户隔离、权限菜单均已可用;
数据库由 Flyway 管理,26 张表(20 业务 + 5 标准 + 1 迁移记录)。
</template>
</a-alert>
<a-empty v-if="!boardTaskId" description="选一个任务,看它的填报进度与审核情况" />
<a-alert type="info" :show-icon="false" class="dash__note">
<template #title>
界面按里程碑推进:<strong>M1</strong> 任务与表单配置 → <strong>M2</strong> 名册与门禁 →
<strong>M3</strong> 填报主流程(改名 / 压缩)→ <strong>M5</strong> 审核 → <strong>M6</strong> 导出打包。
左侧标「待建」的菜单点进去能看到它属于哪个里程碑。
<a-spin v-else :loading="boardLoading" style="display: block">
<!-- 进度四格:应填 / 已提交 / 已通过 / 待审 -->
<a-row :gutter="12">
<a-col v-for="c in boardCards" :key="c.label" :xs="12" :md="6">
<div class="dash__board-card">
<div class="dash__board-label">{{ c.label }}</div>
<div class="dash__board-num" :class="c.tone">{{ c.value }}</div>
<div class="dash__board-foot">{{ c.foot }}</div>
</div>
</a-col>
</a-row>
<a-row :gutter="16" class="dash__board-tables">
<a-col :xs="24" :lg="12">
<div class="dash__board-title">分班进度</div>
<!--
⚠️ 提交率与通过率**分两列**,不合并成一列"完成率":
已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。
-->
<a-table
:data="stat?.byOrg ?? []"
:pagination="false"
size="small"
:bordered="{ cell: true }"
:scroll="{ y: 240 }"
>
<template #columns>
<a-table-column title="班级" data-index="orgName" ellipsis tooltip />
<a-table-column title="应填" data-index="rosterTotal" :width="70" align="center" />
<a-table-column title="已提交" data-index="submittedCount" :width="80" align="center" />
<a-table-column title="已通过" data-index="approvedCount" :width="80" align="center" />
<a-table-column title="提交率" data-index="submitRate" :width="86" align="center" />
</template>
</a-table>
</a-col>
<a-col :xs="24" :lg="12">
<div class="dash__board-title">
审核情况
<span class="dash__board-note">
时效口径:班级 {{ audit?.classTimeoutHours ?? '—' }} 小时 /
后台 {{ audit?.adminTimeoutHours ?? '—' }} 小时
</span>
</div>
<a-table
:data="auditRows"
:pagination="false"
size="small"
:bordered="{ cell: true }"
>
<template #columns>
<a-table-column title="指标" data-index="label" :width="150" />
<a-table-column title="值" data-index="value" />
<a-table-column title="说明" data-index="foot" ellipsis tooltip />
</template>
</a-table>
</a-col>
</a-row>
<template v-if="(audit?.byReason ?? []).length">
<div class="dash__board-title">驳回原因分布(前几名)</div>
<a-space wrap>
<a-tag v-for="r in audit?.byReason ?? []" :key="r.reasonCode" color="orange">
{{ r.reasonText }} × {{ r.count }}
</a-tag>
</a-space>
</template>
</a-alert>
</a-spin>
</a-card>
</GiPageLayout>
</template>
@@ -76,7 +146,9 @@
<script setup lang="ts">
import type { OrgTreeVO } from '@/apis/xt/org'
import { IconFolder, IconIdcard, IconSafe, IconStamp } from '@arco-design/web-vue/es/icon'
import * as auditApi from '@/apis/xt/audit'
import * as orgApi from '@/apis/xt/org'
import * as taskApi from '@/apis/xt/task'
import * as tenantApi from '@/apis/xt/tenant'
import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes'
import { useUserStore } from '@/stores'
@@ -145,6 +217,119 @@ const stats = computed(() => [
}
])
// ── 数据看板(M7-01/02)─────────────────────────
const boardTaskId = ref<number | undefined>(undefined)
const taskOptions = ref<Array<{ label: string, value: number }>>([])
const taskLoading = ref(false)
const boardLoading = ref(false)
const stat = ref<taskApi.TaskStatisticsVO | null>(null)
const audit = ref<auditApi.AuditStatisticsVO | null>(null)
/** 进度四格。比率直接用服务端给的字符串(`—` 也是它给的,不要在前端算)。 */
const boardCards = computed(() => {
const s = stat.value?.stat
return [
{
label: '应填',
value: s?.rosterTotal ?? '—',
foot: '名册人数',
tone: ''
},
{
label: '已提交',
value: s?.submittedCount ?? '—',
foot: `提交率 ${s?.submitRate ?? '—'}`,
tone: ''
},
{
label: '已通过',
value: s?.approvedCount ?? '—',
foot: `通过率 ${s?.passRate ?? '—'}`,
tone: 'is-success'
},
{
label: '待审',
value: audit.value?.pendingCount ?? '—',
foot: audit.value?.timeoutCount
? `其中超时 ${audit.value.timeoutCount} 件`
: '没有超时的',
tone: audit.value?.timeoutCount ? 'is-warn' : ''
}
]
})
const auditRows = computed(() => {
const a = audit.value
if (!a) return []
return [
{ label: '待审', value: a.pendingCount, foot: '还在队列里' },
{ label: '已通过', value: a.approvedCount, foot: '' },
{ label: '已驳回', value: a.rejectedCount, foot: '' },
{
label: '平均审核时长',
value: fmtHours(a.avgAuditHours),
foot: a.avgAuditHours === null || a.avgAuditHours === undefined
? '还没有已审的记录'
: '从提交到审完的平均间隔'
},
{ label: '超时未审', value: a.timeoutCount, foot: '超过时效口径的件数' }
]
})
/**
* 把"小时"这个数字**按量级换单位**显示。
*
* ⚠️ 三种值必须分开,别用同一个 `0` 表达:
* - `null` = **还没有样本**(不是"审核很快");
* - `0` = 有样本,但不足 1 分钟(E2E 的演示数据就是这样:提交与审核差几秒);
* - 其它按分钟/小时显示。
*
* 直接显示 `0.0 小时` 会让人读成"零小时",而那是另一个结论。
*/
function fmtHours(h: number | null | undefined): string {
if (h === null || h === undefined) return '—'
if (h <= 0) return '不到 1 分钟'
if (h < 1) return `${Math.max(1, Math.round(h * 60))} 分钟`
return h >= 10 ? `${Math.round(h)} 小时` : `${h} 小时`
}
async function loadTasks() {
taskLoading.value = true
try {
const res = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 })
const list = res.data?.list ?? []
taskOptions.value = list.map((x) => ({
label: `${x.taskName}(${taskApi.TASK_STATUS_TEXT[x.status] ?? ''})`,
value: x.id
}))
// 默认选第一个还在收集中的任务 —— 那才是看板要回答的问题
const collecting = list.find((x) => x.status === taskApi.TASK_STATUS_COLLECTING)
if (collecting) {
boardTaskId.value = collecting.id
loadBoard()
}
} finally {
taskLoading.value = false
}
}
async function loadBoard() {
if (!boardTaskId.value) return
boardLoading.value = true
try {
// 两个接口互不依赖,并行取;任一个失败不影响另一个显示
const [s, a] = await Promise.allSettled([
taskApi.getTaskStatistics(boardTaskId.value),
auditApi.getAuditStatistics({ taskId: boardTaskId.value })
])
stat.value = s.status === 'fulfilled' ? s.value.data : null
audit.value = a.status === 'fulfilled' ? a.value.data : null
} finally {
boardLoading.value = false
}
}
function countNodes(nodes: OrgTreeVO[]): number {
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
}
@@ -165,6 +350,8 @@ onMounted(async () => {
}
}
loading.value = false
// 看板要任务列表;没有 task:view 权限的角色会拿到空列表,界面显示空态即可
loadTasks()
})
</script>
@@ -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;
}