feat(task): M1-01 收集任务 CRUD 与状态机

后端:/tasks 建改删查 + publish/pause/resume/close/archive 六个动作,
按角色过滤可见范围(班主任只看自己带的班级的任务)。
前端:收集任务列表页 + 建/改任务弹窗(命名模板带实时预览与变量插入)。

验收:e2e-m1-task.py 71/71;e2e-m0.py 36/36、e2e-m0-user.py 55/55 回归通过;
前端 typecheck 0 错 / build 通过 / 浏览器肉眼确认。

三条硬约束(改动时当成规则):

1) 「暂停」与「截止」不是一回事。暂停:学生不能交、已提交的照常审、
   且可恢复;截止不可逆。做成一个会让"临时冻一下"变成不可逆操作。
2) 发布前置 5 项全是阻断级,force 绕不过:名册非空(40016)、名册含姓名与
   证件号(40017)、至少 1 项必传材料(40018)、已设置覆盖班级(40019)、
   命名模板可用(40020)。只有 P1 级提醒(某班未指定初审人)可 force 跳过。
3) collectMode / certType 发布后不可改 —— 它们决定审核流与材料清单,
   改了会让已填过的学生面对另一份表单。

本轮修掉的 3 个真问题:

1) collect_task_org 没有 deleted 列,而我让它继承了带 @TableLogic 的基类
   —— MyBatis-Plus 会给每条查询自动加 deleted = 0,运行时报"列不存在",
   编译期完全看不出来。改为不继承基类、显式声明字段;
   "移除"语义走 status = 0,不要对它调 deleteById(会变成物理删除)。

2) 任务编码查重被逻辑删除遮蔽:用 MP 的 selectCount 会自动过滤 deleted = 0,
   但数据库的唯一约束不看软删标记 —— 软删掉的任务仍占着那个编码,
   于是"查重说没有、插进去说重复",报 duplicate key(500)。
   另外原用 COUNT+1 也错:删掉中间一个后计数会退回去,算出已占用的号。
   改为 MAX(序号)+1,且查重语句刻意不加 deleted = 0(与唯一约束同口径);
   编码一次用过就不复用(它同时是文件目录名,复用会把两批文件混进同一目录)。

3) Boot 4 把 Jackson 自动配置挪到了 spring-boot-starter-jackson,项目没引,
   导致容器里没有 ObjectMapper bean(@Autowired 直接启动失败),
   且 application.yml 的 spring.jackson.* 静默失效 —— 其中
   time-zone: Asia/Shanghai 不生效最危险:本机时区恰好是上海时看不出来,
   部署到 UTC 服务器后所有时间会整体偏 8 小时。
   注意 spring-boot-starter-json 在 Boot 4 已无 4.x 版本(仓库里最高 3.5.6)。
   补依赖,但任务配置的 JSON 读写仍不用 Jackson —— Boot 4 用 Jackson 3
   而 jjwt-jackson 带来 Jackson 2,打包后两个 jackson-databind 并存
   (2.21.5 + 3.1.5),import 哪个都是赌;改用项目已直接声明的 hutool-all。

已知缺口(都写进了代码注释与交接摘要):

- 发布在前置数据齐备前不可达(名册属 M2、材料属 M1-04、覆盖班级属 M1-02),
  E2E 用 SQL 夹具造数据才走到发布之后的状态 —— M1-02/M1-04/M2 落地后
  要回来把夹具换成真实接口。
- 命名规则试跑目前只做静态校验(变量名合法 + 必含姓名/证件号/材料名),
  完整形态(拿真实名册跑一遍查非空与不重名)属 M1-09 / M3-01。
- stat 恒为 null(依赖名册与提交单)。故意不返回全 0 的对象 ——
  那会让界面显示"完成率 0%",而真相是"还没有分母";界面显示「待导入名册」。
- 任务编码并发撞号由唯一约束兜底 + 可读提示(不再 500),未做分布式锁。

顺带:

- 两个列表页的列宽按实测可用宽度重算(1366 屏:收集任务 824px、
  账号管理因左侧组织树只有 574px),并把"操作列必须 fixed + 列宽口径"
  写进注释 —— 之前把「状态」列整个遮住、把操作链接挤成竖排。
