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:
@@ -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 = '{姓名}-{证件号}-{材料名}'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user