feat(task): M1-02 覆盖班级与班主任配置
补上发布前置校验的最后一环(40019),同时确立任务的数据隔离边界 ——
班主任能看到哪些任务、能审哪些单子,全由 collect_task_org 决定。
后端 GET/PUT /tasks/{id}/orgs(整体替换语义 + 软移除 + 历史行复用);
前端「配置班级」抽屉:组织树只允许勾选班级,主班主任留空时显式提示
「将由该班负责人初审」而非留白。发布被拦时给出「去哪里补」的指引
(BLOCKING_HINTS),而不是只说"未设置覆盖班级"。
三条守住的规则:
1) 班级与初审人都必须在本租户内。跨租户的 id 一律当作"不存在或已删除",
不透露"它存在但不属于你" —— 漏了的后果是 A 校能把 B 校的班挂到自己
的任务上,B 校的班主任就出现在 A 校的审批流里。
2) 初审人必须持有班主任/副班主任角色。指定初审人本质上是一次授权,
那个人将能看到该班学生的身份证号与学籍材料;不校验角色就等于把
"谁能看学生隐私"变成任意授权。报错要给出下一步,不能只说"校验失败"。
3) 移除是软移除(status=0),且「移除再加回」必须复用原行。
后者不是优化而是必须:uk_task_org UNIQUE (task_id, org_id) 不带 status
条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束。
本轮修掉的 3 个真问题:
1) 我此前误判 uk_task_org 不存在 —— grep 只匹配含表名的行,而
CONSTRAINT uk_task_org UNIQUE (task_id, org_id) 那行没有表名。
基于这个错误前提,我写了一个多余的部分唯一索引 uk_task_org_active
和一段基于错误前提的代码,已全部撤掉并更正注释。
教训:查约束要看 CREATE TABLE 整块,不能只 grep 表名。
2) collect_task_org.expected_count 是 NOT NULL DEFAULT 0,与接口契约矛盾:
DDL 注释说它是"由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在
PUT 请求体里、说"用于与名册实际人数比对" —— 只有"管理员填的期望值 vs
名册实际值"才需要比对。而 0 是有意义的业务值("预期 0 人"),拿它兼职
表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警;告警
变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉,
而这条告警的全部价值就在那一次。已由 V4 迁移改为可空 + 去掉默认值,
并同步更正 docs/db/schema-postgresql.sql 与 PRD 里的内联 DDL。
3) 删掉迁移文件后必须 mvn clean,否则它会"复活":我只跑 mvn package,
target/classes 里的旧副本被打进 jar,Flyway 发现历史里没有 v4 就又执行
了一遍 —— 索引回来了、历史行填回去了。之后又建了同名版本号的新 V4,
于是"两个 v4"→"checksum 冲突",排查绕了好几圈。
顺带修掉:
· 任务列表操作列从 3 个链接涨到 4 个,160px 会挤成竖排 —— 把「进度」并进
「状态」列腾出 40px,操作列给到 200px,合计 820px(可用约 824px)。
· defineEmits 必须用函数调用签名(frontend-pc/.agents/skills/code-vue)。
我此前写的 5 个组件都是被禁的 tuple 形式,一并改正:
OrgFormModal / TaskFormModal / TaskOrgDrawer / UserFormModal / UserImportModal。
· e2e-m1-orgs.py 的清理抽成幂等的 cleanup_all(),并在 __main__ 的 finally
里兜一次 —— 该脚本首次运行时崩在准备阶段、清理段没执行,把两个测试账号
留在了库里(是在浏览器里看到下拉框里有个「E2E班主任」才发现的)。
其余三条 E2E 脚本仍是老样子,下一轮一并改。
验收:e2e-m1-orgs.py 48/48(含跨租户隔离);e2e-m1-task.py 71/71、
e2e-m0-user.py 55/55、e2e-m0.py 36/36 回归通过,共 210 项断言;
前端 typecheck / eslint / lint:style 0 错、build 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
* 分页 `{ list, total }`(适配层会补一份 `records`)。
|
||||
*/
|
||||
import http from '@/utils/http'
|
||||
import { getUserList } from './user'
|
||||
|
||||
/** 审核流配置(§3.2) */
|
||||
export interface AuditConfig {
|
||||
@@ -217,3 +218,64 @@ export const NAMING_VARIABLES = [
|
||||
|
||||
/** 默认命名模板(与 DDL 的列默认值一致) */
|
||||
export const DEFAULT_NAMING_TEMPLATE = '{姓名}-{证件号}-{材料名}'
|
||||
|
||||
// ── 覆盖班级与班主任(§7.6)──────────────────────────
|
||||
|
||||
/** 任务覆盖的一个班 */
|
||||
export interface TaskOrgVO {
|
||||
orgId: number
|
||||
orgName: string
|
||||
/**
|
||||
* 该班初审人。
|
||||
*
|
||||
* ⚠️ 为空<b>不是</b>「没人审」,而是「取该班负责人」(`sys_org.leader_user_id`)。
|
||||
* 界面上要把这个区别讲清楚,否则管理员会以为漏配了,
|
||||
* 然后在几百个班上机械地点几百次 —— 而它们本来就等于班级负责人。
|
||||
*/
|
||||
headUserId: number | null
|
||||
headUserName: string | null
|
||||
deputyUserIds: number[]
|
||||
deputyUserNames: string[]
|
||||
/** 应填人数(期望值)。null = 未设置,与 0(预期 0 人)含义不同 */
|
||||
expectedCount: number | null
|
||||
/** 恒为 1(只返回生效行) */
|
||||
status: number
|
||||
}
|
||||
|
||||
/** 一个班的提交项。PUT 是整体替换语义:请求体就是最终答案 */
|
||||
export interface TaskOrgItem {
|
||||
orgId: number
|
||||
headUserId?: number | null
|
||||
deputyUserIds?: number[]
|
||||
/** 不传或传 null 表示"未设置",**不要传 0** —— 那是"预期 0 人" */
|
||||
expectedCount?: number | null
|
||||
}
|
||||
|
||||
export function getTaskOrgs(taskId: number) {
|
||||
return http.get<TaskOrgVO[]>(`/tasks/${taskId}/orgs`)
|
||||
}
|
||||
|
||||
/** 设置覆盖班级与班主任。不在列表里的班级会被软移除 */
|
||||
export function saveTaskOrgs(taskId: number, orgs: TaskOrgItem[]) {
|
||||
return http.put<void>(`/tasks/${taskId}/orgs`, { orgs })
|
||||
}
|
||||
|
||||
/**
|
||||
* 可被指定为初审人的账号。
|
||||
*
|
||||
* 拉两种角色再合并:后端要求初审人持有班主任**或**副班主任角色,
|
||||
* 所以两边的账号都是合法选项。只取启用状态 —— 停用的账号会被后端拒掉,
|
||||
* 列在选项里只会让人白选一次。
|
||||
*/
|
||||
export async function getTeacherOptions(): Promise<Array<{ id: number, label: string }>> {
|
||||
const [head, deputy] = await Promise.all([
|
||||
getUserList({ roleCode: 'HEAD_TEACHER', status: 1, pageSize: 200 }),
|
||||
getUserList({ roleCode: 'DEPUTY_TEACHER', status: 1, pageSize: 200 })
|
||||
])
|
||||
const merged = new Map<number, string>()
|
||||
for (const u of [...(head.data?.list ?? []), ...(deputy.data?.list ?? [])]) {
|
||||
// 带账号:学校里同名同姓很常见,只显示姓名选错了就是把别人的班交给了错误的人
|
||||
merged.set(u.id, u.username ? `${u.realName}(${u.username})` : u.realName)
|
||||
}
|
||||
return Array.from(merged.entries(), ([id, label]) => ({ id, label }))
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ import { ORG_TYPE_NAMES } from '@/apis/xt/org'
|
||||
*/
|
||||
defineOptions({ name: 'OrgFormModal' })
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
|
||||
@@ -119,7 +119,9 @@ import { DEFAULT_NAMING_TEMPLATE, NAMING_VARIABLES } from '@/apis/xt/task'
|
||||
*/
|
||||
defineOptions({ name: 'TaskFormModal' })
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" :width="820" :mask-closable="false" unmount-on-close
|
||||
:ok-loading="saving" ok-text="保存" @ok="save" @cancel="close">
|
||||
<template #title>
|
||||
<span>配置覆盖班级</span>
|
||||
<span class="org-drawer__task">{{ taskName }}</span>
|
||||
</template>
|
||||
|
||||
<a-alert v-if="readonly" type="normal" :show-icon="false" class="org-drawer__readonly">
|
||||
任务已{{ readonlyReason }},覆盖范围不可再修改,只能查看。
|
||||
</a-alert>
|
||||
|
||||
<a-spin :loading="loading" class="org-drawer__body">
|
||||
<div class="org-drawer__panes">
|
||||
<!-- 左:组织树 -->
|
||||
<div class="org-drawer__tree">
|
||||
<div class="org-drawer__pane-title">
|
||||
选择班级
|
||||
<!-- 只让班级可勾选:把「学校」或「专业部」当成一个班会选错范围,
|
||||
而且错误很隐蔽 —— 界面上只多一行,实际却把整校学生算进了应填范围 -->
|
||||
<span class="org-drawer__pane-hint">只能勾选到班级一层</span>
|
||||
</div>
|
||||
<a-spin :loading="treeLoading" class="org-drawer__tree-spin">
|
||||
<a-tree v-if="treeData.length" v-model:checked-keys="checkedKeys" :data="treeData"
|
||||
:field-names="treeFieldNames" checkable check-strictly :default-expand-all="true"
|
||||
:disabled="readonly" />
|
||||
<a-empty v-else description="还没有班级,请先在「组织架构」里建班级" />
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 右:已选班级 -->
|
||||
<div class="org-drawer__list">
|
||||
<div class="org-drawer__pane-title">
|
||||
已选 <strong>{{ rows.length }}</strong> 个班
|
||||
<span class="org-drawer__pane-hint">主班主任留空 = 由该班负责人初审</span>
|
||||
</div>
|
||||
|
||||
<a-empty v-if="!rows.length" description="左侧勾选本次收集涉及的班级" />
|
||||
|
||||
<div v-for="row in rows" :key="row.orgId" class="org-drawer__row">
|
||||
<div class="org-drawer__row-head">
|
||||
<span class="org-drawer__org">{{ orgName(row.orgId) }}</span>
|
||||
<a-link v-if="!readonly" status="danger" @click="removeRow(row)">移除</a-link>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="11">
|
||||
<div class="org-drawer__label">主班主任(初审人)</div>
|
||||
<a-select v-model="row.headUserId" placeholder="留空 = 取班级负责人" allow-clear
|
||||
:options="teacherOptions" :disabled="readonly" size="small" />
|
||||
<div v-if="!row.headUserId" class="org-drawer__fallback">
|
||||
{{ leaderHint(row.orgId) }}
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="org-drawer__label">应填人数</div>
|
||||
<a-input-number v-model="row.expectedCount" placeholder="未设置" :min="0"
|
||||
:disabled="readonly" size="small" class="org-drawer__num" />
|
||||
<div class="org-drawer__fallback">留空表示不比对,填了才与名册比</div>
|
||||
</a-col>
|
||||
<a-col :span="5">
|
||||
<div class="org-drawer__label">副班主任</div>
|
||||
<a-select v-model="row.deputyUserIds" multiple placeholder="无" allow-clear
|
||||
:options="deputyOptionsOf(row)" :disabled="readonly" size="small" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TreeFieldNames, TreeNodeData } from '@arco-design/web-vue'
|
||||
import type { OrgTreeVO } from '@/apis/xt/org'
|
||||
import type { TaskOrgItem } from '@/apis/xt/task'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import * as orgApi from '@/apis/xt/org'
|
||||
import * as taskApi from '@/apis/xt/task'
|
||||
|
||||
/**
|
||||
* 任务「覆盖班级与班主任」配置抽屉(《03-接口设计》§7.6)。
|
||||
*
|
||||
* 三处刻意的设计:
|
||||
*
|
||||
* 1. **主班主任留空 ≠ 没配**。留空表示"取该班负责人",所以留空时下面会显示出
|
||||
* 负责人是谁。不这么做,管理员会以为漏配了,然后在几百个班上机械地点几百次 ——
|
||||
* 而它们本来就等于班级负责人。这是本产品"少让人做无意义操作"的一个具体体现。
|
||||
*
|
||||
* 2. **只让班级可勾选**。把「学校」或「专业部」当成一个班会选错范围,而且错误很隐蔽:
|
||||
* 界面上只多一行,实际却把整校学生算进了应填范围。服务端也会拒,但让它在界面上
|
||||
* 就点不动,比事后报错好。
|
||||
*
|
||||
* 3. **应填人数留空 = 未设置,不是 0**。0 是"预期 0 人",是个有意义的业务值;
|
||||
* 拿 0 兼职表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警,
|
||||
* 告警变噪音后就没人看了,真正对不上的那次反而被漏掉。
|
||||
*/
|
||||
defineOptions({ name: 'TaskOrgDrawer' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const treeLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const taskId = ref<number | null>(null)
|
||||
const taskName = ref('')
|
||||
const readonly = ref(false)
|
||||
const readonlyReason = ref('')
|
||||
|
||||
const treeData = ref<TreeNodeData[]>([])
|
||||
const checkedKeys = ref<number[]>([])
|
||||
const teacherOptions = ref<Array<{ id: number, label: string }>>([])
|
||||
|
||||
/** 班级 id → 组织节点,用于取名与取"该班负责人" */
|
||||
const orgIndex = ref<Map<number, OrgTreeVO>>(new Map())
|
||||
|
||||
interface Row {
|
||||
orgId: number
|
||||
/**
|
||||
* 主班主任。
|
||||
*
|
||||
* ⚠️ 用 `undefined` 而不是 `null` 表示"未指定":Arco 的 `a-select` 的 v-model
|
||||
* 只接受 `string | number | (string|number)[] | undefined`,
|
||||
* 绑 `number | null` 会报 TS2322。提交时再转回 `null`(接口约定的"清空"值)。
|
||||
*/
|
||||
headUserId: number | undefined
|
||||
deputyUserIds: number[]
|
||||
/** undefined = 未设置(提交时不传/传 null),不要用 0 代替 —— 0 是"预期 0 人" */
|
||||
expectedCount: number | undefined
|
||||
}
|
||||
|
||||
const rows = ref<Row[]>([])
|
||||
|
||||
const treeFieldNames: TreeFieldNames = {
|
||||
key: 'key',
|
||||
title: 'title',
|
||||
children: 'children',
|
||||
disableCheckbox: 'disableCheckbox'
|
||||
}
|
||||
|
||||
function flatten(nodes: OrgTreeVO[], into: Map<number, OrgTreeVO>) {
|
||||
for (const n of nodes || []) {
|
||||
into.set(n.id, n)
|
||||
flatten(n.children ?? [], into)
|
||||
}
|
||||
}
|
||||
|
||||
function toTreeData(nodes: OrgTreeVO[]): TreeNodeData[] {
|
||||
return (nodes ?? []).map((n) => ({
|
||||
key: n.id,
|
||||
title: n.orgName,
|
||||
// 只有班级可以勾选
|
||||
disableCheckbox: n.orgType !== 3,
|
||||
children: toTreeData(n.children ?? [])
|
||||
}))
|
||||
}
|
||||
|
||||
function orgName(orgId: number) {
|
||||
return orgIndex.value.get(orgId)?.orgName ?? `班级 ${orgId}`
|
||||
}
|
||||
|
||||
/** 留空时告诉用户"将由谁审",而不是留一句让人怀疑的空白 */
|
||||
function leaderHint(orgId: number) {
|
||||
const org = orgIndex.value.get(orgId)
|
||||
return org?.leaderName ? `将由班级负责人 ${org.leaderName} 初审` : '该班未设负责人,建议指定初审人'
|
||||
}
|
||||
|
||||
/** 副班主任选项里排除掉本行已选为主班主任的人(同一人不能既主又副,服务端会拒) */
|
||||
function deputyOptionsOf(row: Row) {
|
||||
return teacherOptions.value.filter((o) => o.id !== row.headUserId)
|
||||
}
|
||||
|
||||
/** 勾选变化 → 行列表变化。rows 是唯一事实来源,勾选框只是它的一个视图 */
|
||||
watch(checkedKeys, (keys) => {
|
||||
const set = new Set(keys as number[])
|
||||
rows.value = rows.value.filter((r) => set.has(r.orgId))
|
||||
for (const id of set) {
|
||||
if (!rows.value.some((r) => r.orgId === id)) {
|
||||
rows.value.push({ orgId: id, headUserId: undefined, deputyUserIds: [], expectedCount: undefined })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function removeRow(row: Row) {
|
||||
rows.value = rows.value.filter((r) => r.orgId !== row.orgId)
|
||||
checkedKeys.value = checkedKeys.value.filter((k) => k !== row.orgId)
|
||||
}
|
||||
|
||||
async function open(task: { id: number, taskName: string, status: number }) {
|
||||
taskId.value = task.id
|
||||
taskName.value = task.taskName
|
||||
// 与后端 EDITABLE_TASK_STATUS 一致:草稿/收集中/已暂停可改,已截止/已归档只读
|
||||
const reason = { 2: '截止', 3: '归档' }[task.status] ?? ''
|
||||
readonly.value = !!reason
|
||||
readonlyReason.value = reason
|
||||
visible.value = true
|
||||
rows.value = []
|
||||
checkedKeys.value = []
|
||||
|
||||
loading.value = true
|
||||
treeLoading.value = true
|
||||
try {
|
||||
const [treeRes, orgRes] = await Promise.all([
|
||||
orgApi.getOrgTree(),
|
||||
taskApi.getTaskOrgs(task.id)
|
||||
])
|
||||
const tree = treeRes.data ?? []
|
||||
treeData.value = toTreeData(tree)
|
||||
const index = new Map<number, OrgTreeVO>()
|
||||
flatten(tree, index)
|
||||
orgIndex.value = index
|
||||
|
||||
for (const o of orgRes.data ?? []) {
|
||||
rows.value.push({
|
||||
orgId: o.orgId,
|
||||
// 后端回 null 表示"未指定";界面上用 undefined 让它显示占位符
|
||||
headUserId: o.headUserId ?? undefined,
|
||||
deputyUserIds: o.deputyUserIds ?? [],
|
||||
expectedCount: o.expectedCount ?? undefined
|
||||
})
|
||||
}
|
||||
checkedKeys.value = rows.value.map((r) => r.orgId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
treeLoading.value = false
|
||||
}
|
||||
|
||||
// 班主任选项不影响主流程,单独加载,失败也不该让抽屉打不开
|
||||
taskApi.getTeacherOptions()
|
||||
.then((opts) => { teacherOptions.value = opts })
|
||||
.catch(() => { teacherOptions.value = [] })
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!taskId.value) return
|
||||
const orgs: TaskOrgItem[] = rows.value.map((r) => ({
|
||||
orgId: r.orgId,
|
||||
headUserId: r.headUserId ?? null,
|
||||
deputyUserIds: r.deputyUserIds ?? [],
|
||||
// 未填就传 null("未设置"),不要传 0
|
||||
expectedCount: r.expectedCount ?? null
|
||||
}))
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await taskApi.saveTaskOrgs(taskId.value, orgs)
|
||||
Message.success(orgs.length ? `已保存 ${orgs.length} 个班的覆盖范围` : '已清空覆盖范围')
|
||||
visible.value = false
|
||||
emit('save-success')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.org-drawer {
|
||||
&__task {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__readonly {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: block;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
&__panes {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__tree {
|
||||
flex: none;
|
||||
width: 250px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__tree-spin {
|
||||
display: block;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__pane-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
&__pane-hint {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__row {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__org {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__fallback {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__num {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -49,7 +49,7 @@
|
||||
留给下一轮:把「手机号」或「状态 / 最后登录」并进「姓名 / 账号」那一列。
|
||||
-->
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||
:scroll="{ x: 840 }" @page-change="onPageChange">
|
||||
:scroll="{ x: 820 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="任务" :width="230" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
@@ -72,31 +72,34 @@
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="进度" :width="90">
|
||||
<!--
|
||||
「进度」并进「状态」列,是为了给操作列腾宽度。
|
||||
1366 的屏幕减去 200 侧栏,表格可用约 824px;操作列需要 200px 才放得下
|
||||
「编辑 / 配置班级 / 发布 / 删除」四个链接(160px 会挤成竖排)。
|
||||
进度在 M1 阶段恒为「待导入名册」,并进来不损失信息。
|
||||
-->
|
||||
<a-table-column title="状态 / 进度" :width="110" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="TASK_STATUS[record.status]?.color ?? 'gray'">
|
||||
{{ TASK_STATUS[record.status]?.text ?? '未知' }}
|
||||
</a-tag>
|
||||
<!--
|
||||
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>
|
||||
<div class="task__muted">
|
||||
{{ record.stat ? record.stat.submitRate : '待导入名册' }}
|
||||
</div>
|
||||
</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">
|
||||
<a-table-column title="操作" :width="200" 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="onPublish(record)">发布</a-link>
|
||||
<a-popconfirm content="删除后不可恢复,确认删除这个草稿?" type="warning"
|
||||
@ok="onDelete(record)">
|
||||
@@ -105,11 +108,13 @@
|
||||
</template>
|
||||
|
||||
<template v-else-if="record.status === TASK_STATUS_COLLECTING">
|
||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<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 @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<a-link status="success" @click="onResume(record)">恢复</a-link>
|
||||
<a-link @click="onClose(record)">截止</a-link>
|
||||
</template>
|
||||
@@ -128,6 +133,7 @@
|
||||
</a-card>
|
||||
|
||||
<TaskFormModal ref="formModalRef" @save-success="load" />
|
||||
<TaskOrgDrawer ref="orgDrawerRef" @save-success="load" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
@@ -145,6 +151,7 @@ import {
|
||||
} from '@/apis/xt/task'
|
||||
import { useUserStore } from '@/stores'
|
||||
import TaskFormModal from './TaskFormModal.vue'
|
||||
import TaskOrgDrawer from './TaskOrgDrawer.vue'
|
||||
|
||||
/**
|
||||
* 收集任务(《03-接口设计》§7.1 ~ §7.5)。
|
||||
@@ -172,6 +179,22 @@ const statusFilter = ref<number | undefined>(undefined)
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, keyword: '', batchNo: '' })
|
||||
|
||||
const formModalRef = useTemplateRef('formModalRef')
|
||||
const orgDrawerRef = useTemplateRef('orgDrawerRef')
|
||||
|
||||
/**
|
||||
* 发布被拦时,每个阻断项**去哪里修**。
|
||||
*
|
||||
* 光说"未设置覆盖班级或班主任"是没用的:用户知道缺什么,但不知道在哪补。
|
||||
* 而这 5 项分属 4 个不同的界面(名册 / 材料规则 / 本页的配置班级 / 编辑弹窗),
|
||||
* 让用户自己找是纯粹的浪费。
|
||||
*/
|
||||
const BLOCKING_HINTS: Record<number, string> = {
|
||||
40016: '请先到「名册管理」导入本任务的报考名册',
|
||||
40017: '名册缺少「姓名」或「身份证号」列,请重新导入名册',
|
||||
40018: '请到「材料规则」里配置至少一项必传材料',
|
||||
40019: '请点本行的「配置班级」,勾选本次收集涉及的班级',
|
||||
40020: '命名规则有问题,请点「编辑」检查文件命名模板'
|
||||
}
|
||||
|
||||
const statusChips = [
|
||||
{ label: '全部', value: undefined },
|
||||
@@ -233,6 +256,10 @@ function onEdit(row: TaskVO) {
|
||||
formModalRef.value?.edit(row)
|
||||
}
|
||||
|
||||
function onConfigOrgs(row: TaskVO) {
|
||||
orgDrawerRef.value?.open(row)
|
||||
}
|
||||
|
||||
async function onDelete(row: TaskVO) {
|
||||
await taskApi.deleteTask(row.id)
|
||||
Message.success('已删除')
|
||||
@@ -259,12 +286,14 @@ async function onPublish(row: TaskVO) {
|
||||
} catch (e) {
|
||||
const err = e as Error & { bizCode?: number }
|
||||
const msg = err?.message ?? ''
|
||||
// 阻断级错误码:把缺项讲清楚,不给 force
|
||||
// 阻断级错误码:把缺项讲清楚 + 告诉他去哪里补,不给 force 入口
|
||||
// (给了也绕不过去,只会让人白试一次)
|
||||
const blocking = [40016, 40017, 40018, 40019, 40020]
|
||||
if (err?.bizCode && blocking.includes(err.bizCode)) {
|
||||
const hint = BLOCKING_HINTS[err.bizCode]
|
||||
Modal.warning({
|
||||
title: '还不能发布',
|
||||
content: `${msg}
|
||||
content: `${msg}${hint ? `\n\n👉 ${hint}` : ''}
|
||||
|
||||
这些是阻断级检查,必须补齐后重新发布(点「确定」不会放行)。`,
|
||||
hideCancel: true
|
||||
|
||||
@@ -153,7 +153,9 @@ import { useUserStore } from '@/stores'
|
||||
*/
|
||||
defineOptions({ name: 'UserFormModal' })
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
|
||||
@@ -177,7 +177,9 @@ import { useUserStore } from '@/stores'
|
||||
*/
|
||||
defineOptions({ name: 'UserImportModal' })
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user