- 状态与最后登录并成一列,给窄屏省出 150px。
This commit is contained in:
2026-09-28 23:48:21 +08:00
parent 8480f431ba
commit daf42608d9
22 changed files with 3228 additions and 29 deletions
+219
View File
@@ -0,0 +1,219 @@
/**
* 收集任务接口(《03-接口设计》§7.1 ~ §7.5)。
*
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
/** 审核流配置(§3.2) */
export interface AuditConfig {
needClassAudit: boolean
needAdminAudit: boolean
classAuditTimeoutHours: number
adminAuditTimeoutHours: number
maxRejectCount: number
allowProxyUpload: boolean
/** 批量通过只带无问题项。**铁律,不可关闭** —— 传 false 也会被服务端扳回 true */
batchApproveOnlyGreen: boolean
}
/** 压缩参数(§3.11) */
export interface CompressConfig {
defaultMaxSizeKb: number
defaultTargetDpi: number
defaultQuality: number
minQuality: number
hardFloorDpi: number
maxPixels: number
keepPngForScreenshot: boolean
markWhenForced: boolean
}
/**
* 任务统计快照(§3.3)。
*
* ⚠️ **M1 阶段后端恒返回 `null`** —— 它依赖名册(M2)与提交单(M3)。
* 界面要显示「待导入名册」,而不是把 null 当成 0 显示成"完成率 0%"。
*/
export interface TaskStat {
rosterTotal: number
submittedCount: number
approvedCount: number
pendingCount: number
rejectedCount: number
unsubmittedCount: number
submitRate: string
passRate: string
abnormalCount: number
avgAuditHours: number
}
/** 收集任务(§3.1) */
export interface TaskVO {
id: number
taskCode: string
taskName: string
/** 1 学校收集 / 2 社会收集(二期) */
collectMode: number
batchNo: string
majorName: string
jobName: string | null
jobLevel: string | null
/** 1 自主认定 / 2 社会评价(二期)/ 3 专项职业能力(二期) */
certType: number
startTime: string | null
endTime: string | null
auditConfig: AuditConfig
namingTemplate: string
compressConfig: CompressConfig
/** 恒为 true —— 名单门禁是产品根基 */
rosterRequired: boolean
/** 0 草稿 / 1 收集中 / 2 已截止 / 3 已归档 / 4 已暂停 */
status: number
publishedTime: string | null
/** M1 阶段恒为 null */
stat: TaskStat | null
createTime: string
}
/** 建任务请求(§7.2) */
export interface TaskUpsertReq {
taskName: string
collectMode: number
batchNo: string
majorName: string
jobName?: string | null
jobLevel?: string | null
certType: number
startTime: string
endTime: string
auditConfig?: Partial<AuditConfig>
namingTemplate?: string
compressConfig?: Partial<CompressConfig>
}
export interface TaskCreateResult {
id: number
taskCode: string
status: number
}
export interface TaskPublishResult {
status: number
publishedTime: string
warnings: string[]
qrcodeUrl: string
}
export interface TaskListQuery {
keyword?: string
status?: number
collectMode?: number
batchNo?: string
orgId?: number
pageNum?: number
pageSize?: number
}
/** 任务分页列表。班主任只会拿到自己带的班级的任务 */
export function getTaskList(params: TaskListQuery) {
return http.get<{ list: TaskVO[], total: number }>('/tasks', params)
}
export function getTaskDetail(id: number) {
return http.get<TaskVO>(`/tasks/${id}`)
}
export function addTask(data: TaskUpsertReq) {
return http.post<TaskCreateResult>('/tasks', data)
}
export function updateTask(id: number, data: TaskUpsertReq) {
return http.put<void>(`/tasks/${id}`, data)
}
/** 删除:只有草稿可以删 */
export function deleteTask(id: number) {
return http.delete<void>(`/tasks/${id}`)
}
/** 发布。前置 5 项全是阻断级,force 无法绕过 */
export function publishTask(id: number, force = false) {
return http.post<TaskPublishResult>(`/tasks/${id}/publish`, { force })
}
/** 暂停:幂等。已提交的照常审核,且可以恢复 —— 与「截止」不是一回事 */
export function pauseTask(id: number, reason?: string) {
return http.post<void>(`/tasks/${id}/pause`, { reason })
}
/** 恢复:幂等 */
export function resumeTask(id: number) {
return http.post<void>(`/tasks/${id}/resume`)
}
/** 截止:不可逆 */
export function closeTask(id: number) {
return http.post<void>(`/tasks/${id}/close`, {})
}
/** 归档:不可逆,要求无待审单 */
export function archiveTask(id: number, archiveRemark?: string) {
return http.post<void>(`/tasks/${id}/archive`, { archiveRemark })
}
// ── 枚举镜像 ────────────────────────────────────────
/** 任务状态 → 文案与颜色(对应 collect_task.status) */
export const TASK_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '草稿', color: 'gray' },
1: { text: '收集中', color: 'green' },
2: { text: '已截止', color: 'orange' },
3: { text: '已归档', color: 'purple' },
4: { text: '已暂停', color: 'red' }
}
/** 收集模式(一期只启用 1) */
export const COLLECT_MODE_NAMES: Record<number, string> = {
1: '学校收集',
2: '社会收集(二期)'
}
/** 认定类型(一期只启用 1) */
export const CERT_TYPE_NAMES: Record<number, string> = {
1: '自主认定',
2: '社会评价(二期)',
3: '专项职业能力(二期)'
}
/** 任务状态常量,避免界面上到处写魔法数字 */
export const TASK_STATUS_DRAFT = 0
export const TASK_STATUS_COLLECTING = 1
export const TASK_STATUS_CLOSED = 2
export const TASK_STATUS_ARCHIVED = 3
export const TASK_STATUS_PAUSED = 4
/**
* 命名模板可用变量(与 PRD 的"可用变量"一栏逐字对齐)。
*
* 必须与服务端 `TaskService.NAMING_PLACEHOLDERS` 一致:
* 前端多给一个,用户点了会被服务端以 40020 拒掉。
*/
export const NAMING_VARIABLES = [
'姓名',
'证件号',
'证件类型',
'材料名',
'班级',
'专业',
'工种',
'等级',
'报名点',
'序号',
'日期',
'后缀'
] as const
/** 默认命名模板(与 DDL 的列默认值一致) */
export const DEFAULT_NAMING_TEMPLATE = '{姓名}-{证件号}-{材料名}'
+1 -2
View File
@@ -51,8 +51,7 @@ export const XT_MENUS: XtMenu[] = [
component: 'task/index',
group: 'collect',
sort: 11,
ready: false,
note: '建任务、配表单与材料规则、发布与暂停。对应里程碑 M1。'
ready: true
},
{
key: 'roster',
@@ -0,0 +1,338 @@
<template>
<a-modal v-model:visible="visible" :title="title" :width="640" :mask-closable="false"
:ok-loading="submitting" @ok="confirm" @cancel="close">
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="14">
<a-form-item field="taskName" label="任务名称">
<a-input v-model="form.taskName" placeholder="如 2026年9月机电专业认定材料收集"
:max-length="200" allow-clear />
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item field="batchNo" label="批次号">
<a-input v-model="form.batchNo" placeholder="如 202609" :max-length="8" allow-clear />
<template #extra>
<span class="task-form__hint">4~8 位数字,同时作为任务编码前缀</span>
</template>
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item field="majorName" label="专业">
<a-input v-model="form.majorName" placeholder="如 数控加工" :max-length="64" allow-clear />
</a-form-item>
</a-col>
<a-col :span="7">
<a-form-item field="jobName" label="报考工种">
<a-input v-model="form.jobName" placeholder="可后补" :max-length="64" allow-clear />
</a-form-item>
</a-col>
<a-col :span="7">
<a-form-item field="jobLevel" label="报考等级">
<a-input v-model="form.jobLevel" placeholder="可后补" :max-length="32" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="startTime" label="填报开始">
<a-date-picker v-model="form.startTime" value-format="YYYY-MM-DD" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="endTime" label="填报截止">
<a-date-picker v-model="form.endTime" value-format="YYYY-MM-DD" style="width: 100%" />
<template #extra>
<span class="task-form__hint">只选日期时按当天 23:59:59 处理</span>
</template>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">一期范围</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="收集模式">
<a-input model-value="学校收集" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="认定类型">
<a-input model-value="自主认定" disabled />
</a-form-item>
</a-col>
</a-row>
<a-alert type="info" :show-icon="false" class="task-form__scope">
<template #title>
一期只做 <strong>学校收集 + 自主认定</strong>。
社会收集、社会评价、专项职业能力已后置二期,数据结构保留、界面暂不开放。
</template>
</a-alert>
<a-divider orientation="left">命名规则</a-divider>
<a-form-item field="namingTemplate" label="文件命名模板">
<a-input v-model="form.namingTemplate" :max-length="256" allow-clear />
<template #extra>
<span class="task-form__hint">
必须含 <code>{姓名}</code> <code>{证件号}</code> <code>{材料名}</code> ——
缺了任一项,文件名就无法唯一归属到人。
</span>
</template>
</a-form-item>
<div class="task-form__vars">
<span class="task-form__vars-label">插入变量</span>
<a-space wrap size="mini">
<a-button v-for="opt in namingVariableOptions" :key="opt.value" size="mini" type="outline"
@click="appendVariable(opt.value)">
{{ opt.label }}
</a-button>
</a-space>
</div>
<a-form-item label="预览(以「张同学 / 361029200912100010 / 身份证」为例)">
<code class="task-form__preview">{{ namingPreview }}</code>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import type { TaskUpsertReq, TaskVO } from '@/apis/xt/task'
import { Message } from '@arco-design/web-vue'
import * as taskApi from '@/apis/xt/task'
import { DEFAULT_NAMING_TEMPLATE, NAMING_VARIABLES } from '@/apis/xt/task'
/**
* 建 / 改任务弹窗。
*
* 三处刻意的设计:
* 1. **命名模板带实时预览**:光看 `{姓名}-{证件号}-{材料名}` 很难想象最终长什么样,
* 而它直接决定导出包里每个文件的名字。用样例数据渲染出来一眼就能判断。
* 2. **收集模式与认定类型做成只读**:它们决定审核流与材料清单,
* 一期也不启用其它取值。可编辑只会让人以为能改。
* 3. **变量用按钮插入而不是让人手打**:手打 `{姓名}` 少一个大括号或写成 `{姓名 }`,
* 服务端只会在发布时给一个 40020,而那时已经配了一堆别的东西。
*/
defineOptions({ name: 'TaskFormModal' })
const emit = defineEmits<{ 'save-success': [] }>()
const visible = ref(false)
const submitting = ref(false)
const detailId = ref<number | null>(null)
const formRef = useTemplateRef('formRef')
const isEdit = computed(() => !!detailId.value)
const title = computed(() => (isEdit.value ? '修改任务' : '新建收集任务'))
/**
* 表单自己的类型。
*
* 与 {@link TaskUpsertReq} 的差别只在 `jobName` / `jobLevel`:
* 接口那边允许 `null`(表示"清空"),而 Arco 的 `a-input` 的 v-model 只接受
* `string | undefined` —— 直接绑 `string | null | undefined` 会报 TS2322。
* 界面上用空串表示"没填",发给服务端前由 Service 的 `trimToNull` 转成 null。
*/
type TaskForm = Omit<TaskUpsertReq, 'jobName' | 'jobLevel'> & {
jobName: string
jobLevel: string
}
const getInitForm = (): TaskForm => ({
taskName: '',
collectMode: 1,
batchNo: '',
majorName: '',
jobName: '',
jobLevel: '',
certType: 1,
startTime: '',
endTime: '',
namingTemplate: DEFAULT_NAMING_TEMPLATE
})
const form = reactive(getInitForm())
const rules: FormInstance['rules'] = {
taskName: [{ required: true, message: '请填写任务名称' }],
batchNo: [
{ required: true, message: '请填写批次号' },
{ match: /^\d{4,8}$/, message: '批次号应为 4~8 位数字' }
],
majorName: [{ required: true, message: '请填写专业' }],
startTime: [{ required: true, message: '请选择填报开始时间' }],
endTime: [
{ required: true, message: '请选择填报截止时间' },
{
validator: (value: string, callback: (error?: string) => void) => {
if (value && form.startTime && value <= form.startTime) {
callback('截止时间必须晚于开始时间')
return
}
callback()
}
}
],
namingTemplate: [{ required: true, message: '请填写命名模板' }]
}
/**
* 预览时把 `{自定义字段.xxx}` 整体替换掉。
*
* 放在模块作用域而不是 computed 里:正则每次渲染都重新编译一遍是纯浪费,
* 而且这条规则(e18e/prefer-static-regex)在项目里是开着的。
*/
const CUSTOM_FIELD_RE = /\{自定义字段\.[^}]*\}/g
/** 命名预览:把变量替换成样例值,让用户一眼看出最终文件名长什么样 */
const namingPreview = computed(() => {
const sample: Record<string, string> = {
姓名: '张同学',
证件号: '361029200912100010',
证件类型: '居民身份证',
材料名: '身份证',
班级: '24中技数控1班',
专业: '数控加工',
工种: '数控车工',
等级: '四级',
报名点: '示范职业学校',
序号: '007',
日期: '20260928',
后缀: '.jpg'
}
let out = form.namingTemplate || ''
for (const [k, v] of Object.entries(sample)) {
out = out.replaceAll(`{${k}}`, v)
}
// 自定义字段在预览里没有真实值,用占位提示而不是留一个空
out = out.replace(CUSTOM_FIELD_RE, '(自定义字段)')
return out || '—'
})
/**
* 变量按钮的文案。
*
* ⚠️ **不要在模板里拼花括号**(比如 `{{ '{' + v + '}' }}`)。
* Vue 的插值解析器是靠找 `}}` 来收尾的,而模板里一旦出现内层的 `}` 与结尾的 `}}`
* 相邻(eslint --fix 会把它"帮忙"改成模板字符串 `` `{${v}}` ``,正好制造这种情况),
* 就会报 `Error parsing JavaScript expression: Unexpected token, expected "}"` ——
* 而且这个错误**只在构建/编译时才出现**,编辑器里看不出问题。
* 所以在 script 里算好,模板里只做纯取值。
*/
const namingVariableOptions = NAMING_VARIABLES.map((v) => ({ value: v, label: `{${v}}` }))
function appendVariable(v: string) {
form.namingTemplate = `${form.namingTemplate ?? ''}{${v}}`
}
async function add() {
detailId.value = null
Object.assign(form, getInitForm())
visible.value = true
}
function edit(row: TaskVO) {
detailId.value = row.id
Object.assign(form, getInitForm())
form.taskName = row.taskName
form.batchNo = row.batchNo ?? ''
form.majorName = row.majorName ?? ''
form.jobName = row.jobName ?? ''
form.jobLevel = row.jobLevel ?? ''
form.startTime = (row.startTime ?? '').slice(0, 10)
form.endTime = (row.endTime ?? '').slice(0, 10)
form.namingTemplate = row.namingTemplate ?? DEFAULT_NAMING_TEMPLATE
visible.value = true
}
function close() {
visible.value = false
}
async function confirm() {
const valid = await formRef.value?.validate()
if (valid) return false
submitting.value = true
try {
const payload: TaskUpsertReq = {
taskName: form.taskName.trim(),
collectMode: 1,
batchNo: (form.batchNo ?? '').trim(),
majorName: form.majorName.trim(),
jobName: form.jobName?.trim() || null,
jobLevel: form.jobLevel?.trim() || null,
certType: 1,
startTime: form.startTime,
// 只给日期时补上当天最后一秒,避免"截止日期当天 00:00 就截止了"
endTime: form.endTime.length <= 10 ? `${form.endTime}T23:59:59` : form.endTime,
namingTemplate: (form.namingTemplate ?? '').trim()
}
if (isEdit.value) {
await taskApi.updateTask(detailId.value!, payload)
Message.success('已保存')
} else {
const res = await taskApi.addTask(payload)
Message.success(`任务已创建,编码 ${res.data?.taskCode ?? ''}(草稿)`)
}
visible.value = false
emit('save-success')
return true
} finally {
submitting.value = false
}
}
defineExpose({ add, edit })
</script>
<style lang="scss" scoped>
.task-form {
&__hint {
font-size: 12px;
line-height: 1.7;
color: var(--color-text-3);
code {
padding: 0 3px;
font-family: var(--font-mono, ui-monospace, monospace);
background: var(--color-fill-2);
border-radius: 2px;
}
}
&__scope {
margin-top: -6px;
line-height: 1.8;
}
&__vars {
display: flex;
gap: 10px;
align-items: baseline;
margin: -8px 0 18px;
}
&__vars-label {
flex: none;
font-size: 12px;
color: var(--color-text-3);
}
&__preview {
display: block;
width: 100%;
padding: 6px 10px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 13px;
color: var(--color-text-1);
word-break: break-all;
background: var(--color-fill-2);
border-radius: 4px;
}
}
</style>
+375
View File
@@ -0,0 +1,375 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<template #title>收集任务</template>
<template #extra>
<span class="task__hint">共 {{ total }} 个任务</span>
</template>
<div class="g-row-tool">
<a-space wrap>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd">
<template #icon><IconPlus /></template>
新建任务
</a-button>
</a-space>
<a-space wrap>
<a-button v-for="c in statusChips" :key="String(c.value)"
:type="statusFilter === c.value ? 'primary' : 'outline'" size="small" @click="setStatus(c.value)">
{{ c.label }}
</a-button>
<a-input v-model="query.batchNo" placeholder="批次号" allow-clear class="task__batch"
@press-enter="search" />
<a-input-search v-model="query.keyword" placeholder="搜索任务名 / 编码 / 专业" allow-clear
class="task__search" @search="search" @clear="search" />
</a-space>
</div>
<!--
列宽是算过的:1366 宽的屏幕上,减去 200 侧栏后内容区约 830px。
下面 6 列合计 840 —— 只差 10px,几乎不出现横向滚动条。
之前给到 1130px,结果是横向滚动 + 钉住的「操作」列把「状态」整个盖住,
表头挤成了「进度操作」。**改列宽前先按这个口径算一遍。**
-->
<!--
⚠️ 关于列宽与「操作」列的 fixed:**改之前先量一下表格可用宽度。**
实测(1366 宽的屏幕):
· 收集任务页:左侧栏 200,表格可用约 824px
· 账号管理页:左侧栏 200 + 左侧组织树卡约 250,表格可用**只有约 574px**
所以账号管理页必须保留 `fixed="right"`:
列宽合计 800px 装不进 574px,必然横向滚动;
横向滚动时**钉住的操作列会盖住紧邻它的那一列**,这是固定列的固有行为,不是配置错了。
去掉 fixed 看似不压盖了,实际是把操作列滚出视野 ——
用户根本看不到「编辑 / 重置密码 / 停用」,那比压盖更糟。
要真正消除压盖只有一条路:**把列数减到能在 574px 内放下**。
留给下一轮:把「手机号」或「状态 / 最后登录」并进「姓名 / 账号」那一列。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 840 }" @page-change="onPageChange">
<template #columns>
<a-table-column title="任务" :width="230" ellipsis tooltip>
<template #cell="{ record }">
<div class="task__name">{{ record.taskName }}</div>
<div class="task__code">{{ record.taskCode }}</div>
</template>
</a-table-column>
<a-table-column title="批次 / 专业" :width="120">
<template #cell="{ record }">
<div>{{ record.batchNo }}</div>
<div class="task__code">{{ record.majorName }}</div>
</template>
</a-table-column>
<a-table-column title="填报时间" :width="160">
<template #cell="{ record }">
<div class="task__muted">{{ fmt(record.startTime) }} 起</div>
<div class="task__muted">{{ fmt(record.endTime) }} 止</div>
</template>
</a-table-column>
<a-table-column title="进度" :width="90">
<template #cell="{ record }">
<!--
stat 在 M1 阶段后端恒为 null(它依赖名册 M2 与提交单 M3)。
这里明确显示「待导入名册」,而不是把 null 当成 0 显示成"0%" ——
后者会让人以为"没人填",而真相是"还没有分母"。
-->
<span v-if="!record.stat" class="task__muted">待导入名册</span>
<span v-else class="task__muted">{{ record.stat.submitRate }}</span>
</template>
</a-table-column>
<a-table-column title="状态" :width="80" align="center">
<template #cell="{ record }">
<a-tag :color="TASK_STATUS[record.status]?.color ?? 'gray'">
{{ TASK_STATUS[record.status]?.text ?? '未知' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" :width="160" 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="onPublish(record)">发布</a-link>
<a-popconfirm content="删除后不可恢复,确认删除这个草稿?" type="warning"
@ok="onDelete(record)">
<a-link status="danger">删除</a-link>
</a-popconfirm>
</template>
<template v-else-if="record.status === TASK_STATUS_COLLECTING">
<a-link status="warning" @click="onPause(record)">暂停</a-link>
<a-link @click="onClose(record)">截止</a-link>
</template>
<template v-else-if="record.status === TASK_STATUS_PAUSED">
<a-link status="success" @click="onResume(record)">恢复</a-link>
<a-link @click="onClose(record)">截止</a-link>
</template>
<template v-else-if="record.status === TASK_STATUS_CLOSED">
<a-link @click="onArchive(record)">归档</a-link>
</template>
<span v-else class="task__muted">只读</span>
</a-space>
<span v-else class="task__muted">只读</span>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<TaskFormModal ref="formModalRef" @save-success="load" />
</GiPageLayout>
</template>
<script setup lang="ts">
import type { TaskVO } from '@/apis/xt/task'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import * as taskApi from '@/apis/xt/task'
import {
TASK_STATUS,
TASK_STATUS_CLOSED,
TASK_STATUS_COLLECTING,
TASK_STATUS_DRAFT,
TASK_STATUS_PAUSED
} from '@/apis/xt/task'
import { useUserStore } from '@/stores'
import TaskFormModal from './TaskFormModal.vue'
/**
* 收集任务(《03-接口设计》§7.1 ~ §7.5)。
*
* 这一屏的核心不是"列表 + 增删改",而是**状态机**:
* 草稿 →(发布)→ 收集中 →(暂停)⇄(恢复)→ 已暂停 →(截止)→ 已截止 →(归档)→ 已归档
*
* 两个容易做错的地方,界面上刻意做了区分:
* 1. **「暂停」和「截止」是两件事**:暂停可以恢复、已提交的照常审核;
* 截止不可逆。所以暂停用普通确认,截止用"不可逆"措辞的二次确认。
* 2. **发布可能被拦**:5 项前置校验都是阻断级。发起前不讲清"还差什么",
* 用户会以为是系统坏了。所以发布失败时把服务端列出的缺项原样展示,
* 并把"仅 P1 提醒、可通过 force 跳过"的那一步单独做成二次确认。
*/
defineOptions({ name: 'Task' })
const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('task:manage'))
const loading = ref(false)
const list = ref<TaskVO[]>([])
const total = ref(0)
const statusFilter = ref<number | undefined>(undefined)
const query = reactive({ pageNum: 1, pageSize: 10, keyword: '', batchNo: '' })
const formModalRef = useTemplateRef('formModalRef')
const statusChips = [
{ label: '全部', value: undefined },
{ label: '草稿', value: TASK_STATUS_DRAFT },
{ label: '收集中', value: TASK_STATUS_COLLECTING },
{ label: '已暂停', value: TASK_STATUS_PAUSED },
{ label: '已截止', value: TASK_STATUS_CLOSED }
]
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
function fmt(v: string | null) {
return v ? v.replace('T', ' ').slice(0, 10) : '—'
}
async function load() {
loading.value = true
try {
const res = await taskApi.getTaskList({
keyword: query.keyword.trim() || undefined,
batchNo: query.batchNo.trim() || undefined,
status: statusFilter.value,
pageNum: query.pageNum,
pageSize: query.pageSize
})
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
loading.value = false
}
}
function search() {
query.pageNum = 1
load()
}
function setStatus(v: number | undefined) {
statusFilter.value = v
search()
}
function onPageChange(page: number) {
query.pageNum = page
load()
}
function onAdd() {
formModalRef.value?.add()
}
function onEdit(row: TaskVO) {
formModalRef.value?.edit(row)
}
async function onDelete(row: TaskVO) {
await taskApi.deleteTask(row.id)
Message.success('已删除')
await load()
}
/**
* 发布。
*
* 先不带 force 试一次:如果服务端说"存在待确认的提醒",说明 5 项阻断级校验都过了,
* 只剩 P1 级提醒(比如某班没指定初审人)。这时才用二次确认 + force 重发。
* 如果返回的是阻断级错误码(40016/40017/40018/40019/40020),
* 说明根本还没配齐 —— 直接展示缺项,**不给 force 的入口**(给了也绕不过去,只会让人白试)。
*/
async function onPublish(row: TaskVO) {
try {
const res = await taskApi.publishTask(row.id, false)
Message.success('已发布,学生可以开始填报了')
const warns = res.data?.warnings ?? []
if (warns.length) {
Modal.info({ title: '发布时的提醒', content: warns.join(';') })
}
await load()
} catch (e) {
const err = e as Error & { bizCode?: number }
const msg = err?.message ?? ''
// 阻断级错误码:把缺项讲清楚,不给 force
const blocking = [40016, 40017, 40018, 40019, 40020]
if (err?.bizCode && blocking.includes(err.bizCode)) {
Modal.warning({
title: '还不能发布',
content: `${msg}
这些是阻断级检查,必须补齐后重新发布(点「确定」不会放行)。`,
hideCancel: true
})
return
}
// P1 级提醒:二次确认后可 force
Modal.confirm({
title: '发现待确认的提醒',
content: `${msg}
确认无误后可以继续发布。`,
okText: '确认并发布',
onOk: async () => {
await taskApi.publishTask(row.id, true)
Message.success('已发布')
await load()
}
})
}
}
function onPause(row: TaskVO) {
const reason = ''
Modal.confirm({
title: `暂停「${row.taskName}」?`,
content: '暂停后学生<b>不能提交</b>,但<b>已提交的照常审核</b>,而且随时可以恢复。'
+ '适合"名单要改、规则要调"的场景 —— 如果是不再收集了,请用「截止」。',
okText: '暂停',
onOk: async () => {
await taskApi.pauseTask(row.id, reason || undefined)
Message.success('已暂停')
await load()
}
})
}
async function onResume(row: TaskVO) {
await taskApi.resumeTask(row.id)
Message.success('已恢复')
await load()
}
function onClose(row: TaskVO) {
Modal.warning({
title: `截止「${row.taskName}」?`,
content: '截止后学生不能提交,且<b>不可逆</b>(不能再恢复为收集中)。已提交的仍可审核。',
okText: '确认截止',
hideCancel: false,
onBeforeOk: async () => {
await taskApi.closeTask(row.id)
Message.success('已截止')
await load()
return true
}
})
}
function onArchive(row: TaskVO) {
Modal.warning({
title: `归档「${row.taskName}」?`,
content: '归档后<b>连审核也不能做</b>,整体转为只读。有未审完的单子会被拦下。',
okText: '确认归档',
hideCancel: false,
onBeforeOk: async () => {
await taskApi.archiveTask(row.id)
Message.success('已归档')
await load()
return true
}
})
}
onMounted(load)
</script>
<style lang="scss" scoped>
.task {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__batch {
width: 120px;
}
&__search {
width: 240px;
}
&__name {
font-weight: 500;
color: var(--color-text-1);
}
&__code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--color-text-3);
}
&__muted {
font-size: 12px;
color: var(--color-text-3);
}
}
</style>
+46 -24
View File
@@ -59,22 +59,40 @@
<!--
⚠️ `:scroll` 不是可选的:Arco 的 `fixed: 'right'` 列**必须**配合 scroll 配置
才会预留位置;不加的话那一列会直接浮在其它列上面,
看起来像"操作列插到手机号前面了"。
总列宽 1140,这里给 1160 让它在常见分辨率下不出现横向滚动条、
窄屏时又能正常滚动并把操作列钉在右边。
才会预留位置;不加的话那一列会直接浮在其它列上面。
⚠️ 列宽也不是随便给的:1366 宽的屏幕减去 200 侧栏,内容区只有约 830px。
下面 6 列合计 860 —— 基本贴边。**下次加列前先按这个口径算一遍**,
否则会横向滚动,而钉住的「操作」列会盖住紧邻它的那一列
(实测把「状态」整个遮住了,表头挤成「进度操作」)。
-->
<!--
⚠️ 关于列宽与「操作」列的 fixed:**改之前先量一下表格可用宽度。**
实测(1366 宽的屏幕):
· 收集任务页:左侧栏 200,表格可用约 824px
· 账号管理页:左侧栏 200 + 左侧组织树卡约 250,表格可用**只有约 574px**
所以账号管理页必须保留 `fixed="right"`:
列宽合计 800px 装不进 574px,必然横向滚动;
横向滚动时**钉住的操作列会盖住紧邻它的那一列**,这是固定列的固有行为,不是配置错了。
去掉 fixed 看似不压盖了,实际是把操作列滚出视野 ——
用户根本看不到「编辑 / 重置密码 / 停用」,那比压盖更糟。
要真正消除压盖只有一条路:**把列数减到能在 574px 内放下**。
留给下一轮:把「手机号」或「状态 / 最后登录」并进「姓名 / 账号」那一列。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 1160 }" @page-change="onPageChange">
:scroll="{ x: 800 }" @page-change="onPageChange">
<template #columns>
<a-table-column title="姓名 / 账号" :width="190">
<a-table-column title="姓名 / 账号" :width="175">
<template #cell="{ record }">
<div class="user__name">{{ record.realName }}</div>
<div class="user__account">{{ record.username ?? '(未设账号)' }}</div>
</template>
</a-table-column>
<a-table-column title="角色" :width="200">
<a-table-column title="角色" :width="170">
<template #cell="{ record }">
<a-space v-if="record.roleNames.length" wrap size="mini">
<a-tag v-for="n in record.roleNames" :key="n" size="small">{{ n }}</a-tag>
@@ -83,13 +101,7 @@
</template>
</a-table-column>
<a-table-column title="所属组织" data-index="orgName" :width="160">
<template #cell="{ record }">
{{ record.orgName ?? '—' }}
</template>
</a-table-column>
<a-table-column title="手机号" :width="140">
<a-table-column title="手机号" :width="125">
<template #cell="{ record }">
<span class="user__mono">{{ record.phoneMasked ?? '—' }}</span>
</template>
@@ -102,20 +114,23 @@
看着像 bug。类型仍由接口返回,需要时在详情里看即可 —— 列表以「角色」为准。
-->
<a-table-column title="状态" :width="100" align="center">
<a-table-column title="状态 / 最后登录" :width="130" align="center">
<template #cell="{ record }">
<a-tag :color="USER_STATUS[record.status]?.color ?? 'gray'">
{{ USER_STATUS[record.status]?.text ?? '未知' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="最后登录" :width="150">
<template #cell="{ record }">
<span v-if="record.lastLoginTime" class="user__muted">
{{ record.lastLoginTime.replace('T', ' ').slice(0, 16) }}
</span>
<span v-else class="user__muted">从未登录</span>
<!--
最后登录并进这一列,是为了给窄屏省宽度。
1366 的屏幕减去 200 侧栏,内容区只有约 830px,
7 列各占一份根本放不下 —— 放不下的后果是横向滚动,
而钉住的「操作」列会直接盖住紧邻它的那一列(实测「状态」被整个遮住)。
这里用"标签 + 一行小字"把两列并成一列,信息一点没少。
-->
<div class="user__muted user__lastlogin">
{{ record.lastLoginTime
? record.lastLoginTime.replace('T', ' ').slice(0, 16)
: '从未登录' }}
</div>
</template>
</a-table-column>
@@ -404,6 +419,13 @@ onMounted(() => {
color: var(--color-text-3);
}
/** 与状态标签同列的"最后登录"小字 */
&__lastlogin {
margin-top: 4px;
font-size: 11px;
line-height: 1.5;
}
&__pwd-box {
margin: 16px 0 0;
}