feat(pc): 工作台换成真实数据看板 + 「待建」页不再说谎
工作台底部原来是一张写着「M0 后端已完成,界面建设中」的进度卡,而项目已是 120/121 个 接口可用——首页上说一件不真的事比空着更糟。改成真实看板:选任务 → 进度四格 + 分班进度 + 审核情况 + 驳回原因分布;提交率与通过率分两列;平均审核时长按量级换单位。 名单管理/材料规则/表单设计三个菜单项对应的功能早就做好了(入口在「收集任务」每行的 链接里),却一直显示「这个页面还没做」。给 XtMenu 加 entryAt:有入口就指路 + 给跳转, 没有才说没做(角色与授权确实没做,属二期)。并删掉那句会静默过期的「已实现页面」清单。 验证:浏览器实测四个菜单与看板出数;pnpm typecheck 通过、eslint 0 错。
This commit is contained in:
@@ -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`)
|
||||
}
|
||||
|
||||
Vendored
+12
@@ -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']
|
||||
|
||||
@@ -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: '平台运营',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user