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