feat(roster): 名册导入前端 —— 任务发布现在真的能在界面上走通了
第 2 步。在此之前「配置 → 任务就绪」缺了**名册**那一环:班级能配、材料能配、
表单能配,但名册只能靠 SQL 造,于是任务在界面上**根本发布不了**(40016 名册非空)。
一、新增(frontend-pc/)
src/apis/xt/roster.ts 导入(multipart)+ 分页 + 类型 + 状态字典
src/views/task/TaskRosterDrawer.vue 「名单管理」抽屉:导入 → 体检报告 → 名册列表
views/task/index.vue 加「名单管理」入口;已截止/已归档只读仍可查看
config/xt-routes.ts 顶层「名单管理」菜单说明改为指向任务行
二、★ 验证了"发布真的通"(e2e-m2-roster.py 第 8 节)
配覆盖班级 → 配必传材料 → **发布成功** → 状态变「收集中」→ stat 仍带分母。
force=true 只跳过 P1 提醒,那 5 项阻断级校验 force 也绕不过 —— 所以是真链路。
三、界面上五个刻意的设计
1. 先体检再导入(两步):实测表头在第 1 行或第 6 行,一步导入只会看到
"成功 0 条",然后以为文件坏了
2. 明说「前面那 5 行是标题与说明,已跳过,这不是导错了」—— 不解释的话
用户会去改自己那份本来正确的表格
3. 行号 + 列字母 + 原值 + 原因四样一起给,用户拿着就能回去改 Excel
4. dryRun 默认 true(服务端也默认 true):不显式要求就不写库
5. 阻断行与警告行分两张表:混在一起会让人以为整批都没进
原因后面跟**机器码**而不是中文标签(第一版给中文,和上面那句原因重复了一遍)
四、顺带修掉一处「界面在说谎」
名册导进来了,任务列表却还显示「待导入名册」—— TaskStatVO 恒为 null 是 M1 的
既有缺口(注释写着"名册为空时返回 null,由界面显示待导入名册")。现在名册有了:
· TaskService.fillStats():一次算一页(两条批量查询,不逐任务查)
· **名册为空时仍返回 null**,口径没有放宽 —— 显示 0% 看起来像"没人填",
而真相是"还没有分母"
· abnormalCount / avgAuditHours 仍为 null(要 M4 的校验表 / M5 的审核记录)
· e2e-m1-task 那条断言改成措辞更准的"名册为空时 stat 为 null"(意图不变),
并在 e2e-m2-roster 补 4 条"名册有值后 stat 该有值"
这条也是第 2 步**自己制造**的问题:能导名册之后,"待导入名册"从诚实变成说谎。
五、为什么顶层「名单管理」菜单还是"待建"
名册是**按任务**的,独立页得先选任务(多一步且容易选错);它和「配置班级」
「材料规则」「表单设计」是同一层的配置。报考范围确认(M2-04/05)会接着做进
同一个抽屉,那时也不需要新页面。所以改的是那句已过期的"对应里程碑 M2"。
六、浏览器验证(真链路,只绕开"手指点选")
文件框没法自动化,但可以把夹具放到 frontend-pc/public/ 再喂进去:
fetch 字节 → new File → DataTransfer → input.files → dispatch change
实测:3 人被正确导入、脱敏证件号、报考三项、扩展字段、状态列、搜索全对。
夹具用完即删。
七、回归
全量 622 条断言、0 失败(名册 E2E 62 → 67)
frontend-pc: vue-tsc 0 错、eslint 目标文件 0 错
verify-docs.py 19 项全通过
八、两个流程教训(已写进交接摘要)
· `Test-Path jar` 不能当作"打包成功":这次 mvn 失败而旧 jar 还在,
于是检查得到"打包 ✓",测试对着旧 jar 跑,四条新断言全红。
必须看退出码 / BUILD SUCCESS。
· `call()` 返回的是 (status, body) 元组不是字典 —— 我新写的那节忘了解包。
与第 1 步"已存在判断写反"同类:新写的代码最容易在细节上错。
九、下一步(第 3 步)
报考范围聚合与一致性体检(M2-04/05)—— 产品的核心增量。
先确认:sys_eval_plan 那几张"评价计划"表还没落地(FR-311 未做),
需要先定"计划从哪来"。
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 名册接口(《03-接口设计》§8.1 ~ §8.2)。
|
||||
*
|
||||
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
|
||||
* 解包后 `http.get<T>()` 得到 `{ code: 200, data: T }`。
|
||||
*
|
||||
* ## 三件界面上必须照顾到的事
|
||||
*
|
||||
* 1. **表头行不固定**。实测两份真实文件的表头分别在第 1 行和第 6 行,工作表名也不同。
|
||||
* 所以导入接口不是"传个文件就完事":它先回一份**体检报告**(表头在第几行、
|
||||
* 每个候选命中几列、每列映射到哪个字段),由界面呈现给人确认,
|
||||
* 确认之后才用 `dryRun=false` 真导入。
|
||||
* ⚠️ 界面上要把"系统在第 6 行找到表头"讲清楚 —— 否则用户看到"第 6 行"
|
||||
* 会以为系统导错了,而真相是**前 5 行本来就是标题与说明**。
|
||||
*
|
||||
* 2. **部分成功是设计目标**。42 行里有 2 行号码写错,不该让另外 40 个人白等,
|
||||
* 但也不能把那 2 行悄悄丢掉。所以 `errors` / `warnings` 是**逐行**的
|
||||
* (行号 + 列字母 + 原值 + 原因),界面必须让用户能拿着行号回去改 Excel。
|
||||
*
|
||||
* 3. **证件号一律只有脱敏值**。列表里是 `idCardMask`,报错里的 `value` 也是脱敏的 ——
|
||||
* 界面上不需要(也不该)显示完整号码。
|
||||
*/
|
||||
import http from '@/utils/http'
|
||||
|
||||
/** 一列的映射结果。 */
|
||||
export interface RosterColumnReport {
|
||||
/** 列字母(`A`~),用户能对着 Excel 找 */
|
||||
col: string
|
||||
/** 表头原文 */
|
||||
title: string
|
||||
/** 落到哪:官方字段名,或 `ext_json.xxx`;null = 忽略该列 */
|
||||
mappedTo: string | null
|
||||
/** 是否识别为官方 21 列之一 */
|
||||
matched: boolean
|
||||
}
|
||||
|
||||
/** 一处问题。 */
|
||||
export interface RosterRowIssue {
|
||||
/** Excel 里的真实行号(从 1 开始) */
|
||||
row: number
|
||||
col: string | null
|
||||
field: string
|
||||
/** 原值(证件号已脱敏) */
|
||||
value: string | null
|
||||
/** 人话原因,直接显示 */
|
||||
reason: string
|
||||
/** 机器码:IDCARD_CHECKSUM / DUP_IDCARD / ROW_LOCKED … */
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface RosterImportSummary {
|
||||
uniquePersons: number
|
||||
dupRows: number
|
||||
idcardInvalid: number
|
||||
nameSuspect: number
|
||||
phoneInvalid: number
|
||||
}
|
||||
|
||||
/** 导入体检报告(§8.1)。`dryRun=true` 时 `created`/`updated` 是"如果真导会怎样"。 */
|
||||
export interface RosterImportReport {
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
headerRow: number
|
||||
sheetName: string | null
|
||||
headerCandidates: { row: number, score: number }[]
|
||||
headerConfirmed: boolean
|
||||
columns: RosterColumnReport[]
|
||||
errors: RosterRowIssue[]
|
||||
warnings: RosterRowIssue[]
|
||||
summary: RosterImportSummary
|
||||
dryRun: boolean
|
||||
mode: 'APPEND' | 'REPLACE' | string
|
||||
created: number
|
||||
updated: number
|
||||
removed: number
|
||||
}
|
||||
|
||||
/** 名册项(§3.6)。证件号与手机号只有脱敏值。 */
|
||||
export interface RosterVO {
|
||||
id: number
|
||||
seqNo: number | null
|
||||
studentNo: string | null
|
||||
realName: string
|
||||
/** 脱敏证件号 */
|
||||
idCardMask: string | null
|
||||
/** 脱敏手机号 */
|
||||
phoneMask: string | null
|
||||
orgId: number | null
|
||||
orgName: string | null
|
||||
occupation: string | null
|
||||
worktype: string | null
|
||||
jobLevel: string | null
|
||||
education: string | null
|
||||
isStudent: number | null
|
||||
school: string | null
|
||||
major: string | null
|
||||
enrollDate: string | null
|
||||
workUnit: string | null
|
||||
isRetake: number | null
|
||||
retakeTheory: string | null
|
||||
retakePractice: string | null
|
||||
retakeOverall: string | null
|
||||
workYears: number | null
|
||||
certOccupation: string | null
|
||||
certLevel: string | null
|
||||
certNo: string | null
|
||||
extJson: Record<string, unknown> | null
|
||||
/** 0 未绑定 / 1 已绑定 */
|
||||
bindStatus: number
|
||||
bindTime: string | null
|
||||
/** 1 门禁核验 / 2 管理员指定 */
|
||||
bindType: number | null
|
||||
/** 0 未提交 / 1 草稿 / 2 待审 / 3 已通过 / 4 已驳回 */
|
||||
submitStatus: number
|
||||
}
|
||||
|
||||
export interface RosterImportOptions {
|
||||
/** 手动指定表头行(1 起);不传则自动探测 */
|
||||
headerRow?: number
|
||||
/** 手动指定工作表;不传则取第一个有数据的 */
|
||||
sheetName?: string
|
||||
/** `APPEND`(默认,按证件号去重合并)/ `REPLACE`(整表替换) */
|
||||
mode?: 'APPEND' | 'REPLACE'
|
||||
/** true = 只体检不落库(**推荐默认**) */
|
||||
dryRun?: boolean
|
||||
/** 整批归到哪个班级 */
|
||||
orgId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入名册(multipart)。
|
||||
*
|
||||
* ⚠️ 超时给到 180s:5000 行的体检要遍历整表 + 逐行算校验位,
|
||||
* 而默认的 30s 在真实的大名单上会超时 —— 而超时的表现是"点了没反应",
|
||||
* 用户只会重复点,于是同一份文件被导好几次。
|
||||
*/
|
||||
export function importRoster(taskId: number, file: File, opts: RosterImportOptions = {}) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('dryRun', String(opts.dryRun ?? true))
|
||||
if (opts.headerRow !== undefined) {
|
||||
fd.append('headerRow', String(opts.headerRow))
|
||||
}
|
||||
if (opts.sheetName) {
|
||||
fd.append('sheetName', opts.sheetName)
|
||||
}
|
||||
if (opts.mode) {
|
||||
fd.append('mode', opts.mode)
|
||||
}
|
||||
if (opts.orgId !== undefined) {
|
||||
fd.append('orgId', String(opts.orgId))
|
||||
}
|
||||
// 不要手动设 Content-Type:axios 会带着 boundary 自己设,
|
||||
// 手动写成 'multipart/form-data' 反而会丢掉 boundary,服务端直接解析失败
|
||||
return http.request<RosterImportReport>({
|
||||
method: 'post',
|
||||
url: `/tasks/${taskId}/roster/import`,
|
||||
data: fd,
|
||||
timeout: 180 * 1000
|
||||
})
|
||||
}
|
||||
|
||||
export interface RosterQuery {
|
||||
keyword?: string
|
||||
orgId?: number
|
||||
submitStatus?: number
|
||||
bindStatus?: number
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 名册分页(§8.2)。
|
||||
*
|
||||
* `keyword` 既能搜姓名(模糊),**也能粘一整串身份证号**(走 hash 等值)——
|
||||
* 服务端按"看起来是不是身份证号"自动分支。密文搜不了 LIKE,这是加密的必然代价。
|
||||
*/
|
||||
export function getRosterList(taskId: number, params: RosterQuery = {}) {
|
||||
return http.get<{ list: RosterVO[], total: number }>(`/tasks/${taskId}/roster`, params)
|
||||
}
|
||||
|
||||
/** 填报单状态(§2.7)。名册行上的 `submitStatus` 与填报单同口径。 */
|
||||
export const SUBMIT_STATUS: Record<number, { text: string, color: string }> = {
|
||||
0: { text: '未提交', color: 'gray' },
|
||||
1: { text: '草稿', color: 'gray' },
|
||||
2: { text: '待初审', color: 'orange' },
|
||||
3: { text: '已通过', color: 'green' },
|
||||
4: { text: '已驳回', color: 'red' }
|
||||
}
|
||||
Vendored
+4
@@ -49,6 +49,8 @@ declare module 'vue' {
|
||||
AOption: typeof import('@arco-design/web-vue')['Option']
|
||||
APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm']
|
||||
APopover: typeof import('@arco-design/web-vue')['Popover']
|
||||
ARadio: typeof import('@arco-design/web-vue')['Radio']
|
||||
ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup']
|
||||
ARow: typeof import('@arco-design/web-vue')['Row']
|
||||
AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar']
|
||||
ASelect: typeof import('@arco-design/web-vue')['Select']
|
||||
@@ -129,6 +131,8 @@ declare global {
|
||||
const AOption: typeof import('@arco-design/web-vue')['Option']
|
||||
const APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm']
|
||||
const APopover: typeof import('@arco-design/web-vue')['Popover']
|
||||
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']
|
||||
const AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar']
|
||||
const ASelect: typeof import('@arco-design/web-vue')['Select']
|
||||
|
||||
@@ -62,7 +62,8 @@ export const XT_MENUS: XtMenu[] = [
|
||||
group: 'collect',
|
||||
sort: 12,
|
||||
ready: false,
|
||||
note: '导入名册、报考范围确认、门禁名单维护。对应里程碑 M2。'
|
||||
note: '名册是**按任务**的,所以入口在「收集任务」的每一行里:点「名单管理」。'
|
||||
+ '导入、体检报告、名册列表都在那里;报考范围确认(M2-04/05)会接着做进同一个抽屉。'
|
||||
},
|
||||
{
|
||||
key: 'join',
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" :width="1120" :mask-closable="false" :footer="false"
|
||||
unmount-on-close @cancel="close">
|
||||
<template #title>
|
||||
<span>名单管理</span>
|
||||
<span class="roster__task">{{ taskName }}</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
已截止 / 已归档只读。与服务端同口径(服务端也会拒),
|
||||
界面上先说清楚 —— 否则用户选完文件点「开始体检」才被告知不能导。
|
||||
注意:**体检(dryRun)不受状态限制**,看报告是安全的,所以只有"确认导入"被禁。
|
||||
-->
|
||||
<a-alert v-if="readonly" type="normal" :show-icon="false" class="roster__alert">
|
||||
任务已{{ readonlyReason }},名册不可再改,只能查看。
|
||||
(仍然可以「开始体检」看一份表格有什么问题,只是不能导入。)
|
||||
</a-alert>
|
||||
|
||||
<!--
|
||||
收集中改名册要明确警告:学生可能正在核验。
|
||||
删掉/改掉某一行,那个学生下次扫码就进不来了;而改「报考职业」这类锁定字段,
|
||||
等于把他已经确认过的材料变成另一种认定。
|
||||
-->
|
||||
<a-alert v-else-if="collecting" type="warning" class="roster__alert">
|
||||
<template #title>
|
||||
任务正在收集中,<strong>改动立刻生效</strong>:学生下一次核验就会看到新名单。
|
||||
已经核验过的人不受影响(他们的名册项<b>不会被覆盖</b>,系统会跳过并提示)。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- ── 一、导入 ─────────────────────────────────── -->
|
||||
<div class="roster__section">导入名册</div>
|
||||
<a-alert type="info" :show-icon="false" class="roster__intro">
|
||||
<template #title>
|
||||
表格里需要有 <strong>姓名</strong> 与 <strong>身份证号</strong> 两列,
|
||||
其余按官方模板的列名(学历、联系电话、报考职业…)识别。
|
||||
<strong>表头在第几行不用管</strong>,系统会自己找;认错了可以手动指定。
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="roster__hint">
|
||||
名册是<b>准入名单</b>:学生必须先填写姓名与身份证号、与名册匹配上,才能进入填报。
|
||||
所以这一份名单就是"谁能填、填的是谁"的唯一依据。
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-row :gutter="16" class="roster__form">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="整批归到哪个班级" class="roster__item">
|
||||
<a-select v-model="orgId" placeholder="不指定(名册项不带班级)" allow-clear
|
||||
:loading="orgLoading" size="small">
|
||||
<a-option v-for="c in classOptions" :key="c.id" :value="c.id">{{ c.label }}</a-option>
|
||||
</a-select>
|
||||
<template #extra>
|
||||
<span class="roster__hint">官方模板里没有班级列,所以这里选了就整批都算这个班</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="导入方式" class="roster__item">
|
||||
<a-radio-group v-model="mode" type="button" size="small">
|
||||
<a-radio value="APPEND">合并导入</a-radio>
|
||||
<a-radio value="REPLACE">整表替换</a-radio>
|
||||
</a-radio-group>
|
||||
<template #extra>
|
||||
<span class="roster__hint">{{ modeHint }}</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="Excel 文件" class="roster__item">
|
||||
<div class="roster__pick">
|
||||
<!--
|
||||
用原生 input 而不是 a-upload:本场景只需要"选一个本地文件然后自己发请求",
|
||||
原生 input 拿原始 File 的行为是完全确定的。与账号导入弹窗同一做法。
|
||||
-->
|
||||
<input ref="fileInput" type="file" accept=".xlsx,.xls" class="roster__file"
|
||||
@change="onFileChange" />
|
||||
<a-button size="small" :disabled="readonly" @click="pickFile">
|
||||
<template #icon><IconUpload /></template>
|
||||
选择文件
|
||||
</a-button>
|
||||
<span v-if="file" class="roster__filename" :title="file.name">{{ file.name }}</span>
|
||||
<span v-else class="roster__hint">.xlsx / .xls,≤10MB</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-space class="roster__tool" wrap>
|
||||
<a-button type="primary" size="small" :loading="checking" :disabled="!file || readonly"
|
||||
@click="runDryRun()">
|
||||
{{ report ? '按当前设置重新体检' : '开始体检' }}
|
||||
</a-button>
|
||||
<span class="roster__hint">
|
||||
体检<b>不会写库</b>:先看清楚系统把哪一行当表头、哪几行有问题,确认无误再导入。
|
||||
</span>
|
||||
</a-space>
|
||||
|
||||
<!-- ── 二、体检报告 ─────────────────────────────── -->
|
||||
<template v-if="report">
|
||||
<a-divider />
|
||||
|
||||
<a-row :gutter="12" class="roster__stats">
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat">
|
||||
<div class="roster__stat-num">{{ report.total }}</div>
|
||||
<div class="roster__stat-label">数据行</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat roster__stat--ok">
|
||||
<div class="roster__stat-num">{{ report.success }}</div>
|
||||
<div class="roster__stat-label">可入库</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat" :class="report.failed ? 'roster__stat--bad' : ''">
|
||||
<div class="roster__stat-num">{{ report.failed }}</div>
|
||||
<div class="roster__stat-label">被阻断</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat">
|
||||
<div class="roster__stat-num">{{ report.dryRun ? plannedNew : report.created }}</div>
|
||||
<div class="roster__stat-label">{{ report.dryRun ? '将新增' : '已新增' }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat">
|
||||
<div class="roster__stat-num">{{ report.dryRun ? plannedUpdate : report.updated }}</div>
|
||||
<div class="roster__stat-label">{{ report.dryRun ? '将更新' : '已更新' }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat" :class="report.warnings.length ? 'roster__stat--warn' : ''">
|
||||
<div class="roster__stat-num">{{ report.warnings.length }}</div>
|
||||
<div class="roster__stat-label">警告(不影响入库)</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-alert :type="headerAlertType" :show-icon="false" class="roster__header-alert">
|
||||
<template #title>
|
||||
系统把第 <strong>{{ report.headerRow }}</strong> 行当成了表头
|
||||
<span v-if="report.headerRow > 1">
|
||||
—— 它<strong>前面那 {{ report.headerRow - 1 }} 行</strong>是标题与说明,已跳过,
|
||||
这不是导错了
|
||||
</span>
|
||||
。
|
||||
<span v-if="report.sheetName">(工作表:{{ report.sheetName }})</span>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="report.headerCandidates.length > 1" class="roster__hint">
|
||||
另外还有 {{ report.headerCandidates.length - 1 }} 行也像表头(命中列数:
|
||||
{{ report.headerCandidates.map((c) => `第${c.row}行=${c.score}`).join(',') }})。
|
||||
如果系统选错了,在下面手动指定。
|
||||
</div>
|
||||
<div class="roster__hint">
|
||||
缺了哪些列、多出来的列去了哪,看下面的「列识别结果」——
|
||||
多出来的列不会丢,会原样存进扩展字段。
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-space class="roster__tool" wrap>
|
||||
<span class="roster__hint">手动指定表头行:</span>
|
||||
<a-input-number v-model="manualHeaderRow" :min="1" :max="200" size="small"
|
||||
:style="{ width: '110px' }" placeholder="行号" />
|
||||
<a-button size="small" :loading="checking" :disabled="!file" @click="runDryRun(manualHeaderRow)">
|
||||
按这一行重新体检
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
<!-- 列识别结果 -->
|
||||
<a-table :data="report.columns" :pagination="false" row-key="col" size="small"
|
||||
:max-height="200" :scroll="{ y: 200 }" class="roster__table">
|
||||
<template #columns>
|
||||
<a-table-column title="列" :width="60" align="center" data-index="col" />
|
||||
<a-table-column title="表头原文" data-index="title" :width="200" ellipsis tooltip />
|
||||
<a-table-column title="识别为" :width="180">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.matched" class="roster__mapped">{{ fieldName(record.mappedTo) }}</span>
|
||||
<span v-else-if="record.mappedTo" class="roster__muted">
|
||||
非官方列 → 存进扩展字段
|
||||
</span>
|
||||
<span v-else class="roster__muted">忽略</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="说明" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<a-tag v-if="record.matched" color="green" size="small">官方列</a-tag>
|
||||
<a-tag v-else-if="record.mappedTo" size="small">保留</a-tag>
|
||||
<a-tag v-else size="small">空列</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 阻断行 -->
|
||||
<div class="roster__section roster__section--bad">
|
||||
没能入库的行({{ report.errors.length }})
|
||||
</div>
|
||||
<a-empty v-if="!report.errors.length" description="没有阻断行" />
|
||||
<a-table v-else :data="report.errors" :pagination="false" row-key="key" size="small"
|
||||
:max-height="240" :scroll="{ y: 240 }">
|
||||
<template #columns>
|
||||
<a-table-column title="Excel 行" :width="90" align="center">
|
||||
<template #cell="{ record }">第 {{ record.row }} 行</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="列" :width="60" align="center">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.col">{{ record.col }} 列</span>
|
||||
<span v-else class="roster__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="原值" :width="170" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.value">{{ record.value }}</span>
|
||||
<span v-else class="roster__muted">(空)</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="原因" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
{{ record.reason }}
|
||||
<!-- 标签给的是**机器码**(排查/提单时有用),不重复上面那句人话 -->
|
||||
<code class="roster__code">{{ record.code }}</code>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 警告行 -->
|
||||
<div class="roster__section roster__section--warn">
|
||||
有警告、但仍然会入库的行({{ report.warnings.length }})
|
||||
</div>
|
||||
<a-empty v-if="!report.warnings.length" description="没有警告" />
|
||||
<a-table v-else :data="report.warnings" :pagination="false" row-key="key" size="small"
|
||||
:max-height="200" :scroll="{ y: 200 }">
|
||||
<template #columns>
|
||||
<a-table-column title="Excel 行" :width="90" align="center">
|
||||
<template #cell="{ record }">第 {{ record.row }} 行</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="原值" :width="170" ellipsis tooltip>
|
||||
<template #cell="{ record }">{{ record.value || '(空)' }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="原因" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
{{ record.reason }}
|
||||
<code class="roster__code">{{ record.code }}</code>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<div class="roster__footer">
|
||||
<a-space>
|
||||
<a-button size="small" @click="resetReport">重新选文件</a-button>
|
||||
<a-button type="primary" size="small" :loading="importing"
|
||||
:disabled="readonly || !report.success" @click="confirmImport">
|
||||
{{ importButtonText }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
<div v-if="!report.success" class="roster__hint">
|
||||
没有任何可入库的行,请按上面的原因改好 Excel 再来。
|
||||
</div>
|
||||
<div v-else-if="report.dryRun" class="roster__hint">
|
||||
上面这些数字是<b>预计</b>结果;导入只处理有问题的行以外的那
|
||||
{{ report.success }} 行,其余行改好后可以再次导入(同证件号会合并更新,不会重复)。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── 三、名册 ─────────────────────────────────── -->
|
||||
<a-divider />
|
||||
<div class="roster__section">
|
||||
名册({{ total }} 人)
|
||||
<span v-if="stat.t">已核验 {{ stat.bound }} 人 · 已提交 {{ stat.submitted }} 人</span>
|
||||
</div>
|
||||
|
||||
<a-space class="roster__tool" wrap>
|
||||
<a-input-search v-model="query.keyword" placeholder="姓名,或粘贴完整身份证号" allow-clear
|
||||
class="roster__search" size="small" @search="search" @clear="search" />
|
||||
<a-select v-model="query.submitStatus" placeholder="填报状态" allow-clear size="small"
|
||||
class="roster__filter" @change="search">
|
||||
<a-option v-for="(v, k) in SUBMIT_STATUS" :key="k" :value="Number(k)">{{ v.text }}</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.bindStatus" placeholder="核验状态" allow-clear size="small"
|
||||
class="roster__filter" @change="search">
|
||||
<a-option :value="0">未核验</a-option>
|
||||
<a-option :value="1">已核验</a-option>
|
||||
</a-select>
|
||||
<a-button size="small" @click="loadList">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
<a-table :data="rows" :loading="listLoading" row-key="id" size="small" :pagination="pagination"
|
||||
:scroll="{ x: 940 }" class="roster__table" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="序号" :width="64" align="center">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.seqNo !== null">{{ record.seqNo }}</span>
|
||||
<span v-else class="roster__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="姓名 / 学历" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<div class="roster__name">{{ record.realName }}</div>
|
||||
<div class="roster__muted">{{ record.education || '—' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="证件号 / 电话" :width="190">
|
||||
<template #cell="{ record }">
|
||||
<!-- 后端只回脱敏值;这里不做任何"还原",界面上不该有完整号码 -->
|
||||
<div class="roster__mono">{{ record.idCardMask || '—' }}</div>
|
||||
<div class="roster__muted">{{ record.phoneMask || '未填' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="班级" :width="110" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.orgName">{{ record.orgName }}</span>
|
||||
<span v-else class="roster__muted">未指定</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="报考(职业 / 工种 / 等级)" :width="230">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.occupation || '—' }}</div>
|
||||
<div class="roster__muted">
|
||||
{{ record.worktype || '—' }} · {{ record.jobLevel || '—' }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="状态" :width="130" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="SUBMIT_STATUS[record.submitStatus]?.color ?? 'gray'" size="small">
|
||||
{{ SUBMIT_STATUS[record.submitStatus]?.text ?? '未知' }}
|
||||
</a-tag>
|
||||
<div class="roster__muted">
|
||||
{{ record.bindStatus === 1 ? '已核验' : '未核验' }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="其它" :width="96" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tooltip v-if="record.extJson && Object.keys(record.extJson).length"
|
||||
:content="extText(record.extJson)">
|
||||
<a-tag size="small">扩展字段 {{ Object.keys(record.extJson).length }}</a-tag>
|
||||
</a-tooltip>
|
||||
<span v-else class="roster__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RosterImportReport, RosterVO } from '@/apis/xt/roster'
|
||||
import type { TaskVO } from '@/apis/xt/task'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconRefresh, IconUpload } from '@arco-design/web-vue/es/icon'
|
||||
import * as orgApi from '@/apis/xt/org'
|
||||
import * as rosterApi from '@/apis/xt/roster'
|
||||
import { SUBMIT_STATUS } from '@/apis/xt/roster'
|
||||
|
||||
/**
|
||||
* 任务「名单管理」抽屉(《03-接口设计》§8.1 ~ §8.2)—— FR-501 / FR-502。
|
||||
*
|
||||
* ## 为什么这个界面必须存在
|
||||
*
|
||||
* 发布任务的 5 项前置检查里有一项是「名册不能为空」(40016)。在它之前,
|
||||
* 名册只能靠直接改数据库来造 —— 而**名册是准入名单**:学生能不能进来填、
|
||||
* 填的是谁,全靠它。也就是说这个界面之前,整条填报链路在界面上是走不通的。
|
||||
*
|
||||
* ## 三处刻意的设计
|
||||
*
|
||||
* 1. **先体检、再导入(两步)**。实测两份真实文件的表头分别在第 1 行和第 6 行,
|
||||
* 工作表名也不同。一步导入的话,用户只会看到"成功 0 条",
|
||||
* 然后以为自己的文件坏了 —— 而真相是系统把标题行当成了表头。
|
||||
* 所以:先把"系统认的是第几行、认成了哪几列、哪几行有问题"摊开给他看,
|
||||
* **确认之后才写库**。
|
||||
*
|
||||
* 2. **把"表头在第 6 行"解释清楚**。看到"第 6 行"的人第一反应是"导错了"。
|
||||
* 所以那句提示里直接写明「它前面那 5 行是标题与说明,已跳过」——
|
||||
* 不解释的话,用户会去改自己那份**本来正确**的表格。
|
||||
*
|
||||
* 3. **行号 + 列字母 + 原值 + 原因,四样一起给**。只给"第 12 行有问题",
|
||||
* 用户还得自己回去找是哪一列;给了列字母与原因,他能直接改完再导一次。
|
||||
* 而"改好再来一次"是这个界面的正常用法(同证件号会合并更新,不会重复)。
|
||||
*/
|
||||
defineOptions({ name: 'TaskRosterDrawer' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const taskId = ref<number | null>(null)
|
||||
const taskName = ref('')
|
||||
const taskStatus = ref(0)
|
||||
|
||||
/** 与服务端 `RosterImportService.EDITABLE_TASK_STATUS` 同口径:{草稿, 收集中, 已暂停} */
|
||||
const EDITABLE = [0, 1, 4]
|
||||
const readonly = computed(() => !EDITABLE.includes(taskStatus.value))
|
||||
const readonlyReason = computed(() => (taskStatus.value === 2 ? '截止' : '归档'))
|
||||
const collecting = computed(() => taskStatus.value === 1)
|
||||
|
||||
// ── 导入 ──────────────────────────────────────────
|
||||
|
||||
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
|
||||
const file = ref<File | null>(null)
|
||||
const mode = ref<'APPEND' | 'REPLACE'>('APPEND')
|
||||
const orgId = ref<number | undefined>(undefined)
|
||||
const checking = ref(false)
|
||||
const importing = ref(false)
|
||||
const report = ref<RosterImportReport | null>(null)
|
||||
const manualHeaderRow = ref<number | undefined>(undefined)
|
||||
/** 整表替换的二次确认只做一次(用户点过一次「确认替换」之后就走真导入) */
|
||||
const replaceConfirmed = ref(false)
|
||||
|
||||
const modeHint = computed(() =>
|
||||
mode.value === 'REPLACE'
|
||||
? '替换:清掉现有名册再导入(已有人核验时会拒绝)'
|
||||
: '合并:按身份证号去重,已有的更新、没有的新增'
|
||||
)
|
||||
|
||||
/** dryRun 时后端把预计结果放在 created/updated 里;这里用同一份数据,只为把标签说准 */
|
||||
const plannedNew = computed(() => report.value?.created ?? 0)
|
||||
const plannedUpdate = computed(() => report.value?.updated ?? 0)
|
||||
|
||||
const headerAlertType = computed(() => (report.value?.headerConfirmed ? 'success' : 'warning'))
|
||||
|
||||
const importButtonText = computed(() => {
|
||||
if (!report.value) return '确认导入'
|
||||
if (report.value.dryRun) {
|
||||
return mode.value === 'REPLACE'
|
||||
? `确认替换:导入这 ${report.value.success} 行`
|
||||
: `确认导入这 ${report.value.success} 行`
|
||||
}
|
||||
return '已完成导入'
|
||||
})
|
||||
|
||||
/**
|
||||
* 列的中文名 —— 报告里回的是字段(`real_name`),直接展示用户看不懂。
|
||||
* 只覆盖官方 21 列;多出来的列报告里已标成"非官方列"。
|
||||
*/
|
||||
const FIELD_NAMES: Record<string, string> = {
|
||||
seq_no: '序号',
|
||||
real_name: '姓名',
|
||||
id_card_no: '身份证号',
|
||||
education: '学历',
|
||||
phone: '联系电话',
|
||||
work_unit: '工作单位',
|
||||
occupation: '报考职业',
|
||||
worktype: '报考工种',
|
||||
job_level: '报考等级',
|
||||
is_retake: '是否补考',
|
||||
retake_theory: '补考理论',
|
||||
retake_practice: '补考实操',
|
||||
retake_overall: '补考综合',
|
||||
is_student: '是否在校生',
|
||||
school: '在读学校',
|
||||
major: '所学专业',
|
||||
enroll_date: '入学时间',
|
||||
work_years: '本职业工作年限',
|
||||
cert_occupation: '已获证书职业工种',
|
||||
cert_level: '已获证书等级',
|
||||
cert_no: '已获证书编号'
|
||||
}
|
||||
|
||||
function fieldName(field: string | null) {
|
||||
if (!field) return '—'
|
||||
return FIELD_NAMES[field] ?? field
|
||||
}
|
||||
|
||||
function extText(ext: Record<string, unknown>) {
|
||||
return Object.entries(ext).map(([k, v]) => `${k}:${String(v)}`).join('\n')
|
||||
}
|
||||
|
||||
// ── 班级下拉 ───────────────────────────────────────
|
||||
|
||||
interface ClassOption {
|
||||
id: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const classOptions = ref<ClassOption[]>([])
|
||||
const orgLoading = ref(false)
|
||||
|
||||
async function loadClasses() {
|
||||
if (classOptions.value.length) {
|
||||
return
|
||||
}
|
||||
orgLoading.value = true
|
||||
try {
|
||||
const res = await orgApi.getOrgTree()
|
||||
const out: ClassOption[] = []
|
||||
const walk = (nodes: orgApi.OrgTreeVO[] | undefined, parent: string) => {
|
||||
for (const n of nodes ?? []) {
|
||||
if (n.orgType === 3) {
|
||||
out.push({ id: n.id, label: parent ? `${parent} / ${n.orgName}` : n.orgName })
|
||||
}
|
||||
walk(n.children, n.orgType === 3 ? parent : n.orgName)
|
||||
}
|
||||
}
|
||||
walk(res.data, '')
|
||||
classOptions.value = out
|
||||
} finally {
|
||||
orgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 名册列表 ───────────────────────────────────────
|
||||
|
||||
const listLoading = ref(false)
|
||||
const rows = ref<RosterVO[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive<{
|
||||
keyword: string
|
||||
submitStatus: number | undefined
|
||||
bindStatus: number | undefined
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}>({ keyword: '', submitStatus: undefined, bindStatus: undefined, pageNum: 1, pageSize: 10 })
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
|
||||
/**
|
||||
* 一句话统计。没有它,用户看不出"名册导进来了没有、有多少人已经开始填",
|
||||
* 而这些恰恰是他打开这个抽屉最想知道的两件事。
|
||||
*/
|
||||
const stat = computed(() => {
|
||||
const bound = rows.value.filter((r) => r.bindStatus === 1).length
|
||||
const submitted = rows.value.filter((r) => r.submitStatus > 0).length
|
||||
return { t: total.value > 0, bound, submitted }
|
||||
})
|
||||
|
||||
async function loadList() {
|
||||
if (!taskId.value) {
|
||||
return
|
||||
}
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await rosterApi.getRosterList(taskId.value, {
|
||||
keyword: query.keyword.trim() || undefined,
|
||||
submitStatus: query.submitStatus,
|
||||
bindStatus: query.bindStatus,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
rows.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
query.pageNum = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
loadList()
|
||||
}
|
||||
|
||||
// ── 打开 / 关闭 ────────────────────────────────────
|
||||
|
||||
async function open(task: TaskVO) {
|
||||
visible.value = true
|
||||
taskId.value = task.id
|
||||
taskName.value = task.taskName
|
||||
taskStatus.value = task.status
|
||||
resetReport()
|
||||
query.keyword = ''
|
||||
query.submitStatus = undefined
|
||||
query.bindStatus = undefined
|
||||
query.pageNum = 1
|
||||
await Promise.all([loadClasses(), loadList()])
|
||||
}
|
||||
|
||||
function close() {
|
||||
resetReport()
|
||||
}
|
||||
|
||||
function resetReport() {
|
||||
file.value = null
|
||||
report.value = null
|
||||
manualHeaderRow.value = undefined
|
||||
mode.value = 'APPEND'
|
||||
orgId.value = undefined
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function pickFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const picked = input.files?.[0] ?? null
|
||||
if (picked && picked.size > 10 * 1024 * 1024) {
|
||||
Message.warning('文件超过 10MB,请拆分后分批导入')
|
||||
input.value = ''
|
||||
file.value = null
|
||||
return
|
||||
}
|
||||
file.value = picked
|
||||
report.value = null
|
||||
}
|
||||
|
||||
// ── 体检 / 导入 ────────────────────────────────────
|
||||
|
||||
async function runDryRun(headerRow?: number) {
|
||||
if (!file.value || !taskId.value) {
|
||||
return
|
||||
}
|
||||
checking.value = true
|
||||
try {
|
||||
const res = await rosterApi.importRoster(taskId.value, file.value, {
|
||||
dryRun: true,
|
||||
mode: mode.value,
|
||||
orgId: orgId.value,
|
||||
headerRow: headerRow ?? undefined
|
||||
})
|
||||
report.value = withKeys(res.data ?? null)
|
||||
manualHeaderRow.value = report.value?.headerRow
|
||||
} catch (e) {
|
||||
// 40028(认不出表头)/ 40017(缺关键列)是**预期内的结果**,不是系统故障:
|
||||
// 把它当"需要人工指定表头"来处理,比弹一句红字有用
|
||||
const err = e as Error & { bizCode?: number }
|
||||
if (err?.bizCode === 40028 || err?.bizCode === 40017) {
|
||||
Modal.info({
|
||||
title: '需要人工指定表头',
|
||||
content: `${err.message}
|
||||
|
||||
请在下方把表头行号填上再试一次(比如标题在第 1 行、说明在第 3 行、表头在第 6 行时,填 6)。`,
|
||||
hideCancel: true
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 行 key:报告里的行号可能有多条问题,拼上 code 才是唯一的 */
|
||||
function withKeys(r: RosterImportReport | null): RosterImportReport | null {
|
||||
if (!r) return null
|
||||
const tag = (list: RosterImportReport['errors']) =>
|
||||
list.map((x, i) => ({ ...x, key: `${x.row}-${x.code}-${i}` })) as RosterImportReport['errors']
|
||||
return { ...r, errors: tag(r.errors), warnings: tag(r.warnings) }
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!file.value || !taskId.value || !report.value) {
|
||||
return
|
||||
}
|
||||
if (mode.value === 'REPLACE' && !replaceConfirmed.value) {
|
||||
Modal.warning({
|
||||
title: '整表替换会清掉现有名册',
|
||||
content: `现有的 ${total.value} 条名册会被替换成这份文件里的 ${report.value.success} 条。
|
||||
已经核验或已提交的人会被拒绝(服务端会拦下整次替换)。
|
||||
确认要用「替换」而不是「合并」吗?`,
|
||||
okText: '确认替换',
|
||||
hideCancel: false,
|
||||
onBeforeOk: () => {
|
||||
replaceConfirmed.value = true
|
||||
void confirmImport()
|
||||
return false
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
const res = await rosterApi.importRoster(taskId.value, file.value, {
|
||||
dryRun: false,
|
||||
mode: mode.value,
|
||||
orgId: orgId.value,
|
||||
headerRow: manualHeaderRow.value
|
||||
})
|
||||
report.value = withKeys(res.data ?? null)
|
||||
const d = report.value
|
||||
const removedPart = d?.removed ? `、移除 ${d.removed} 人` : ''
|
||||
Message.success(`导入完成:新增 ${d?.created ?? 0} 人、更新 ${d?.updated ?? 0} 人${removedPart}`)
|
||||
query.pageNum = 1
|
||||
await loadList()
|
||||
emit('save-success')
|
||||
} finally {
|
||||
importing.value = false
|
||||
replaceConfirmed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.roster {
|
||||
&__task { margin-left: 12px; color: var(--color-text-3); font-weight: 400; }
|
||||
&__alert { margin-bottom: 16px; }
|
||||
&__intro { margin-bottom: 16px; line-height: 1.8; }
|
||||
&__form { margin-bottom: 4px; }
|
||||
&__item { margin-bottom: 8px; }
|
||||
&__pick { display: flex; gap: 10px; align-items: center; }
|
||||
&__file { display: none; }
|
||||
&__filename {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
&__hint { font-size: 12px; line-height: 1.7; color: var(--color-text-3); }
|
||||
&__muted { font-size: 12px; color: var(--color-text-3); }
|
||||
&__mono { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
|
||||
&__name { font-weight: 500; color: var(--color-text-1); }
|
||||
&__tool { margin-bottom: 12px; }
|
||||
&__table { margin-bottom: 8px; }
|
||||
&__mapped { color: rgb(var(--primary-6)); }
|
||||
&__code { margin-left: 6px; }
|
||||
|
||||
&__section {
|
||||
margin: 4px 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
|
||||
span { margin-left: 8px; font-size: 12px; font-weight: 400; color: var(--color-text-3); }
|
||||
|
||||
&--bad { color: rgb(var(--danger-6)); }
|
||||
&--warn { color: rgb(var(--warning-6)); }
|
||||
}
|
||||
|
||||
&__stats { margin-bottom: 16px; }
|
||||
|
||||
&__stat {
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 6px;
|
||||
|
||||
&--ok .roster__stat-num { color: rgb(var(--success-6)); }
|
||||
&--bad .roster__stat-num { color: rgb(var(--danger-6)); }
|
||||
&--warn .roster__stat-num { color: rgb(var(--warning-6)); }
|
||||
}
|
||||
|
||||
&__stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__stat-label { font-size: 12px; color: var(--color-text-3); }
|
||||
|
||||
&__header-alert { margin-bottom: 12px; line-height: 1.8; }
|
||||
&__footer { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; margin: 12px 0 4px; }
|
||||
&__search { width: 240px; }
|
||||
&__filter { width: 130px; }
|
||||
}
|
||||
</style>
|
||||
@@ -31,27 +31,17 @@
|
||||
下面 6 列合计 840 —— 只差 10px,几乎不出现横向滚动条。
|
||||
之前给到 1130px,结果是横向滚动 + 钉住的「操作」列把「状态」整个盖住,
|
||||
表头挤成了「进度操作」。**改列宽前先按这个口径算一遍。**
|
||||
-->
|
||||
<!--
|
||||
⚠️ 关于列宽与「操作」列的 fixed:**改之前先量一下表格可用宽度。**
|
||||
|
||||
实测(1366 宽的屏幕):
|
||||
· 收集任务页:左侧栏 200,表格可用约 824px
|
||||
· 账号管理页:左侧栏 200 + 左侧组织树卡约 250,表格可用**只有约 574px**
|
||||
|
||||
所以账号管理页必须保留 `fixed="right"`:
|
||||
列宽合计 800px 装不进 574px,必然横向滚动;
|
||||
横向滚动时**钉住的操作列会盖住紧邻它的那一列**,这是固定列的固有行为,不是配置错了。
|
||||
去掉 fixed 看似不压盖了,实际是把操作列滚出视野 ——
|
||||
用户根本看不到「编辑 / 重置密码 / 停用」,那比压盖更糟。
|
||||
|
||||
要真正消除压盖只有一条路:**把列数减到能在 574px 内放下**。
|
||||
留给下一轮:把「手机号」或「状态 / 最后登录」并进「姓名 / 账号」那一列。
|
||||
⚠️ 2026-09-29 加「名单管理」时的账(新增一个链接,所以必须从别处挪宽度):
|
||||
· 「任务」230 → 200、「填报时间」160 → 130,共省出 60
|
||||
· 「操作」300 → 340(草稿态有 7 个链接,会折成两行 —— 这是**故意认下的**:
|
||||
名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置)
|
||||
· 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。
|
||||
-->
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||
:scroll="{ x: 820 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="任务" :width="230" ellipsis tooltip>
|
||||
<a-table-column title="任务" :width="200" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<div class="task__name">{{ record.taskName }}</div>
|
||||
<div class="task__code">{{ record.taskCode }}</div>
|
||||
@@ -65,7 +55,7 @@
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="填报时间" :width="160">
|
||||
<a-table-column title="填报时间" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<div class="task__muted">{{ fmt(record.startTime) }} 起</div>
|
||||
<div class="task__muted">{{ fmt(record.endTime) }} 止</div>
|
||||
@@ -95,12 +85,14 @@
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="操作" :width="300" align="center" fixed="right">
|
||||
<a-table-column title="操作" :width="340" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space v-if="canManage" size="mini" wrap>
|
||||
<template v-if="record.status === TASK_STATUS_DRAFT">
|
||||
<a-link @click="onEdit(record)">编辑</a-link>
|
||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<!-- 名册(准入名单)排在材料之前:没有它,学生连填报页都进不去 -->
|
||||
<a-link @click="onConfigRoster(record)">名单管理</a-link>
|
||||
<!-- 文案必须与服务端 40018 的提示「请到『材料规则』里配置…」一字不差,
|
||||
否则用户按提示找不到地方 -->
|
||||
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
||||
@@ -114,6 +106,7 @@
|
||||
|
||||
<template v-else-if="record.status === TASK_STATUS_COLLECTING">
|
||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<a-link @click="onConfigRoster(record)">名单管理</a-link>
|
||||
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
||||
<a-link @click="onConfigForm(record)">表单设计</a-link>
|
||||
<a-link status="warning" @click="onPause(record)">暂停</a-link>
|
||||
@@ -122,6 +115,7 @@
|
||||
|
||||
<template v-else-if="record.status === TASK_STATUS_PAUSED">
|
||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<a-link @click="onConfigRoster(record)">名单管理</a-link>
|
||||
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
||||
<a-link @click="onConfigForm(record)">表单设计</a-link>
|
||||
<a-link status="success" @click="onResume(record)">恢复</a-link>
|
||||
@@ -129,10 +123,14 @@
|
||||
</template>
|
||||
|
||||
<template v-else-if="record.status === TASK_STATUS_CLOSED">
|
||||
<!-- 截止后名册只能看不能改(抽屉里会说清),所以仍然给入口 -->
|
||||
<a-link @click="onConfigRoster(record)">查看名单</a-link>
|
||||
<a-link @click="onArchive(record)">归档</a-link>
|
||||
</template>
|
||||
|
||||
<span v-else class="task__muted">只读</span>
|
||||
<template v-else>
|
||||
<a-link @click="onConfigRoster(record)">查看名单</a-link>
|
||||
</template>
|
||||
</a-space>
|
||||
<span v-else class="task__muted">只读</span>
|
||||
</template>
|
||||
@@ -145,6 +143,7 @@
|
||||
<TaskOrgDrawer ref="orgDrawerRef" @save-success="load" />
|
||||
<TaskMaterialDrawer ref="materialDrawerRef" @save-success="load" />
|
||||
<TaskFormSchemaDrawer ref="formDrawerRef" @save-success="load" />
|
||||
<TaskRosterDrawer ref="rosterDrawerRef" @save-success="load" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
@@ -165,6 +164,7 @@ import TaskFormModal from './TaskFormModal.vue'
|
||||
import TaskFormSchemaDrawer from './TaskFormSchemaDrawer.vue'
|
||||
import TaskMaterialDrawer from './TaskMaterialDrawer.vue'
|
||||
import TaskOrgDrawer from './TaskOrgDrawer.vue'
|
||||
import TaskRosterDrawer from './TaskRosterDrawer.vue'
|
||||
|
||||
/**
|
||||
* 收集任务(《03-接口设计》§7.1 ~ §7.5)。
|
||||
@@ -195,6 +195,7 @@ const formModalRef = useTemplateRef('formModalRef')
|
||||
const orgDrawerRef = useTemplateRef('orgDrawerRef')
|
||||
const materialDrawerRef = useTemplateRef('materialDrawerRef')
|
||||
const formDrawerRef = useTemplateRef('formDrawerRef')
|
||||
const rosterDrawerRef = useTemplateRef('rosterDrawerRef')
|
||||
|
||||
/**
|
||||
* 发布被拦时,每个阻断项**去哪里修**。
|
||||
@@ -204,8 +205,8 @@ const formDrawerRef = useTemplateRef('formDrawerRef')
|
||||
* 让用户自己找是纯粹的浪费。
|
||||
*/
|
||||
const BLOCKING_HINTS: Record<number, string> = {
|
||||
40016: '请先到「名单管理」导入本任务的报考名册',
|
||||
40017: '名册缺少「姓名」或「身份证号」列,请重新导入名册',
|
||||
40016: '请点本行的「名单管理」,导入本任务的报考名册',
|
||||
40017: '名册缺少「姓名」或「身份证号」列,请到本行的「名单管理」里重新导入',
|
||||
// 必须说"点本行":「材料规则」也是一个顶层菜单(那个还是里程碑占位页),
|
||||
// 只说「请到『材料规则』里配置」会把人引到那个占位页去
|
||||
40018: '请点本行的「材料规则」,配置至少一项必传材料',
|
||||
@@ -285,6 +286,10 @@ function onConfigForm(row: TaskVO) {
|
||||
formDrawerRef.value?.open(row)
|
||||
}
|
||||
|
||||
function onConfigRoster(row: TaskVO) {
|
||||
rosterDrawerRef.value?.open(row)
|
||||
}
|
||||
|
||||
async function onDelete(row: TaskVO) {
|
||||
await taskApi.deleteTask(row.id)
|
||||
Message.success('已删除')
|
||||
|
||||
Reference in New Issue
Block a user