refactor(pc): 从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI
旧模板的 193 个文件整体删除,换成纯 Vue 3.5 + Naive UI 2.42 的树。
- 去掉 auto-import(组件与 API 全显式 import)、v-hasPerm(改 user.has('码'),全库 8 处)、
Stylelint;n-tag 的 type 统一过 utils/tag.ts 转换(Arco 色词直接透传会静默变灰)
- 图标改为构建期抽取:scripts/extract-pc-icons.py -> icons/icon-data.ts(37 个),
内网离线部署不会白屏
- 重搭后修掉 6 类"编译 / lint / typecheck 全绿、界面上就是坏的"故障:
① NGrid 的 cols 写成像素值(不是断点名 -> 永远只 1 列,工作台因此错乱)
② n-tab-pane 用 tab 属性(只渲染空注释节点,整排 tab 一个字都不显示)
③ 漏 import 被当未知自定义元素(样式整块丢) ④ .n-card__content 类名不存在
(高度链第一环失效,表格行在 DOM 里但屏幕上没有) ⑤ 填报单状态映射错位一格
⑥ 有标题的卡片没有上内边距(naive 的 padding-top 只给"内容区是长子"的卡片)
- 卡片式版面提成全局基线:styles/index.scss 的 xt-* 令牌 + .page-card / .toolbar /
.section-title,页面只引用、不再各写一套
- 品牌:改名「深学在线」,文字标换成几何标(方 = 卷宗 / 环 = 在线,不用文字字形);
index.html 的页签图标从 404 的 /favicon.ico 改指真实存在的 /favicon.svg
This commit is contained in:
@@ -1,132 +1,48 @@
|
||||
<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="预览(以「张同学 / 361029200912100015 / 身份证」为例)">
|
||||
<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'
|
||||
|
||||
/**
|
||||
* 建 / 改任务弹窗。
|
||||
* 建 / 改任务弹窗(《03-接口设计》§7.2)。
|
||||
*
|
||||
* 三处刻意的设计:
|
||||
* 1. **命名模板带实时预览**:光看 `{姓名}-{证件号}-{材料名}` 很难想象最终长什么样,
|
||||
* 而它直接决定导出包里每个文件的名字。用样例数据渲染出来一眼就能判断。
|
||||
* 2. **收集模式与认定类型做成只读**:它们决定审核流与材料清单,
|
||||
* 一期也不启用其它取值。可编辑只会让人以为能改。
|
||||
* 2. **收集模式与认定类型做成只读**:它们决定审核流与材料清单,本期也不开放其它取值。
|
||||
* 可编辑只会让人以为能改。
|
||||
* 3. **变量用按钮插入而不是让人手打**:手打 `{姓名}` 少一个大括号或写成 `{姓名 }`,
|
||||
* 服务端只会在发布时给一个 40020,而那时已经配了一堆别的东西。
|
||||
*/
|
||||
import type { FormInst, FormRules } from 'naive-ui'
|
||||
import type { TaskUpsertReq, TaskVO } from '@/apis/xt/task'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDatePicker,
|
||||
NDivider,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NFormItemGi,
|
||||
NGrid,
|
||||
NInput,
|
||||
NModal,
|
||||
NSpace,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, reactive, ref, useTemplateRef } from 'vue'
|
||||
import * as taskApi from '@/apis/xt/task'
|
||||
import { DEFAULT_NAMING_TEMPLATE, NAMING_VARIABLES } from '@/apis/xt/task'
|
||||
|
||||
defineOptions({ name: 'TaskFormModal' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const formRef = useTemplateRef<FormInst>('formRef')
|
||||
|
||||
const isEdit = computed(() => !!detailId.value)
|
||||
const title = computed(() => (isEdit.value ? '修改任务' : '新建收集任务'))
|
||||
@@ -134,59 +50,63 @@ const title = computed(() => (isEdit.value ? '修改任务' : '新建收集任
|
||||
/**
|
||||
* 表单自己的类型。
|
||||
*
|
||||
* 与 {@link TaskUpsertReq} 的差别只在 `jobName` / `jobLevel`:
|
||||
* 接口那边允许 `null`(表示"清空"),而 Arco 的 `a-input` 的 v-model 只接受
|
||||
* `string | undefined` —— 直接绑 `string | null | undefined` 会报 TS2322。
|
||||
* 界面上用空串表示"没填",发给服务端前由 Service 的 `trimToNull` 转成 null。
|
||||
* 与 {@link TaskUpsertReq} 的差别是这里的 `jobName` / `jobLevel` 用空串表示"没填"
|
||||
* (接口那边用 `null` 表示清空);另外收集模式与认定类型**界面上没有输入项**,
|
||||
* 构造请求体时写死为 1(本期只有学校收集 + 自主认定)。
|
||||
*
|
||||
* `startTime` / `endTime` 允许 `null`:Naive 的日期选择器清空时给的是 `null`。
|
||||
*/
|
||||
type TaskForm = Omit<TaskUpsertReq, 'jobName' | 'jobLevel'> & {
|
||||
interface TaskForm {
|
||||
taskName: string
|
||||
batchNo: string
|
||||
majorName: string
|
||||
jobName: string
|
||||
jobLevel: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
namingTemplate: string
|
||||
}
|
||||
|
||||
const getInitForm = (): TaskForm => ({
|
||||
taskName: '',
|
||||
collectMode: 1,
|
||||
batchNo: '',
|
||||
majorName: '',
|
||||
jobName: '',
|
||||
jobLevel: '',
|
||||
certType: 1,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
namingTemplate: DEFAULT_NAMING_TEMPLATE
|
||||
})
|
||||
|
||||
const form = reactive(getInitForm())
|
||||
|
||||
const rules: FormInstance['rules'] = {
|
||||
taskName: [{ required: true, message: '请填写任务名称' }],
|
||||
const rules: FormRules = {
|
||||
taskName: { required: true, message: '请填写任务名称', trigger: ['input', 'blur'] },
|
||||
batchNo: [
|
||||
{ required: true, message: '请填写批次号' },
|
||||
{ match: /^\d{4,8}$/, message: '批次号应为 4~8 位数字' }
|
||||
{ required: true, message: '请填写批次号', trigger: ['input', 'blur'] },
|
||||
{ pattern: /^\d{4,8}$/, message: '批次号应为 4~8 位数字', trigger: ['input', 'blur'] }
|
||||
],
|
||||
majorName: [{ required: true, message: '请填写专业' }],
|
||||
startTime: [{ required: true, message: '请选择填报开始时间' }],
|
||||
majorName: { required: true, message: '请填写专业', trigger: ['input', 'blur'] },
|
||||
startTime: { required: true, message: '请选择填报开始时间', trigger: ['change'] },
|
||||
endTime: [
|
||||
{ required: true, message: '请选择填报截止时间' },
|
||||
{ required: true, message: '请选择填报截止时间', trigger: ['change'] },
|
||||
{
|
||||
validator: (value: string, callback: (error?: string) => void) => {
|
||||
validator: (_rule, value: string | null) => {
|
||||
if (value && form.startTime && value <= form.startTime) {
|
||||
callback('截止时间必须晚于开始时间')
|
||||
return
|
||||
return new Error('截止时间必须晚于开始时间')
|
||||
}
|
||||
callback()
|
||||
}
|
||||
return true
|
||||
},
|
||||
trigger: ['change']
|
||||
}
|
||||
],
|
||||
namingTemplate: [{ required: true, message: '请填写命名模板' }]
|
||||
namingTemplate: { required: true, message: '请填写命名模板', trigger: ['input', 'blur'] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览时把 `{自定义字段.xxx}` 整体替换掉。
|
||||
*
|
||||
* 放在模块作用域而不是 computed 里:正则每次渲染都重新编译一遍是纯浪费,
|
||||
* 而且这条规则(e18e/prefer-static-regex)在项目里是开着的。
|
||||
* 放在模块作用域而不是 computed 里:正则每次渲染都重新编译一遍是纯浪费。
|
||||
*/
|
||||
const CUSTOM_FIELD_RE = /\{自定义字段\.[^}]*\}/g
|
||||
|
||||
@@ -208,7 +128,10 @@ const namingPreview = computed(() => {
|
||||
}
|
||||
let out = form.namingTemplate || ''
|
||||
for (const [k, v] of Object.entries(sample)) {
|
||||
out = out.replaceAll(`{${k}}`, v)
|
||||
// 用 split/join 而不是 replaceAll:后者的 lib 声明是 ES2021,
|
||||
// 而本项目的 tsconfig 没把 lib 提到那一档(typecheck 会红)。
|
||||
// 效果一样,且模板里的变量互不重叠,不存在"替换结果又被下一次命中"的问题。
|
||||
out = out.split(`{${k}}`).join(v)
|
||||
}
|
||||
// 自定义字段在预览里没有真实值,用占位提示而不是留一个空
|
||||
out = out.replace(CUSTOM_FIELD_RE, '(自定义字段)')
|
||||
@@ -220,10 +143,8 @@ const namingPreview = computed(() => {
|
||||
*
|
||||
* ⚠️ **不要在模板里拼花括号**(比如 `{{ '{' + v + '}' }}`)。
|
||||
* Vue 的插值解析器是靠找 `}}` 来收尾的,而模板里一旦出现内层的 `}` 与结尾的 `}}`
|
||||
* 相邻(eslint --fix 会把它"帮忙"改成模板字符串 `` `{${v}}` ``,正好制造这种情况),
|
||||
* 就会报 `Error parsing JavaScript expression: Unexpected token, expected "}"` ——
|
||||
* 而且这个错误**只在构建/编译时才出现**,编辑器里看不出问题。
|
||||
* 所以在 script 里算好,模板里只做纯取值。
|
||||
* 相邻,就会报 `Error parsing JavaScript expression` —— 而且这个错误**只在编译时**出现,
|
||||
* 编辑器里看不出问题。所以在 script 里算好,模板里只做纯取值。
|
||||
*/
|
||||
const namingVariableOptions = NAMING_VARIABLES.map((v) => ({ value: v, label: `{${v}}` }))
|
||||
|
||||
@@ -231,7 +152,7 @@ function appendVariable(v: string) {
|
||||
form.namingTemplate = `${form.namingTemplate ?? ''}{${v}}`
|
||||
}
|
||||
|
||||
async function add() {
|
||||
function add() {
|
||||
detailId.value = null
|
||||
Object.assign(form, getInitForm())
|
||||
visible.value = true
|
||||
@@ -245,8 +166,8 @@ function edit(row: TaskVO) {
|
||||
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.startTime = (row.startTime ?? '').slice(0, 10) || null
|
||||
form.endTime = (row.endTime ?? '').slice(0, 10) || null
|
||||
form.namingTemplate = row.namingTemplate ?? DEFAULT_NAMING_TEMPLATE
|
||||
visible.value = true
|
||||
}
|
||||
@@ -256,34 +177,40 @@ function close() {
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return false
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
// ⚠️ Naive 的 `validate()` 在不过时是 **reject**(不是返回 false),所以必须 catch
|
||||
return
|
||||
}
|
||||
|
||||
const end = form.endTime ?? ''
|
||||
const payload: TaskUpsertReq = {
|
||||
taskName: form.taskName.trim(),
|
||||
// 本期只开放这两个取值,界面上也没有输入项 —— 写死而不是从表单里读
|
||||
collectMode: 1,
|
||||
certType: 1,
|
||||
batchNo: (form.batchNo ?? '').trim(),
|
||||
majorName: form.majorName.trim(),
|
||||
jobName: form.jobName?.trim() || null,
|
||||
jobLevel: form.jobLevel?.trim() || null,
|
||||
startTime: form.startTime ?? '',
|
||||
// 只给日期时补上当天最后一秒,避免"截止日期当天 00:00 就截止了"
|
||||
endTime: end.length <= 10 ? `${end}T23:59:59` : end,
|
||||
namingTemplate: (form.namingTemplate ?? '').trim()
|
||||
}
|
||||
|
||||
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('已保存')
|
||||
if (isEdit.value && detailId.value) {
|
||||
await taskApi.updateTask(detailId.value, payload)
|
||||
message.success('已保存')
|
||||
} else {
|
||||
const res = await taskApi.addTask(payload)
|
||||
Message.success(`任务已创建,编码 ${res.data?.taskCode ?? ''}(草稿)`)
|
||||
message.success(`任务已创建,编码 ${res?.taskCode ?? ''}(草稿)`)
|
||||
}
|
||||
visible.value = false
|
||||
emit('save-success')
|
||||
return true
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -292,17 +219,140 @@ async function confirm() {
|
||||
defineExpose({ add, edit })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
:title="title"
|
||||
:style="{ width: '640px' }"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<NGrid :cols="24" :x-gap="16">
|
||||
<NFormItemGi :span="14" label="任务名称" path="taskName">
|
||||
<NInput
|
||||
v-model:value="form.taskName"
|
||||
placeholder="如 2026年9月机电专业认定材料收集"
|
||||
:maxlength="200"
|
||||
clearable
|
||||
/>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="10" label="批次号" path="batchNo">
|
||||
<NInput v-model:value="form.batchNo" placeholder="如 202609" :maxlength="8" clearable />
|
||||
<template #feedback>
|
||||
<span class="task-form__hint">4~8 位数字,同时作为任务编码前缀</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="10" label="专业" path="majorName">
|
||||
<NInput v-model:value="form.majorName" placeholder="如 数控加工" :maxlength="64" clearable />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="7" label="报考工种" path="jobName">
|
||||
<NInput v-model:value="form.jobName" placeholder="可后补" :maxlength="64" clearable />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="7" label="报考等级" path="jobLevel">
|
||||
<NInput v-model:value="form.jobLevel" placeholder="可后补" :maxlength="32" clearable />
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="12" label="填报开始" path="startTime">
|
||||
<NDatePicker
|
||||
v-model:formatted-value="form.startTime"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
class="task-form__date"
|
||||
/>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="12" label="填报截止" path="endTime">
|
||||
<NDatePicker
|
||||
v-model:formatted-value="form.endTime"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
class="task-form__date"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="task-form__hint">只选日期时按当天 23:59:59 处理</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
本期范围
|
||||
</NDivider>
|
||||
|
||||
<NGrid :cols="2" :x-gap="16">
|
||||
<NFormItem label="收集模式">
|
||||
<NInput value="学校收集" disabled />
|
||||
</NFormItem>
|
||||
<NFormItem label="认定类型">
|
||||
<NInput value="自主认定" disabled />
|
||||
</NFormItem>
|
||||
</NGrid>
|
||||
|
||||
<NAlert type="info" :show-icon="false" :bordered="false" class="task-form__scope">
|
||||
本期只做 <strong>学校收集 + 自主认定</strong>:收集模式与认定类型由系统固定,
|
||||
界面上不开放其它取值。
|
||||
</NAlert>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
命名规则
|
||||
</NDivider>
|
||||
|
||||
<NFormItem label="文件命名模板" path="namingTemplate">
|
||||
<NInput v-model:value="form.namingTemplate" :maxlength="256" clearable />
|
||||
<template #feedback>
|
||||
<span class="task-form__hint">
|
||||
必须含 <code>{姓名}</code> <code>{证件号}</code> <code>{材料名}</code> ——
|
||||
缺了任一项,文件名就无法唯一归属到人。
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<div class="task-form__vars">
|
||||
<span class="task-form__vars-label">插入变量</span>
|
||||
<NSpace :size="6">
|
||||
<NButton
|
||||
v-for="opt in namingVariableOptions"
|
||||
:key="opt.value"
|
||||
size="tiny"
|
||||
secondary
|
||||
@click="appendVariable(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<NFormItem label="预览(以「张同学 / 361029200912100015 / 身份证」为例)">
|
||||
<code class="task-form__preview">{{ namingPreview }}</code>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end" :size="8">
|
||||
<NButton :disabled="submitting" @click="close">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="submitting" @click="confirm">
|
||||
保存
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task-form {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text-3);
|
||||
color: #8a8a8a;
|
||||
|
||||
code {
|
||||
padding: 0 3px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
background: var(--color-fill-2);
|
||||
font-family: ui-monospace, monospace;
|
||||
background: #f2f3f5;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
@@ -312,6 +362,10 @@ defineExpose({ add, edit })
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
&__date {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__vars {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -322,18 +376,18 @@ defineExpose({ add, edit })
|
||||
&__vars-label {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1);
|
||||
color: #1f2229;
|
||||
word-break: break-all;
|
||||
background: var(--color-fill-2);
|
||||
background: #f2f3f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,249 +1,4 @@
|
||||
<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="fd__task">{{ taskName }}</span>
|
||||
</template>
|
||||
|
||||
<a-alert v-if="readonly" type="normal" :show-icon="false" class="fd__alert">
|
||||
任务已{{ readonlyReason }},表单不可再修改,只能查看。
|
||||
</a-alert>
|
||||
<a-alert v-else-if="collecting" type="warning" class="fd__alert">
|
||||
<template #title>
|
||||
任务正在收集中,<strong>保存后即刻生效</strong>:学生刷新页面就会看到新表单。
|
||||
已在填报的学生会按新表单重新校验(新标必填而没填的项会变成问题项)。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!--
|
||||
版本号要显眼地摆出来。设计器是"打开 → 改很多处 → 保存"的过程,
|
||||
而服务端用 baseVersion 做乐观锁 —— 让人知道自己正基于哪一版改,
|
||||
冲突时才看得懂那句"别人已经保存过"。
|
||||
-->
|
||||
<div class="fd__bar">
|
||||
<div class="fd__ver">
|
||||
<a-tag :color="schema.version === 0 ? 'gray' : 'arcoblue'">
|
||||
{{ schema.version === 0 ? '未保存过(当前为官方默认表单)' : `当前版本 v${schema.version}` }}
|
||||
</a-tag>
|
||||
<span v-if="schema.latestVersion && schema.latestVersion !== schema.version" class="fd__warn">
|
||||
库里最新是 v{{ schema.latestVersion }},保存时会被拦下
|
||||
</span>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="saving" :disabled="readonly" @click="save">
|
||||
保存为新版本
|
||||
</a-button>
|
||||
<a-button :disabled="saving" @click="load">放弃修改并重新加载</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-spin :loading="loading">
|
||||
<a-divider orientation="left">内置字段(官方 21 项,不可删除)</a-divider>
|
||||
<!--
|
||||
为什么内置字段不能删:删掉之后"这个值到底该不该有"就永远说不清了
|
||||
(服务端 40026)。不想收的信息用「条件显示 → 不收集」隐藏,
|
||||
这样它仍然在定义里,只是学生看不到、也不参与必填校验。
|
||||
-->
|
||||
<div class="fd__hint">
|
||||
这些是认定机构要的官方字段。<strong>不想要某一项时不要删</strong>(也删不掉),
|
||||
把它的「条件显示」设为「不收集」即可。带「只读」的字段由老师在名册里填,学生看不到输入框。
|
||||
</div>
|
||||
|
||||
<a-table :data="fields" :pagination="false" row-key="key" size="small" :bordered="false">
|
||||
<template #columns>
|
||||
<a-table-column title="字段" :width="220">
|
||||
<template #cell="{ record }">
|
||||
<div class="fd__label">
|
||||
{{ record.label }}
|
||||
<a-tag v-if="record.locked" size="small">只读</a-tag>
|
||||
</div>
|
||||
<div class="fd__key">{{ record.key }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="类型" :width="90">
|
||||
<template #cell="{ record }">
|
||||
<span class="fd__muted">{{ FIELD_TYPE_NAMES[record.type] ?? record.type }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="必填" :width="80" align="center">
|
||||
<template #cell="{ record }">
|
||||
<!-- 只读字段的必填没有意义(学生改不了、值来自名册),所以直接置灰 -->
|
||||
<a-switch v-model="record.required" size="small" :disabled="readonly || record.locked" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="条件显示" :width="190">
|
||||
<template #cell="{ record }">
|
||||
<a-select v-model="record.condIndex" size="small" :disabled="readonly"
|
||||
:error="record.condIndex === -1" placeholder="自定义规则">
|
||||
<a-option v-for="(o, i) in CONDITION_OPTIONS" :key="i" :value="i">{{ o.label }}</a-option>
|
||||
<a-option v-if="record.condIndex === -1" :value="-1">自定义(接口配的)</a-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="排序" :width="80" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-input-number v-model="record.sortNo" size="small" :min="0" :max="9999"
|
||||
:disabled="readonly" class="fd__num" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="学生端提示语">
|
||||
<template #cell="{ record }">
|
||||
<a-input v-model="record.tip" size="small" :max-length="200" allow-clear
|
||||
:disabled="readonly" placeholder="如:没有工作单位请填「无」" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-divider orientation="left">扩展字段(自定义,仅展示与导出,不参与统计)</a-divider>
|
||||
<a-space class="fd__tool">
|
||||
<a-button v-if="!readonly" type="primary" @click="onAddExtra">
|
||||
<template #icon><IconPlus /></template>
|
||||
新增扩展字段
|
||||
</a-button>
|
||||
<span class="fd__hint fd__hint--inline">
|
||||
扩展字段的值落在 <code>extras_json</code>,key 必须带 <code>extra_</code> 前缀 ——
|
||||
前缀是防重名的护栏,否则"这个值到底是内置的还是扩展的"永远说不清
|
||||
</span>
|
||||
</a-space>
|
||||
|
||||
<a-empty v-if="!extras.length" description="没有扩展字段" />
|
||||
|
||||
<a-table v-else :data="extras" :pagination="false" row-key="key" size="small" :bordered="false">
|
||||
<template #columns>
|
||||
<a-table-column title="字段" :width="200">
|
||||
<template #cell="{ record }">
|
||||
<div class="fd__label">{{ record.label }}</div>
|
||||
<div class="fd__key">{{ record.key }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="类型" :width="90">
|
||||
<template #cell="{ record }">
|
||||
<span class="fd__muted">{{ FIELD_TYPE_NAMES[record.type] ?? record.type }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="必填" :width="80" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag v-if="record.required" color="red" size="small">必填</a-tag>
|
||||
<a-tag v-else size="small">选填</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="条件显示" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<span class="fd__muted">{{ CONDITION_OPTIONS[record.condIndex]?.label ?? '自定义' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="选项">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.options?.length">{{ record.options.join(' / ') }}</span>
|
||||
<span v-else class="fd__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="110" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-space v-if="!readonly" size="mini">
|
||||
<a-link @click="onEditExtra(record)">编辑</a-link>
|
||||
<a-popconfirm content="删除后学生端不再显示这一项。确认删除?" type="warning"
|
||||
@ok="onDeleteExtra(record)">
|
||||
<a-link status="danger">删除</a-link>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
<span v-else class="fd__muted">只读</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
|
||||
<!-- ── 扩展字段编辑 ── -->
|
||||
<a-modal v-model:visible="extraVisible" :title="extraTitle" :width="620" :mask-closable="false"
|
||||
:ok-loading="false" @ok="saveExtra" @cancel="() => (extraVisible = false)">
|
||||
<a-form ref="extraFormRef" :model="extraForm" :rules="extraRules" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="label" label="字段名称">
|
||||
<a-input v-model="extraForm.label" placeholder="如 家长联系电话" :max-length="64" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="key" label="字段标识(key)">
|
||||
<a-input v-model="extraForm.key" placeholder="extra_xxx" :max-length="46" allow-clear />
|
||||
<template #extra>
|
||||
<span class="fd__hint">
|
||||
必须以 <code>extra_</code> 开头,只含小写字母、数字、下划线
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12">
|
||||
<a-form-item field="type" label="类型">
|
||||
<a-select v-model="extraForm.type" placeholder="选择类型">
|
||||
<a-option v-for="t in FIELD_TYPES" :key="t" :value="t">
|
||||
{{ FIELD_TYPE_NAMES[t] }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="required" label="是否必填">
|
||||
<a-switch v-model="extraForm.required" :checked-value="true" :unchecked-value="false" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col v-if="needOptions" :span="24">
|
||||
<a-form-item field="options" label="选项(每行一个)">
|
||||
<a-textarea v-model="extraForm.optionsText" :auto-size="{ minRows: 3, maxRows: 8 }"
|
||||
placeholder="选项一 选项二" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12">
|
||||
<a-form-item field="condIndex" label="条件显示">
|
||||
<a-select v-model="extraForm.condIndex">
|
||||
<a-option v-for="(o, i) in CONDITION_OPTIONS" :key="i" :value="i">{{ o.label }}</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="sortNo" label="排序号">
|
||||
<a-input-number v-model="extraForm.sortNo" :min="0" :max="9999" class="fd__num" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-form-item field="tip" label="学生端提示语">
|
||||
<a-input v-model="extraForm.tip" :max-length="200" allow-clear
|
||||
placeholder="如:填不常用的手机号,便于联系" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FieldRule, FormInstance } from '@arco-design/web-vue'
|
||||
import type { FormFieldVO, FormSchemaVO } from '@/apis/xt/form-schema'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import * as formApi from '@/apis/xt/form-schema'
|
||||
import {
|
||||
CONDITION_OPTIONS,
|
||||
conditionIndex,
|
||||
EXTRA_KEY_PATTERN,
|
||||
FIELD_TYPE_NAMES,
|
||||
FIELD_TYPES,
|
||||
suggestExtraKey
|
||||
} from '@/apis/xt/form-schema'
|
||||
|
||||
/**
|
||||
* 任务「表单设计」抽屉(《03-接口设计》§7.9)。
|
||||
*
|
||||
@@ -256,8 +11,8 @@ import {
|
||||
*
|
||||
* ## 五处刻意的设计
|
||||
*
|
||||
* 1. **版本号摆在最显眼处**。服务端用 `baseVersion` 做乐观锁,
|
||||
* 冲突时返回 40025 —— 用户得先知道自己正基于哪一版改,才看得懂那句"别人保存过"。
|
||||
* 1. **版本号摆在最显眼处**。服务端用 `baseVersion` 做乐观锁,冲突时返回 40025 ——
|
||||
* 用户得先知道自己正基于哪一版改,才看得懂那句"别人保存过"。
|
||||
*
|
||||
* 2. **「不收集」是拼出来的,不是删出来的**。内置字段不可删,
|
||||
* 所以用一条永远不成立的条件(`_never_`)把它隐藏。
|
||||
@@ -267,18 +22,60 @@ import {
|
||||
* 学生改不了 —— 给一个能拨但拨不动的开关,只会让人以为它坏了。
|
||||
*
|
||||
* 4. **保存失败时给"重新加载"这条路**。版本冲突不是错误,是并发,
|
||||
* 所以用 Modal 明确说清楚,而不是一句红色 toast。
|
||||
* 所以用弹窗明确说清楚,而不是一句红色 toast。
|
||||
*
|
||||
* 5. **提示语直接在表里编辑**。它是本产品最省人力的一个字段
|
||||
* (写清楚"没有工作单位请填「无」",就少一批人来问),
|
||||
* 藏在二级弹窗里没人会去填。
|
||||
* (写清楚"没有工作单位请填「无」",就少一批人来问),藏在二级弹窗里没人会去填。
|
||||
*
|
||||
* ⚠️ 单元格样式走内联:渲染函数创建的元素拿不到本组件的 scoped 属性。
|
||||
*/
|
||||
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
|
||||
import type { BizError } from '@/apis/types'
|
||||
import type { FormFieldVO, FormSchemaVO } from '@/apis/xt/form-schema'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDataTable,
|
||||
NDivider,
|
||||
NDrawer,
|
||||
NDrawerContent,
|
||||
NEmpty,
|
||||
NForm,
|
||||
NFormItemGi,
|
||||
NGrid,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NSwitch,
|
||||
NTag,
|
||||
useDialog,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, reactive, ref, useTemplateRef, watch } from 'vue'
|
||||
import * as formApi from '@/apis/xt/form-schema'
|
||||
import {
|
||||
CONDITION_OPTIONS,
|
||||
conditionIndex,
|
||||
EXTRA_KEY_PATTERN,
|
||||
FIELD_TYPE_NAMES,
|
||||
FIELD_TYPES,
|
||||
suggestExtraKey
|
||||
} from '@/apis/xt/form-schema'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
|
||||
defineOptions({ name: 'TaskFormSchemaDrawer' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
/** 内部编辑模型:多一个 `condIndex` 承载下拉选择(服务端存的是 visibleWhen 对象)。 */
|
||||
interface EditField extends FormFieldVO {
|
||||
condIndex: number
|
||||
@@ -302,6 +99,12 @@ const readonly = computed(() => !EDITABLE.includes(taskStatus.value))
|
||||
const readonlyReason = computed(() => (taskStatus.value === 2 ? '截止' : '归档'))
|
||||
const collecting = computed(() => taskStatus.value === 1)
|
||||
|
||||
// 渲染函数里的样式常量(scoped 样式对这些元素不生效)
|
||||
const S_LABEL = 'display: flex; align-items: center; gap: 6px'
|
||||
const S_MUTED = 'font-size: 12px; color: #8a8a8a'
|
||||
|
||||
const conditionSelectOptions = CONDITION_OPTIONS.map((o, i) => ({ label: o.label, value: i }))
|
||||
|
||||
// ── 加载 ──────────────────────────────────────────
|
||||
|
||||
function toEdit(list: FormFieldVO[] | undefined): EditField[] {
|
||||
@@ -332,11 +135,10 @@ async function load() {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await formApi.getFormSchema(taskId.value)
|
||||
const d = r.data ?? { version: 0, isCurrent: true, fields: [], extras: [] }
|
||||
schema.value = d
|
||||
fields.value = toEdit(d.fields)
|
||||
extras.value = toEdit(d.extras)
|
||||
const d = await formApi.getFormSchema(taskId.value)
|
||||
schema.value = d ?? { version: 0, isCurrent: true, fields: [], extras: [] }
|
||||
fields.value = toEdit(d?.fields)
|
||||
extras.value = toEdit(d?.extras)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -346,6 +148,19 @@ function close() {
|
||||
extraVisible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭抽屉时把内层弹窗一起收掉。
|
||||
*
|
||||
* ⚠️ Naive 的抽屉默认**不销毁内容**:抽屉关掉而"新增扩展字段"的弹窗还开着,
|
||||
* 它会被 teleport 到 body 上继续挂在屏幕中间。
|
||||
*/
|
||||
function onShowChange(v: boolean) {
|
||||
visible.value = v
|
||||
if (!v) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 保存 ──────────────────────────────────────────
|
||||
|
||||
async function save() {
|
||||
@@ -359,37 +174,189 @@ async function save() {
|
||||
fields: toSave(fields.value),
|
||||
extras: toSave(extras.value)
|
||||
})
|
||||
Message.success(`已保存为 v${r.data?.version ?? '?'}`)
|
||||
message.success(`已保存为 v${r?.version ?? '?'}`)
|
||||
await load()
|
||||
emit('save-success')
|
||||
} catch (e) {
|
||||
// 版本冲突(40025)不是错误,是并发:别人先保存过。
|
||||
// 服务端在 data 里回了最新定义 —— 但**直接套用等于把用户改的丢掉**,
|
||||
// 所以这里给两条路,让人自己决定。
|
||||
const err = e as Error & { bizCode?: number, bizData?: unknown }
|
||||
const err = e as BizError
|
||||
if (err.bizCode === 40025) {
|
||||
Modal.warning({
|
||||
dialog.warning({
|
||||
title: '有人先保存过了',
|
||||
content: `你打开时是 v${schema.value.version},库里已经是新版本。
|
||||
直接覆盖会丢掉对方刚改的内容 —— 请先重新加载看看对方改了什么,再决定怎么合。`,
|
||||
okText: '重新加载',
|
||||
hideCancel: true
|
||||
content: () => h('div', { style: 'line-height: 1.8' }, `你打开时是 v${schema.value.version},库里已经是新版本。直接覆盖会丢掉对方刚改的内容 —— 请先重新加载看看对方改了什么,再决定怎么合。`),
|
||||
positiveText: '重新加载',
|
||||
onPositiveClick: () => {
|
||||
void load()
|
||||
}
|
||||
})
|
||||
await load()
|
||||
} else if (err.bizCode === 40026) {
|
||||
Message.error('内置字段不能删除。不想收某一项,请把它的「条件显示」设为「不收集」')
|
||||
message.error('内置字段不能删除。不想收某一项,请把它的「条件显示」设为「不收集」')
|
||||
} else {
|
||||
Message.error(err.message || '保存失败')
|
||||
message.error(err.message || '保存失败')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内置字段列 ─────────────────────────────────────
|
||||
|
||||
const fieldColumns = computed<DataTableColumns<EditField>>(() => [
|
||||
{
|
||||
title: '字段',
|
||||
key: 'label',
|
||||
width: 220,
|
||||
render: (row) => h('div', [
|
||||
h('div', { style: S_LABEL }, [
|
||||
row.label,
|
||||
row.locked ? h(NTag, { size: 'small', bordered: false }, { default: () => '只读' }) : null
|
||||
]),
|
||||
h('div', { style: S_MUTED }, row.key)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 90,
|
||||
render: (row) => h('span', { style: S_MUTED }, FIELD_TYPE_NAMES[row.type] ?? row.type)
|
||||
},
|
||||
{
|
||||
title: '必填',
|
||||
key: 'required',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
// 只读字段的必填没有意义(学生改不了、值来自名册),所以直接置灰
|
||||
render: (row) => h(NSwitch, {
|
||||
'size': 'small',
|
||||
'value': !!row.required,
|
||||
'disabled': readonly.value || !!row.locked,
|
||||
'onUpdate:value': (v: boolean) => {
|
||||
row.required = v
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '条件显示',
|
||||
key: 'condIndex',
|
||||
width: 190,
|
||||
render: (row) => {
|
||||
const opts = [...conditionSelectOptions]
|
||||
if (row.condIndex === -1) {
|
||||
opts.push({ label: '自定义(接口配的)', value: -1 })
|
||||
}
|
||||
return h(NSelect, {
|
||||
'size': 'small',
|
||||
'value': row.condIndex,
|
||||
'options': opts,
|
||||
'status': row.condIndex === -1 ? 'warning' : undefined,
|
||||
'disabled': readonly.value,
|
||||
'onUpdate:value': (v: number) => {
|
||||
row.condIndex = v
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
key: 'sortNo',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (row) => h(NInputNumber, {
|
||||
'size': 'small',
|
||||
'value': row.sortNo ?? 0,
|
||||
'min': 0,
|
||||
'max': 9999,
|
||||
'disabled': readonly.value,
|
||||
'onUpdate:value': (v: number | null) => {
|
||||
row.sortNo = v ?? 0
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '学生端提示语',
|
||||
key: 'tip',
|
||||
render: (row) => h(NInput, {
|
||||
'size': 'small',
|
||||
'value': row.tip ?? '',
|
||||
'maxlength': 200,
|
||||
'clearable': true,
|
||||
'placeholder': '如:没有工作单位请填「无」',
|
||||
'disabled': readonly.value,
|
||||
'onUpdate:value': (v: string) => {
|
||||
row.tip = v
|
||||
}
|
||||
})
|
||||
}
|
||||
])
|
||||
|
||||
const extraColumns = computed<DataTableColumns<EditField>>(() => [
|
||||
{
|
||||
title: '字段',
|
||||
key: 'label',
|
||||
width: 200,
|
||||
render: (row) => h('div', [
|
||||
h('div', { style: 'font-weight: 500; color: #1f2229' }, row.label),
|
||||
h('div', { style: S_MUTED }, row.key)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 90,
|
||||
render: (row) => h('span', { style: S_MUTED }, FIELD_TYPE_NAMES[row.type] ?? row.type)
|
||||
},
|
||||
{
|
||||
title: '必填',
|
||||
key: 'required',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (row) => h(NTag, {
|
||||
size: 'small',
|
||||
bordered: false,
|
||||
type: row.required ? 'error' : 'default'
|
||||
}, { default: () => (row.required ? '必填' : '选填') })
|
||||
},
|
||||
{
|
||||
title: '条件显示',
|
||||
key: 'condIndex',
|
||||
width: 150,
|
||||
render: (row) => h('span', { style: S_MUTED }, CONDITION_OPTIONS[row.condIndex]?.label ?? '自定义')
|
||||
},
|
||||
{
|
||||
title: '选项',
|
||||
key: 'options',
|
||||
render: (row) => (row.options?.length
|
||||
? h('span', row.options.join(' / '))
|
||||
: h('span', { style: S_MUTED }, '—'))
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (row) => {
|
||||
if (readonly.value) {
|
||||
return h('span', { style: S_MUTED }, '只读')
|
||||
}
|
||||
return h('div', { style: 'display: flex; gap: 8px; justify-content: center' }, [
|
||||
h(NButton, { text: true, size: 'small', onClick: () => onEditExtra(row) }, { default: () => '编辑' }),
|
||||
h(NPopconfirm, {
|
||||
onPositiveClick: () => onDeleteExtra(row)
|
||||
}, {
|
||||
trigger: () => h(NButton, { text: true, size: 'small', type: 'error' }, { default: () => '删除' }),
|
||||
default: () => '删除后学生端不再显示这一项。确认删除?'
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// ── 扩展字段 ──────────────────────────────────────
|
||||
|
||||
const extraEditingKey = ref<string | null>(null)
|
||||
const extraFormRef = useTemplateRef<FormInstance>('extraFormRef')
|
||||
const extraFormRef = useTemplateRef<FormInst>('extraFormRef')
|
||||
|
||||
const emptyExtra = () => ({
|
||||
label: '',
|
||||
@@ -406,27 +373,24 @@ const extraForm = reactive(emptyExtra())
|
||||
const needOptions = computed(() => extraForm.type === 'select' || extraForm.type === 'cascade')
|
||||
const extraTitle = computed(() => (extraEditingKey.value ? '编辑扩展字段' : '新增扩展字段'))
|
||||
|
||||
const extraRules: Record<string, FieldRule[]> = {
|
||||
label: [{ required: true, message: '请填写字段名称' }],
|
||||
type: [{ required: true, message: '请选择类型' }],
|
||||
const extraRules: FormRules = {
|
||||
label: { required: true, message: '请填写字段名称', trigger: ['input', 'blur'] },
|
||||
type: { required: true, message: '请选择类型', trigger: ['change'] },
|
||||
key: [
|
||||
{ required: true, message: '请填写字段标识' },
|
||||
{ required: true, message: '请填写字段标识', trigger: ['input', 'blur'] },
|
||||
{
|
||||
validator: (value: unknown, cb: (msg?: string) => void) => {
|
||||
validator: (_rule, value: string) => {
|
||||
const v = String(value ?? '').trim()
|
||||
if (!EXTRA_KEY_PATTERN.test(v)) {
|
||||
cb('必须以 extra_ 开头,且只含小写字母、数字、下划线')
|
||||
return
|
||||
return new Error('必须以 extra_ 开头,且只含小写字母、数字、下划线')
|
||||
}
|
||||
const dup = extras.value.some(
|
||||
(x) => x.key === v && x.key !== extraEditingKey.value
|
||||
)
|
||||
const dup = extras.value.some((x) => x.key === v && x.key !== extraEditingKey.value)
|
||||
if (dup) {
|
||||
cb('这个标识已经被别的扩展字段用了')
|
||||
return
|
||||
return new Error('这个标识已经被别的扩展字段用了')
|
||||
}
|
||||
cb()
|
||||
}
|
||||
return true
|
||||
},
|
||||
trigger: ['input', 'blur']
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -460,12 +424,14 @@ function onEditExtra(row: EditField) {
|
||||
|
||||
function onDeleteExtra(row: EditField) {
|
||||
extras.value = extras.value.filter((x) => x.key !== row.key)
|
||||
Message.info('已从列表移除,点「保存为新版本」后生效')
|
||||
message.info('已从列表移除,点「保存为新版本」后生效')
|
||||
}
|
||||
|
||||
async function saveExtra() {
|
||||
const invalid = await extraFormRef.value?.validate()
|
||||
if (invalid) {
|
||||
try {
|
||||
await extraFormRef.value?.validate()
|
||||
} catch {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
return
|
||||
}
|
||||
const opts = needOptions.value
|
||||
@@ -490,10 +456,10 @@ async function saveExtra() {
|
||||
extras.value.push(item)
|
||||
}
|
||||
extraVisible.value = false
|
||||
Message.info('已加入列表,点「保存为新版本」后生效')
|
||||
message.info('已加入列表,点「保存为新版本」后生效')
|
||||
}
|
||||
|
||||
/** 填了名称但还没填 key 时,顺手给一个合法的候选(不覆盖用户已填的)。 */
|
||||
/** 填了名称但还没填 key 时,顺手给一个合法的候选(不覆盖用户已填的) */
|
||||
watch(
|
||||
() => extraForm.label,
|
||||
(v) => {
|
||||
@@ -506,24 +472,234 @@ watch(
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer
|
||||
:show="visible"
|
||||
:width="1120"
|
||||
:mask-closable="false"
|
||||
placement="right"
|
||||
@update:show="onShowChange"
|
||||
>
|
||||
<NDrawerContent :native-scrollbar="false" closable>
|
||||
<template #header>
|
||||
<span>表单设计</span>
|
||||
<span class="fd__task">{{ taskName }}</span>
|
||||
</template>
|
||||
|
||||
<NAlert v-if="readonly" type="default" :show-icon="false" :bordered="false" class="fd__alert">
|
||||
任务已{{ readonlyReason }},表单不可再修改,只能查看。
|
||||
</NAlert>
|
||||
<NAlert v-else-if="collecting" type="warning" :show-icon="false" class="fd__alert">
|
||||
任务正在收集中,<strong>保存后即刻生效</strong>:学生刷新页面就会看到新表单。
|
||||
已在填报的学生会按新表单重新校验(新标必填而没填的项会变成问题项)。
|
||||
</NAlert>
|
||||
|
||||
<!--
|
||||
版本号要显眼地摆出来。设计器是"打开 → 改很多处 → 保存"的过程,
|
||||
而服务端用 baseVersion 做乐观锁 —— 让人知道自己正基于哪一版改,
|
||||
冲突时才看得懂那句"别人已经保存过"。
|
||||
-->
|
||||
<div class="fd__bar">
|
||||
<div class="fd__ver">
|
||||
<NTag size="small" :bordered="false" :type="schema.version === 0 ? 'default' : 'info'">
|
||||
{{ schema.version === 0 ? '未保存过(当前为官方默认表单)' : `当前版本 v${schema.version}` }}
|
||||
</NTag>
|
||||
<span v-if="schema.latestVersion && schema.latestVersion !== schema.version" class="fd__warn">
|
||||
库里最新是 v{{ schema.latestVersion }},保存时会被拦下
|
||||
</span>
|
||||
</div>
|
||||
<NSpace :size="8">
|
||||
<NButton type="primary" :loading="saving" :disabled="readonly" @click="save">
|
||||
保存为新版本
|
||||
</NButton>
|
||||
<NButton :disabled="saving" @click="load">
|
||||
放弃修改并重新加载
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<NDivider title-placement="left">
|
||||
内置字段(官方 21 项,不可删除)
|
||||
</NDivider>
|
||||
<!--
|
||||
为什么内置字段不能删:删掉之后"这个值到底该不该有"就永远说不清了
|
||||
(服务端 40026)。不想收的信息用「条件显示 → 不收集」隐藏,
|
||||
这样它仍然在定义里,只是学生看不到、也不参与必填校验。
|
||||
-->
|
||||
<div class="fd__hint">
|
||||
这些是认定机构要的官方字段。<strong>不想要某一项时不要删</strong>(也删不掉),
|
||||
把它的「条件显示」设为「不收集」即可。带「只读」的字段由老师在名册里填,学生看不到输入框。
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
:columns="fieldColumns"
|
||||
:data="fields"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
扩展字段(自定义,仅展示与导出,不参与统计)
|
||||
</NDivider>
|
||||
<NSpace class="fd__tool" :size="10" align="center">
|
||||
<NButton v-if="!readonly" type="primary" @click="onAddExtra">
|
||||
<template #icon>
|
||||
<AppIcon name="plus" :size="15" />
|
||||
</template>
|
||||
新增扩展字段
|
||||
</NButton>
|
||||
<span class="fd__hint fd__hint--inline">
|
||||
扩展字段的值落在 <code>extras_json</code>,key 必须带 <code>extra_</code> 前缀 ——
|
||||
前缀是防重名的护栏,否则"这个值到底是内置的还是扩展的"永远说不清
|
||||
</span>
|
||||
</NSpace>
|
||||
|
||||
<NEmpty v-if="!extras.length" description="没有扩展字段" />
|
||||
|
||||
<NDataTable
|
||||
v-else
|
||||
:columns="extraColumns"
|
||||
:data="extras"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
</NSpin>
|
||||
|
||||
<!-- ── 扩展字段编辑 ── -->
|
||||
<NModal
|
||||
v-model:show="extraVisible"
|
||||
preset="card"
|
||||
:title="extraTitle"
|
||||
:style="{ width: '620px' }"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<NForm ref="extraFormRef" :model="extraForm" :rules="extraRules" label-placement="top">
|
||||
<NGrid :cols="24" :x-gap="16">
|
||||
<NFormItemGi :span="12" label="字段名称" path="label">
|
||||
<NInput v-model:value="extraForm.label" placeholder="如 家长联系电话" :maxlength="64" clearable />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="12" label="字段标识(key)" path="key">
|
||||
<NInput v-model:value="extraForm.key" placeholder="extra_xxx" :maxlength="46" clearable />
|
||||
<template #feedback>
|
||||
<span class="fd__hint fd__hint--inline">
|
||||
必须以 <code>extra_</code> 开头,只含小写字母、数字、下划线
|
||||
</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="12" label="类型" path="type">
|
||||
<NSelect
|
||||
v-model:value="extraForm.type"
|
||||
placeholder="选择类型"
|
||||
:options="FIELD_TYPES.map(t => ({ label: FIELD_TYPE_NAMES[t], value: t }))"
|
||||
/>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="12" label="是否必填" path="required">
|
||||
<NSwitch v-model:value="extraForm.required" />
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi v-if="needOptions" :span="24" label="选项(每行一个)" path="options">
|
||||
<NInput
|
||||
v-model:value="extraForm.optionsText"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
placeholder="选项一 选项二"
|
||||
/>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="12" label="条件显示" path="condIndex">
|
||||
<NSelect v-model:value="extraForm.condIndex" :options="conditionSelectOptions" />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="12" label="排序号" path="sortNo">
|
||||
<NInputNumber v-model:value="extraForm.sortNo" :min="0" :max="9999" class="fd__num" />
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="24" label="学生端提示语" path="tip">
|
||||
<NInput
|
||||
v-model:value="extraForm.tip"
|
||||
:maxlength="200"
|
||||
clearable
|
||||
placeholder="如:填不常用的手机号,便于联系"
|
||||
/>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end" :size="8">
|
||||
<NButton @click="extraVisible = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" @click="saveExtra">
|
||||
确定
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fd {
|
||||
&__task { margin-left: 12px; color: var(--color-text-3); font-weight: 400; }
|
||||
&__alert { margin-bottom: 16px; }
|
||||
&__task {
|
||||
margin-left: 12px;
|
||||
font-weight: 400;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px; flex-wrap: wrap; gap: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
&__ver { display: flex; align-items: center; gap: 10px; }
|
||||
&__warn { color: rgb(var(--orange-6)); font-size: 12px; }
|
||||
|
||||
&__ver {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__warn {
|
||||
font-size: 12px;
|
||||
color: #f0a020;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin-bottom: 12px; color: var(--color-text-3); font-size: 12px; line-height: 1.7;
|
||||
&--inline { margin-bottom: 0; }
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #8a8a8a;
|
||||
|
||||
code {
|
||||
padding: 0 3px;
|
||||
font-family: ui-monospace, monospace;
|
||||
background: #f2f3f5;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&--inline {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__tool {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__num {
|
||||
width: 100%;
|
||||
}
|
||||
&__tool { margin-bottom: 12px; }
|
||||
&__label { display: flex; align-items: center; gap: 6px; }
|
||||
&__key { color: var(--color-text-3); font-size: 12px; }
|
||||
&__muted { color: var(--color-text-3); }
|
||||
&__num { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,295 +1,4 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" :width="980" :mask-closable="false" :footer="false"
|
||||
unmount-on-close @cancel="close">
|
||||
<template #title>
|
||||
<span>配置收集材料</span>
|
||||
<span class="mat-drawer__task">{{ taskName }}</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
已截止 / 已归档时只读。服务端的可编辑状态是 {草稿,收集中,已暂停},
|
||||
这里与服务端同口径 —— 界面上让按钮点不动,比点完被拒好。
|
||||
-->
|
||||
<a-alert v-if="readonly" type="normal" :show-icon="false" class="mat-drawer__alert">
|
||||
任务已{{ readonlyReason }},材料要求不可再修改,只能查看。
|
||||
</a-alert>
|
||||
|
||||
<!--
|
||||
收集中改材料要明确警告。学生可能已经在传了 —— 他现在看到的要求与十分钟后的
|
||||
可能不一样,而这会让已传的文件与材料定义对不上(比如把必传改成选传、
|
||||
或把体积上限调小,学生手里那份就已经超了)。服务端允许改(业务确实需要),
|
||||
但界面必须把后果说出来。
|
||||
-->
|
||||
<a-alert v-else-if="collecting" type="warning" class="mat-drawer__alert">
|
||||
<template #title>
|
||||
任务正在收集中,<strong>改动立刻生效</strong>:学生刷新页面就会看到新的要求。
|
||||
调小体积上限或改成必传,会让已经传过材料的学生出现新的问题项。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-space class="mat-drawer__tool" wrap>
|
||||
<a-dropdown v-if="!readonly" trigger="click" @select="onApplyTemplate">
|
||||
<a-button type="primary">
|
||||
<template #icon><IconPlus /></template>
|
||||
按模板初始化
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption v-for="t in templates" :key="t.templateCode" :value="t.templateCode">
|
||||
{{ t.label }}
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<a-button v-if="!readonly" @click="onAdd">
|
||||
<template #icon><IconPlus /></template>
|
||||
新增材料
|
||||
</a-button>
|
||||
<a-button @click="load">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<!--
|
||||
模板是幂等的(已存在的材料码会跳过),所以先说清楚"会跳过已存在的",
|
||||
否则管理员点两次会以为第一次没生效。
|
||||
-->
|
||||
<span class="mat-drawer__hint">按模板初始化是幂等的,已有的材料不会重复添加</span>
|
||||
</a-space>
|
||||
|
||||
<a-spin :loading="loading">
|
||||
<a-empty v-if="!rows.length" description="还没有材料要求,请先「按模板初始化」或「新增材料」">
|
||||
<div class="mat-drawer__empty-hint">
|
||||
必传材料一项都没有时,任务<strong>发布不了</strong>(服务端会返回 40018)。
|
||||
</div>
|
||||
</a-empty>
|
||||
|
||||
<a-table v-else :data="rows" :pagination="false" row-key="id" size="small" :bordered="false">
|
||||
<template #columns>
|
||||
<a-table-column title="材料" :width="200">
|
||||
<template #cell="{ record }">
|
||||
<div class="mat-drawer__name">
|
||||
{{ record.materialName }}
|
||||
<a-tag v-if="record.required" color="red" size="small">必传</a-tag>
|
||||
<a-tag v-else size="small">选传</a-tag>
|
||||
</div>
|
||||
<div class="mat-drawer__code">{{ record.materialCode }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="适用对象" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="!record.visibleWhen" class="mat-drawer__muted">所有学生</span>
|
||||
<a-tag v-else-if="visibleWhenIndex(record.visibleWhen) === 1" color="arcoblue">
|
||||
仅在校生
|
||||
</a-tag>
|
||||
<a-tag v-else-if="visibleWhenIndex(record.visibleWhen) === 2" color="orange">
|
||||
仅非在校生
|
||||
</a-tag>
|
||||
<a-tooltip v-else content="这是通过接口直接配置的规则,界面上暂不支持编辑">
|
||||
<a-tag color="purple">自定义条件</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="要求" :width="200">
|
||||
<template #cell="{ record }">
|
||||
<div class="mat-drawer__req">
|
||||
{{ (record.fileTypes || []).join('/') }} ·
|
||||
≤{{ record.maxSizeKb }}KB
|
||||
</div>
|
||||
<div class="mat-drawer__req mat-drawer__muted">
|
||||
{{ record.maxCount > 1 ? `最多 ${record.maxCount} 份` : '1 份' }} ·
|
||||
{{ profileShort(record.compressProfile) }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="学生端提示语" data-index="tipText" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.tipText">{{ record.tipText }}</span>
|
||||
<span v-else class="mat-drawer__muted">未设置</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="操作" :width="120" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-space v-if="!readonly" size="mini">
|
||||
<a-link @click="onEdit(record)">编辑</a-link>
|
||||
<!--
|
||||
已有文件时删不掉(服务端 40027)。这里不预先禁用,而是让服务端说清原因 ——
|
||||
因为"为什么删不掉"的答案(有人已经传了)比一个灰掉的按钮有用得多。
|
||||
-->
|
||||
<a-popconfirm content="删除后学生端不再要求这一项。确认删除?" type="warning"
|
||||
@ok="onDelete(record)">
|
||||
<a-link status="danger">删除</a-link>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
<span v-else class="mat-drawer__muted">只读</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
|
||||
<!-- ── 新增 / 编辑 ── -->
|
||||
<a-modal v-model:visible="formVisible" :title="formTitle" :width="680" :mask-closable="false"
|
||||
:ok-loading="saving" @ok="save" @cancel="closeForm">
|
||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="materialCode" label="材料类型">
|
||||
<!--
|
||||
编辑时锁死材料码:它是 `collect_file` 的关联键(两者之间没有外键)。
|
||||
改了码,学生已上传的文件就成了孤儿 —— 静默丢材料,且极难发现。
|
||||
-->
|
||||
<a-select v-model="form.materialCode" placeholder="选择材料类型"
|
||||
:disabled="isEdit" @change="onCodeChange">
|
||||
<a-option v-for="k in catalog.codes" :key="k.code" :value="k.code">
|
||||
{{ k.name }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<template v-if="isEdit" #extra>
|
||||
<span class="mat-form__hint">材料码不可修改,它是已上传文件的关联键</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="materialName" label="材料名称">
|
||||
<a-input v-model="form.materialName" placeholder="如 身份证" :max-length="64" allow-clear />
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">学生端与导出文件名都用它,要写规范名</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-form-item field="aliases" label="别名(俗称)">
|
||||
<a-input-tag v-model="form.aliases" placeholder="输入后回车,如:身份证正面、身份证人像面"
|
||||
:max-tag-count="6" allow-clear />
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">
|
||||
用于从历史文件名里认出这是哪一项材料 —— 别名不全,该认出来的就认不出来
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="8">
|
||||
<a-form-item field="required" label="是否必传">
|
||||
<a-switch v-model="form.required" :checked-value="true" :unchecked-value="false" />
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">一项都不必传时任务发布不了</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="16">
|
||||
<a-form-item field="visibleWhenIndex" label="适用对象">
|
||||
<a-select v-model="form.visibleWhenIndex" placeholder="所有学生都要">
|
||||
<a-option v-for="(o, i) in visibleWhenOptions" :key="i" :value="i">
|
||||
{{ o.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">{{ currentWhenHint }}</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-form-item field="fileTypes" label="允许的格式">
|
||||
<a-select v-model="form.fileTypes" multiple placeholder="选择允许上传的文件格式">
|
||||
<a-option v-for="t in catalog.fileTypes" :key="t" :value="t">.{{ t }}</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="8">
|
||||
<a-form-item field="maxSizeKb" label="体积上限(KB)">
|
||||
<a-input-number v-model="form.maxSizeKb" :min="maxSizeMin" :max="maxSizeMax"
|
||||
:step="128" class="mat-form__num" />
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">{{ maxSizeHint }}</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="maxCount" label="最多可传份数">
|
||||
<a-input-number v-model="form.maxCount" :min="1" :max="10" class="mat-form__num" />
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">身份证正反面这类要传 2 份</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="compressProfile" label="压缩档位">
|
||||
<a-select v-model="form.compressProfile" placeholder="自动判定">
|
||||
<a-option v-for="p in catalog.profiles" :key="p" :value="p">
|
||||
{{ profileName(p) }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">档位决定分辨率与清晰度,一般不用改</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-form-item field="namePattern" label="命名模板(留空则继承任务级)">
|
||||
<a-input v-model="form.namePattern" :max-length="256" allow-clear
|
||||
:placeholder="DEFAULT_NAMING_TEMPLATE" />
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">
|
||||
留空即用任务的 <code>{{ DEFAULT_NAMING_TEMPLATE }}</code>。
|
||||
单材料项要特殊命名时才填(如身份证正反面要区分)
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-form-item field="tipText" label="来源引导语">
|
||||
<a-textarea v-model="form.tipText" :max-length="512" :auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
show-word-limit placeholder="显示在学生端这一项下面,如:拍身份证人像面,四角要拍全" />
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">
|
||||
写得越具体,学生传错的比例越低 —— 这句话省下的是班主任逐人纠错的时间
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12">
|
||||
<a-form-item field="ocrType" label="自动识别">
|
||||
<a-select v-model="form.ocrType" placeholder="不做识别" allow-clear>
|
||||
<a-option v-for="t in catalog.ocrTypes" :key="t" :value="t">
|
||||
{{ OCR_TYPE_NAMES[t] ?? t }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">识别结果只用于提示,不会自动改动材料归属</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="sortNo" label="排序号">
|
||||
<a-input-number v-model="form.sortNo" :min="0" class="mat-form__num" />
|
||||
<template #extra>
|
||||
<span class="mat-form__hint">越小越靠前(学生端按这个顺序列材料)</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FieldRule, FormInstance } from '@arco-design/web-vue'
|
||||
import type { MaterialCatalog, MaterialDefSaveReq, MaterialDefVO } from '@/apis/xt/material'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconPlus, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import * as matApi from '@/apis/xt/material'
|
||||
import { DEFAULT_NAMING_TEMPLATE } from '@/apis/xt/task'
|
||||
|
||||
/**
|
||||
* 任务「收集材料」配置抽屉(《03-接口设计》§7.10)。
|
||||
*
|
||||
@@ -314,15 +23,50 @@ import { DEFAULT_NAMING_TEMPLATE } from '@/apis/xt/task'
|
||||
* 3. **收集中改动要显式警告**。服务端允许改(业务确实需要),
|
||||
* 但学生会立刻看到新要求,已传的材料可能因此出现新的问题项。
|
||||
*
|
||||
* 4. **编辑时锁死材料码**。它是 `collect_file` 的关联键,
|
||||
* 改了码已上传的文件就成了孤儿 —— 静默丢材料。
|
||||
* 4. **编辑时锁死材料码**。它是 `collect_file` 的关联键(两者之间没有外键),
|
||||
* 改了码学生已上传的文件就成了孤儿 —— 静默丢材料,且极难发现。
|
||||
*
|
||||
* ⚠️ 单元格样式走内联:渲染函数创建的元素拿不到本组件的 scoped 属性。
|
||||
*/
|
||||
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
|
||||
import type { MaterialCatalog, MaterialDefSaveReq, MaterialDefVO } from '@/apis/xt/material'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDataTable,
|
||||
NDrawer,
|
||||
NDrawerContent,
|
||||
NDropdown,
|
||||
NDynamicTags,
|
||||
NEmpty,
|
||||
NForm,
|
||||
NFormItemGi,
|
||||
NGrid,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NSwitch,
|
||||
NTag,
|
||||
NTooltip,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, reactive, ref, useTemplateRef } from 'vue'
|
||||
import * as matApi from '@/apis/xt/material'
|
||||
import { DEFAULT_NAMING_TEMPLATE } from '@/apis/xt/task'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
|
||||
defineOptions({ name: 'TaskMaterialDrawer' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
@@ -355,12 +99,17 @@ const maxSizeHint = computed(() => `可配范围 ${maxSizeMin.value}~${maxSizeMa
|
||||
const visibleWhenIndex = matApi.visibleWhenIndex
|
||||
const OCR_TYPE_NAMES = matApi.OCR_TYPE_NAMES
|
||||
|
||||
// 渲染函数里的样式常量(scoped 样式对这些元素不生效)
|
||||
const S_NAME = 'display: flex; align-items: center; gap: 6px'
|
||||
const S_MUTED = 'color: #8a8a8a'
|
||||
const S_REQ = 'font-size: 12px; line-height: 1.6'
|
||||
|
||||
// ── 表单 ──────────────────────────────────────────
|
||||
|
||||
const formVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const formRef = useTemplateRef<FormInstance>('formRef')
|
||||
const formRef = useTemplateRef<FormInst>('formRef')
|
||||
|
||||
const emptyForm = () => ({
|
||||
materialCode: '',
|
||||
@@ -380,18 +129,22 @@ const emptyForm = () => ({
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const rules: Record<string, FieldRule[]> = {
|
||||
materialCode: [{ required: true, message: '请选择材料类型' }],
|
||||
materialName: [{ required: true, message: '请填写材料名称' }],
|
||||
fileTypes: [{ required: true, type: 'array', minLength: 1, message: '至少选一种允许的格式' }],
|
||||
maxSizeKb: [{ required: true, message: '请填写体积上限' }],
|
||||
maxCount: [{ required: true, message: '请填写最多可传份数' }]
|
||||
const rules: FormRules = {
|
||||
materialCode: { required: true, message: '请选择材料类型', trigger: ['change'] },
|
||||
materialName: { required: true, message: '请填写材料名称', trigger: ['input', 'blur'] },
|
||||
fileTypes: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
min: 1,
|
||||
message: '至少选一种允许的格式',
|
||||
trigger: ['change']
|
||||
},
|
||||
maxSizeKb: { required: true, message: '请填写体积上限', trigger: ['change'] },
|
||||
maxCount: { required: true, message: '请填写最多可传份数', trigger: ['change'] }
|
||||
}
|
||||
|
||||
const formTitle = computed(() => (isEdit.value ? '编辑材料要求' : '新增材料要求'))
|
||||
const currentWhenHint = computed(
|
||||
() => visibleWhenOptions[form.visibleWhenIndex]?.hint ?? ''
|
||||
)
|
||||
const currentWhenHint = computed(() => visibleWhenOptions[form.visibleWhenIndex]?.hint ?? '')
|
||||
|
||||
function profileName(p: string) {
|
||||
return matApi.PROFILE_NAMES[p] ?? p
|
||||
@@ -405,6 +158,99 @@ function profileShort(p: string) {
|
||||
return p
|
||||
}
|
||||
|
||||
const codeOptions = computed(() => catalog.value.codes.map((k) => ({ label: k.name, value: k.code })))
|
||||
const profileOptions = computed(() => catalog.value.profiles.map((p) => ({ label: profileName(p), value: p })))
|
||||
const fileTypeOptions = computed(() => catalog.value.fileTypes.map((t) => ({ label: `.${t}`, value: t })))
|
||||
const ocrTypeOptions = computed(
|
||||
() => catalog.value.ocrTypes.map((t) => ({ label: OCR_TYPE_NAMES[t] ?? t, value: t }))
|
||||
)
|
||||
const whenOptions = computed(
|
||||
() => visibleWhenOptions.map((o, i) => ({ label: o.label, value: i }))
|
||||
)
|
||||
|
||||
// ── 表格列 ────────────────────────────────────────
|
||||
|
||||
const columns = computed<DataTableColumns<MaterialDefVO>>(() => [
|
||||
{
|
||||
title: '材料',
|
||||
key: 'materialName',
|
||||
width: 200,
|
||||
render: (row) => h('div', [
|
||||
h('div', { style: S_NAME }, [
|
||||
row.materialName,
|
||||
h(NTag, {
|
||||
size: 'small',
|
||||
bordered: false,
|
||||
type: row.required ? 'error' : 'default'
|
||||
}, { default: () => (row.required ? '必传' : '选传') })
|
||||
]),
|
||||
h('div', { style: S_MUTED }, row.materialCode)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '适用对象',
|
||||
key: 'visibleWhen',
|
||||
width: 120,
|
||||
render: (row) => {
|
||||
if (!row.visibleWhen) {
|
||||
return h('span', { style: S_MUTED }, '所有学生')
|
||||
}
|
||||
const idx = visibleWhenIndex(row.visibleWhen)
|
||||
if (idx === 1) {
|
||||
return h(NTag, { size: 'small', bordered: false, type: 'info' }, { default: () => '仅在校生' })
|
||||
}
|
||||
if (idx === 2) {
|
||||
return h(NTag, { size: 'small', bordered: false, type: 'warning' }, { default: () => '仅非在校生' })
|
||||
}
|
||||
return h(NTooltip, { trigger: 'hover' }, {
|
||||
trigger: () => h(NTag, { size: 'small', bordered: false }, { default: () => '自定义条件' }),
|
||||
default: () => '这是通过接口直接配置的规则,界面上暂不支持编辑'
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '要求',
|
||||
key: 'maxSizeKb',
|
||||
width: 200,
|
||||
render: (row) => h('div', [
|
||||
h('div', { style: S_REQ }, `${(row.fileTypes || []).join('/')} · ≤${row.maxSizeKb}KB`),
|
||||
h('div', { style: `${S_REQ} ${S_MUTED}` }, `${row.maxCount > 1 ? `最多 ${row.maxCount} 份` : '1 份'} · ${profileShort(row.compressProfile)}`)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '学生端提示语',
|
||||
key: 'tipText',
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => (row.tipText ? row.tipText : h('span', { style: S_MUTED }, '未设置'))
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (row) => {
|
||||
if (readonly.value) {
|
||||
return h('span', { style: S_MUTED }, '只读')
|
||||
}
|
||||
return h('div', { style: 'display: flex; gap: 8px; justify-content: center' }, [
|
||||
h(NButton, { text: true, size: 'small', onClick: () => onEdit(row) }, { default: () => '编辑' }),
|
||||
// 已有文件时删不掉(服务端 40027)。这里不预先禁用,而是让服务端说清原因 ——
|
||||
// 因为"为什么删不掉"的答案(有人已经传了)比一个灰掉的按钮有用得多
|
||||
h(NPopconfirm, {
|
||||
onPositiveClick: () => onDelete(row)
|
||||
}, {
|
||||
trigger: () => h(NButton, { text: true, size: 'small', type: 'error' }, { default: () => '删除' }),
|
||||
default: () => '删除后学生端不再要求这一项。确认删除?'
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const templateOptions = computed(
|
||||
() => templates.value.map((t) => ({ key: t.templateCode, label: t.label }))
|
||||
)
|
||||
|
||||
// ── 打开 / 加载 ───────────────────────────────────
|
||||
|
||||
async function open(task: { id: number, taskName: string, status: number }) {
|
||||
@@ -414,9 +260,9 @@ async function open(task: { id: number, taskName: string, status: number }) {
|
||||
taskStatus.value = task.status
|
||||
if (!catalog.value.codes.length) {
|
||||
// 目录是静态清单,拉一次就够 —— 不要每次开抽屉都拉
|
||||
const r = await matApi.getMaterialCatalog()
|
||||
if (r.data) {
|
||||
catalog.value = r.data
|
||||
const c = await matApi.getMaterialCatalog()
|
||||
if (c) {
|
||||
catalog.value = c
|
||||
}
|
||||
}
|
||||
await load()
|
||||
@@ -428,8 +274,8 @@ async function load() {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await matApi.getMaterialList(taskId.value)
|
||||
rows.value = (r.data ?? []).slice().sort((a, b) => (a.sortNo ?? 0) - (b.sortNo ?? 0))
|
||||
const list = await matApi.getMaterialList(taskId.value)
|
||||
rows.value = (list ?? []).slice().sort((a, b) => (a.sortNo ?? 0) - (b.sortNo ?? 0))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -439,6 +285,19 @@ function close() {
|
||||
closeForm()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭抽屉时把内层弹窗一起收掉。
|
||||
*
|
||||
* ⚠️ Naive 的抽屉默认**不销毁内容**:抽屉关掉而"新增材料"的弹窗还开着,
|
||||
* 它会被 teleport 到 body 上继续挂在屏幕中间。
|
||||
*/
|
||||
function onShowChange(v: boolean) {
|
||||
visible.value = v
|
||||
if (!v) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 新增 / 编辑 ───────────────────────────────────
|
||||
|
||||
function onAdd() {
|
||||
@@ -477,7 +336,7 @@ function onEdit(row: MaterialDefVO) {
|
||||
*
|
||||
* 只在"新增"时带出。编辑时改类型是不可能的(码被锁),所以不必考虑覆盖用户已填内容。
|
||||
*/
|
||||
function onCodeChange(value: unknown) {
|
||||
function onCodeChange(value: string | null) {
|
||||
const code = String(value ?? '')
|
||||
const k = catalog.value.codes.find((c) => c.code === code)
|
||||
if (!k) {
|
||||
@@ -494,15 +353,17 @@ function onCodeChange(value: unknown) {
|
||||
|
||||
function closeForm() {
|
||||
formVisible.value = false
|
||||
formRef.value?.resetFields()
|
||||
formRef.value?.restoreValidation()
|
||||
Object.assign(form, emptyForm())
|
||||
isEdit.value = false
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const invalid = await formRef.value?.validate()
|
||||
if (invalid) {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
|
||||
return
|
||||
}
|
||||
if (!taskId.value) {
|
||||
@@ -527,10 +388,10 @@ async function save() {
|
||||
try {
|
||||
if (isEdit.value && editingId.value) {
|
||||
await matApi.updateMaterial(editingId.value, payload)
|
||||
Message.success('已保存')
|
||||
message.success('已保存')
|
||||
} else {
|
||||
await matApi.addMaterial(taskId.value, payload)
|
||||
Message.success('已新增')
|
||||
message.success('已新增')
|
||||
}
|
||||
closeForm()
|
||||
await load()
|
||||
@@ -542,18 +403,17 @@ async function save() {
|
||||
|
||||
// ── 模板 / 删除 ───────────────────────────────────
|
||||
|
||||
async function onApplyTemplate(value: unknown) {
|
||||
async function onApplyTemplate(key: string) {
|
||||
if (!taskId.value) {
|
||||
return
|
||||
}
|
||||
const tc = String(value ?? '')
|
||||
const r = await matApi.applyMaterialTemplate(taskId.value, tc)
|
||||
const n = r.data?.createdCount ?? 0
|
||||
const r = await matApi.applyMaterialTemplate(taskId.value, key)
|
||||
const n = r?.createdCount ?? 0
|
||||
if (n === 0) {
|
||||
// 幂等的结果要说清楚,否则管理员会以为点了没反应
|
||||
Message.info('这个模板里的材料都已经有了,没有新增')
|
||||
message.info('这个模板里的材料都已经有了,没有新增')
|
||||
} else {
|
||||
Message.success(`已添加 ${n} 项材料要求`)
|
||||
message.success(`已添加 ${n} 项材料要求`)
|
||||
}
|
||||
await load()
|
||||
emit('save-success')
|
||||
@@ -561,7 +421,7 @@ async function onApplyTemplate(value: unknown) {
|
||||
|
||||
async function onDelete(row: MaterialDefVO) {
|
||||
await matApi.deleteMaterial(row.id)
|
||||
Message.success('已删除')
|
||||
message.success('已删除')
|
||||
await load()
|
||||
emit('save-success')
|
||||
}
|
||||
@@ -569,21 +429,277 @@ async function onDelete(row: MaterialDefVO) {
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer
|
||||
:show="visible"
|
||||
:width="980"
|
||||
:mask-closable="false"
|
||||
placement="right"
|
||||
@update:show="onShowChange"
|
||||
>
|
||||
<NDrawerContent :native-scrollbar="false" closable>
|
||||
<template #header>
|
||||
<span>配置收集材料</span>
|
||||
<span class="mat-drawer__task">{{ taskName }}</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
已截止 / 已归档时只读。服务端的可编辑状态是 {草稿,收集中,已暂停},
|
||||
这里与服务端同口径 —— 界面上让按钮点不动,比点完被拒好。
|
||||
-->
|
||||
<NAlert v-if="readonly" type="default" :show-icon="false" :bordered="false" class="mat-drawer__alert">
|
||||
任务已{{ readonlyReason }},材料要求不可再修改,只能查看。
|
||||
</NAlert>
|
||||
|
||||
<!--
|
||||
收集中改材料要明确警告。学生可能已经在传了 —— 他现在看到的要求与十分钟后的
|
||||
可能不一样,而这会让已传的文件与材料定义对不上(比如把必传改成选传、
|
||||
或把体积上限调小,学生手里那份就已经超了)。服务端允许改(业务确实需要),
|
||||
但界面必须把后果说出来。
|
||||
-->
|
||||
<NAlert v-else-if="collecting" type="warning" :show-icon="false" class="mat-drawer__alert">
|
||||
任务正在收集中,<strong>改动立刻生效</strong>:学生刷新页面就会看到新的要求。
|
||||
调小体积上限或改成必传,会让已经传过材料的学生出现新的问题项。
|
||||
</NAlert>
|
||||
|
||||
<NSpace class="mat-drawer__tool" :size="8" align="center">
|
||||
<NDropdown
|
||||
v-if="!readonly"
|
||||
trigger="click"
|
||||
:options="templateOptions"
|
||||
@select="onApplyTemplate"
|
||||
>
|
||||
<NButton type="primary">
|
||||
<template #icon>
|
||||
<AppIcon name="plus" :size="15" />
|
||||
</template>
|
||||
按模板初始化
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
<NButton v-if="!readonly" @click="onAdd">
|
||||
<template #icon>
|
||||
<AppIcon name="plus" :size="15" />
|
||||
</template>
|
||||
新增材料
|
||||
</NButton>
|
||||
<NButton @click="load">
|
||||
<template #icon>
|
||||
<AppIcon name="refresh" :size="15" />
|
||||
</template>
|
||||
刷新
|
||||
</NButton>
|
||||
<!--
|
||||
模板是幂等的(已存在的材料码会跳过),所以先说清楚"会跳过已存在的",
|
||||
否则管理员点两次会以为第一次没生效。
|
||||
-->
|
||||
<span class="mat-drawer__hint">按模板初始化是幂等的,已有的材料不会重复添加</span>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<NEmpty v-if="!rows.length" description="还没有材料要求,请先「按模板初始化」或「新增材料」">
|
||||
<div class="mat-drawer__empty-hint">
|
||||
必传材料一项都没有时,任务<strong>发布不了</strong>(服务端会返回 40018)。
|
||||
</div>
|
||||
</NEmpty>
|
||||
|
||||
<NDataTable
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
</NSpin>
|
||||
|
||||
<!-- ── 新增 / 编辑 ── -->
|
||||
<NModal
|
||||
v-model:show="formVisible"
|
||||
preset="card"
|
||||
:title="formTitle"
|
||||
:style="{ width: '680px' }"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<NGrid :cols="24" :x-gap="16">
|
||||
<NFormItemGi :span="12" label="材料类型" path="materialCode">
|
||||
<!--
|
||||
编辑时锁死材料码:它是 `collect_file` 的关联键(两者之间没有外键)。
|
||||
改了码,学生已上传的文件就成了孤儿 —— 静默丢材料,且极难发现。
|
||||
-->
|
||||
<NSelect
|
||||
v-model:value="form.materialCode"
|
||||
placeholder="选择材料类型"
|
||||
:options="codeOptions"
|
||||
:disabled="isEdit"
|
||||
@update:value="onCodeChange"
|
||||
/>
|
||||
<template v-if="isEdit" #feedback>
|
||||
<span class="mat-form__hint">材料码不可修改,它是已上传文件的关联键</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="12" label="材料名称" path="materialName">
|
||||
<NInput v-model:value="form.materialName" placeholder="如 身份证" :maxlength="64" clearable />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">学生端与导出文件名都用它,要写规范名</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="24" label="别名(俗称)" path="aliases">
|
||||
<NDynamicTags v-model:value="form.aliases" :max="6" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">
|
||||
输入后回车,如:身份证正面、身份证人像面。
|
||||
用于从历史文件名里认出这是哪一项材料 —— 别名不全,该认出来的就认不出来
|
||||
</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="8" label="是否必传" path="required">
|
||||
<NSwitch v-model:value="form.required" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">一项都不必传时任务发布不了</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="16" label="适用对象" path="visibleWhenIndex">
|
||||
<NSelect v-model:value="form.visibleWhenIndex" :options="whenOptions" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">{{ currentWhenHint }}</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="24" label="允许的格式" path="fileTypes">
|
||||
<NSelect
|
||||
v-model:value="form.fileTypes"
|
||||
multiple
|
||||
placeholder="选择允许上传的文件格式"
|
||||
:options="fileTypeOptions"
|
||||
/>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="8" label="体积上限(KB)" path="maxSizeKb">
|
||||
<NInputNumber
|
||||
v-model:value="form.maxSizeKb"
|
||||
:min="maxSizeMin"
|
||||
:max="maxSizeMax"
|
||||
:step="128"
|
||||
class="mat-form__num"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">{{ maxSizeHint }}</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="8" label="最多可传份数" path="maxCount">
|
||||
<NInputNumber v-model:value="form.maxCount" :min="1" :max="10" class="mat-form__num" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">身份证正反面这类要传 2 份</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="8" label="压缩档位" path="compressProfile">
|
||||
<NSelect v-model:value="form.compressProfile" :options="profileOptions" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">档位决定分辨率与清晰度,一般不用改</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="24" label="命名模板(留空则继承任务级)" path="namePattern">
|
||||
<NInput
|
||||
v-model:value="form.namePattern"
|
||||
:maxlength="256"
|
||||
clearable
|
||||
:placeholder="DEFAULT_NAMING_TEMPLATE"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">
|
||||
留空即用任务的 <code>{{ DEFAULT_NAMING_TEMPLATE }}</code>。
|
||||
单材料项要特殊命名时才填(如身份证正反面要区分)
|
||||
</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="24" label="来源引导语" path="tipText">
|
||||
<NInput
|
||||
v-model:value="form.tipText"
|
||||
type="textarea"
|
||||
:maxlength="512"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
show-count
|
||||
placeholder="显示在学生端这一项下面,如:拍身份证人像面,四角要拍全"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">
|
||||
写得越具体,学生传错的比例越低 —— 这句话省下的是班主任逐人纠错的时间
|
||||
</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
|
||||
<NFormItemGi :span="12" label="自动识别" path="ocrType">
|
||||
<NSelect v-model:value="form.ocrType" clearable placeholder="不做识别" :options="ocrTypeOptions" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">识别结果只用于提示,不会自动改动材料归属</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi :span="12" label="排序号" path="sortNo">
|
||||
<NInputNumber v-model:value="form.sortNo" :min="0" class="mat-form__num" />
|
||||
<template #feedback>
|
||||
<span class="mat-form__hint">越小越靠前(学生端按这个顺序列材料)</span>
|
||||
</template>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end" :size="8">
|
||||
<NButton :disabled="saving" @click="closeForm">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="saving" @click="save">
|
||||
保存
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mat-drawer {
|
||||
&__task { margin-left: 12px; color: var(--color-text-3); font-weight: 400; }
|
||||
&__alert { margin-bottom: 16px; }
|
||||
&__tool { margin-bottom: 16px; }
|
||||
&__hint { color: var(--color-text-3); font-size: 12px; }
|
||||
&__empty-hint { margin-top: 8px; color: var(--color-text-3); font-size: 12px; }
|
||||
&__name { display: flex; align-items: center; gap: 6px; }
|
||||
&__code { color: var(--color-text-3); font-size: 12px; }
|
||||
&__req { font-size: 12px; line-height: 1.6; }
|
||||
&__muted { color: var(--color-text-3); }
|
||||
&__task {
|
||||
margin-left: 12px;
|
||||
font-weight: 400;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
&__tool {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
&__empty-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-form {
|
||||
&__hint { color: var(--color-text-3); font-size: 12px; }
|
||||
&__num { width: 100%; }
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
&__num {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,118 +1,64 @@
|
||||
<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)。
|
||||
* 任务「覆盖班级与初审人」配置抽屉(《03-接口设计》§7.6)。
|
||||
*
|
||||
* 三处刻意的设计:
|
||||
*
|
||||
* 1. **主班主任留空 ≠ 没配**。留空表示"取该班负责人",所以留空时下面会显示出
|
||||
* 负责人是谁。不这么做,管理员会以为漏配了,然后在几百个班上机械地点几百次 ——
|
||||
* 而它们本来就等于班级负责人。这是本产品"少让人做无意义操作"的一个具体体现。
|
||||
* 1. **初审人留空 ≠ 没配**。留空表示"取该班负责人"(服务端回退到 `sys_org.leader_user_id`),
|
||||
* 所以留空时下面会显示出负责人是谁。不这么做,管理员会以为漏配了,
|
||||
* 然后在几百个班上机械地点几百次 —— 而它们本来就等于班级负责人。
|
||||
* 这是本产品"少让人做无意义操作"的一个具体体现。
|
||||
*
|
||||
* 2. **只让班级可勾选**。把「学校」或「专业部」当成一个班会选错范围,而且错误很隐蔽:
|
||||
* 界面上只多一行,实际却把整校学生算进了应填范围。服务端也会拒,但让它在界面上
|
||||
* 就点不动,比事后报错好。
|
||||
* 界面上只多一行,实际却把整校学生算进了应填范围。服务端也会拒,
|
||||
* 但让它在界面上就点不动,比事后报错好。
|
||||
*
|
||||
* 3. **应填人数留空 = 未设置,不是 0**。0 是"预期 0 人",是个有意义的业务值;
|
||||
* 拿 0 兼职表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警,
|
||||
* 告警变噪音后就没人看了,真正对不上的那次反而被漏掉。
|
||||
*
|
||||
* ⚠️ 2026-10 需求收敛:**副班主任角色已删除**(后端 `TaskOrgSaveReq` 里也没有这个字段了),
|
||||
* 所以这里只有"初审人"一列。多留一个选了不生效的下拉,比没有它更糟。
|
||||
*/
|
||||
import type { TreeOption } from 'naive-ui'
|
||||
import type { OrgTreeVO } from '@/apis/xt/org'
|
||||
import type { TaskOrgItem, TaskVO } from '@/apis/xt/task'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDrawer,
|
||||
NDrawerContent,
|
||||
NEmpty,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NTree,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import * as orgApi from '@/apis/xt/org'
|
||||
import * as taskApi from '@/apis/xt/task'
|
||||
|
||||
defineOptions({ name: 'TaskOrgDrawer' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
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 treeData = ref<TreeOption[]>([])
|
||||
/** Naive 的勾选键是 `(string | number)[]`,所以这里不能窄化成 number[] */
|
||||
const checkedKeys = ref<Array<string | number>>([])
|
||||
const teacherOptions = ref<Array<{ id: number, label: string }>>([])
|
||||
|
||||
/** 班级 id → 组织节点,用于取名与取"该班负责人" */
|
||||
@@ -120,41 +66,31 @@ 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
|
||||
/** null = 未指定,界面上显示占位符;提交时原样传 null(接口约定的"清空"值) */
|
||||
headUserId: number | null
|
||||
/** null = 未设置(提交时不传/传 null),**不要用 0 代替** —— 0 是"预期 0 人" */
|
||||
expectedCount: number | null
|
||||
}
|
||||
|
||||
const rows = ref<Row[]>([])
|
||||
|
||||
const treeFieldNames: TreeFieldNames = {
|
||||
key: 'key',
|
||||
title: 'title',
|
||||
children: 'children',
|
||||
disableCheckbox: 'disableCheckbox'
|
||||
}
|
||||
const teacherSelectOptions = computed(
|
||||
() => teacherOptions.value.map((t) => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
function flatten(nodes: OrgTreeVO[], into: Map<number, OrgTreeVO>) {
|
||||
for (const n of nodes || []) {
|
||||
for (const n of nodes ?? []) {
|
||||
into.set(n.id, n)
|
||||
flatten(n.children ?? [], into)
|
||||
}
|
||||
}
|
||||
|
||||
function toTreeData(nodes: OrgTreeVO[]): TreeNodeData[] {
|
||||
function toTreeData(nodes: OrgTreeVO[]): TreeOption[] {
|
||||
return (nodes ?? []).map((n) => ({
|
||||
key: n.id,
|
||||
title: n.orgName,
|
||||
// 只有班级可以勾选
|
||||
disableCheckbox: n.orgType !== 3,
|
||||
label: n.orgName,
|
||||
// 只有班级可以勾选;只读时整棵树都点不动
|
||||
checkboxDisabled: readonly.value || n.orgType !== 3,
|
||||
children: toTreeData(n.children ?? [])
|
||||
}))
|
||||
}
|
||||
@@ -166,35 +102,32 @@ function orgName(orgId: number) {
|
||||
/** 留空时告诉用户"将由谁审",而不是留一句让人怀疑的空白 */
|
||||
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)
|
||||
return org?.leaderName
|
||||
? `将由班级负责人 ${org.leaderName} 初审`
|
||||
: '该班未设负责人,建议指定初审人'
|
||||
}
|
||||
|
||||
/** 勾选变化 → 行列表变化。rows 是唯一事实来源,勾选框只是它的一个视图 */
|
||||
watch(checkedKeys, (keys) => {
|
||||
const set = new Set(keys as number[])
|
||||
const set = new Set(keys.map((k) => Number(k)))
|
||||
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 })
|
||||
rows.value.push({ orgId: id, headUserId: null, expectedCount: null })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function removeRow(row: Row) {
|
||||
rows.value = rows.value.filter((r) => r.orgId !== row.orgId)
|
||||
checkedKeys.value = checkedKeys.value.filter((k) => k !== row.orgId)
|
||||
checkedKeys.value = checkedKeys.value.filter((k) => Number(k) !== row.orgId)
|
||||
}
|
||||
|
||||
async function open(task: { id: number, taskName: string, status: number }) {
|
||||
async function open(task: TaskVO) {
|
||||
taskId.value = task.id
|
||||
taskName.value = task.taskName
|
||||
// 与后端 EDITABLE_TASK_STATUS 一致:草稿/收集中/已暂停可改,已截止/已归档只读
|
||||
const reason = { 2: '截止', 3: '归档' }[task.status] ?? ''
|
||||
const reason = ({ 2: '截止', 3: '归档' } as Record<number, string>)[task.status] ?? ''
|
||||
readonly.value = !!reason
|
||||
readonlyReason.value = reason
|
||||
visible.value = true
|
||||
@@ -202,37 +135,36 @@ async function open(task: { id: number, taskName: string, status: number }) {
|
||||
checkedKeys.value = []
|
||||
|
||||
loading.value = true
|
||||
treeLoading.value = true
|
||||
try {
|
||||
const [treeRes, orgRes] = await Promise.all([
|
||||
const [tree, orgs] = await Promise.all([
|
||||
orgApi.getOrgTree(),
|
||||
taskApi.getTaskOrgs(task.id)
|
||||
])
|
||||
const tree = treeRes.data ?? []
|
||||
treeData.value = toTreeData(tree)
|
||||
const nodes = tree ?? []
|
||||
const index = new Map<number, OrgTreeVO>()
|
||||
flatten(tree, index)
|
||||
flatten(nodes, index)
|
||||
orgIndex.value = index
|
||||
treeData.value = toTreeData(nodes)
|
||||
|
||||
for (const o of orgRes.data ?? []) {
|
||||
for (const o of orgs ?? []) {
|
||||
rows.value.push({
|
||||
// 后端回 null 表示"未指定";界面上显示占位符、提交时再回 null
|
||||
orgId: o.orgId,
|
||||
// 后端回 null 表示"未指定";界面上用 undefined 让它显示占位符
|
||||
headUserId: o.headUserId ?? undefined,
|
||||
deputyUserIds: o.deputyUserIds ?? [],
|
||||
expectedCount: o.expectedCount ?? undefined
|
||||
headUserId: o.headUserId ?? null,
|
||||
expectedCount: o.expectedCount ?? null
|
||||
})
|
||||
}
|
||||
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 = [] })
|
||||
// 初审人选项不影响主流程,单独加载,失败也不该让抽屉打不开
|
||||
try {
|
||||
teacherOptions.value = await taskApi.getTeacherOptions()
|
||||
} catch {
|
||||
teacherOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -240,19 +172,20 @@ function close() {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!taskId.value) return
|
||||
if (!taskId.value) {
|
||||
return
|
||||
}
|
||||
const orgs: TaskOrgItem[] = rows.value.map((r) => ({
|
||||
orgId: r.orgId,
|
||||
headUserId: r.headUserId ?? null,
|
||||
deputyUserIds: r.deputyUserIds ?? [],
|
||||
headUserId: r.headUserId,
|
||||
// 未填就传 null("未设置"),不要传 0
|
||||
expectedCount: r.expectedCount ?? null
|
||||
expectedCount: r.expectedCount
|
||||
}))
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await taskApi.saveTaskOrgs(taskId.value, orgs)
|
||||
Message.success(orgs.length ? `已保存 ${orgs.length} 个班的覆盖范围` : '已清空覆盖范围')
|
||||
message.success(orgs.length ? `已保存 ${orgs.length} 个班的覆盖范围` : '已清空覆盖范围')
|
||||
visible.value = false
|
||||
emit('save-success')
|
||||
} finally {
|
||||
@@ -263,24 +196,126 @@ async function save() {
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer v-model:show="visible" :width="820" :mask-closable="false" placement="right">
|
||||
<NDrawerContent :native-scrollbar="false" closable>
|
||||
<template #header>
|
||||
<span>配置覆盖班级</span>
|
||||
<span class="org-drawer__task">{{ taskName }}</span>
|
||||
</template>
|
||||
|
||||
<NAlert v-if="readonly" type="default" :show-icon="false" :bordered="false" class="org-drawer__readonly">
|
||||
任务已{{ readonlyReason }},覆盖范围不可再修改,只能查看。
|
||||
</NAlert>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<div class="org-drawer__panes">
|
||||
<!-- 左:组织树 -->
|
||||
<div class="org-drawer__tree">
|
||||
<div class="org-drawer__pane-title">
|
||||
<!--
|
||||
⚠️ 标题必须整个包在一个元素里:`.org-drawer__pane-title` 是
|
||||
`display:flex + justify-content:space-between`,
|
||||
而 flex 会把**裸文本节点各自变成一个 flex item** ——
|
||||
写「已选 <strong>1</strong> 个班」会变成三个 item 被摊到整行两端,
|
||||
看起来就是"已选 1 个班"这种散开的错乱排版。
|
||||
-->
|
||||
<span>选择班级</span>
|
||||
<span class="org-drawer__pane-hint">只能勾选到班级一层</span>
|
||||
</div>
|
||||
<div class="org-drawer__tree-body">
|
||||
<NTree
|
||||
v-if="treeData.length"
|
||||
v-model:checked-keys="checkedKeys"
|
||||
:data="treeData"
|
||||
checkable
|
||||
:cascade="false"
|
||||
:selectable="false"
|
||||
:default-expand-all="true"
|
||||
block-line
|
||||
/>
|
||||
<NEmpty v-else description="还没有班级,请先在「组织架构」里建班级" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:已选班级 -->
|
||||
<div class="org-drawer__list">
|
||||
<div class="org-drawer__pane-title">
|
||||
<span>已选 <strong>{{ rows.length }}</strong> 个班</span>
|
||||
<span class="org-drawer__pane-hint">初审人留空 = 由该班负责人初审</span>
|
||||
</div>
|
||||
|
||||
<NEmpty 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>
|
||||
<NButton v-if="!readonly" text size="tiny" type="error" @click="removeRow(row)">
|
||||
移除
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="org-drawer__fields">
|
||||
<div class="org-drawer__field org-drawer__field--head">
|
||||
<div class="org-drawer__label">初审人(班主任)</div>
|
||||
<NSelect
|
||||
v-model:value="row.headUserId"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="留空 = 取班级负责人"
|
||||
:options="teacherSelectOptions"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
<div v-if="!row.headUserId" class="org-drawer__fallback">
|
||||
{{ leaderHint(row.orgId) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="org-drawer__field org-drawer__field--count">
|
||||
<div class="org-drawer__label">应填人数</div>
|
||||
<NInputNumber
|
||||
v-model:value="row.expectedCount"
|
||||
size="small"
|
||||
placeholder="未设置"
|
||||
:min="0"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
<div class="org-drawer__fallback">
|
||||
留空表示不比对,填了才与名册比
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end" :size="8">
|
||||
<NButton :disabled="saving" @click="close">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="saving" :disabled="readonly" @click="save">
|
||||
保存
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.org-drawer {
|
||||
&__task {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
&__readonly {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: block;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
&__panes {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@@ -290,12 +325,11 @@ defineExpose({ open })
|
||||
flex: none;
|
||||
width: 250px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border: 1px solid var(--xt-card-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__tree-spin {
|
||||
display: block;
|
||||
&__tree-body {
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -312,20 +346,22 @@ defineExpose({ open })
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
color: var(--xt-text);
|
||||
}
|
||||
|
||||
&__pane-hint {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
&__row {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
/* 抽屉里的"卡片内小格子":浅灰底 + 弱描边(见 styles/index.scss 的卡片式规矩) */
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&__row-head {
|
||||
@@ -337,24 +373,37 @@ defineExpose({ open })
|
||||
|
||||
&__org {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
color: #1f2229;
|
||||
}
|
||||
|
||||
&__fields {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__field {
|
||||
&--head {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&--count {
|
||||
flex: none;
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
&__fallback {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__num {
|
||||
width: 100%;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,166 +1,44 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false" class="g-list-card">
|
||||
<template #title>收集任务</template>
|
||||
<template #extra>
|
||||
<span class="task__hint">共 {{ total }} 个任务</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
状态切换:**单独一行的 tab**(2026-10-09 客户要求),不再和
|
||||
「新建任务 / 批次号 / 搜索」挤在同一行做成一个个按钮 ——
|
||||
一排长得一样的按钮分不清"哪个是当前视图、哪个是动作"。
|
||||
-->
|
||||
<a-tabs class="task__tabs" :active-key="activeStatusKey" hide-content @change="onStatusChange">
|
||||
<a-tab-pane v-for="t in STATUS_TABS" :key="t.key" :title="t.label" />
|
||||
</a-tabs>
|
||||
|
||||
<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-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,结果是横向滚动 + 钉住的「操作」列把「状态」整个盖住,
|
||||
表头挤成了「进度操作」。**改列宽前先按这个口径算一遍。**
|
||||
|
||||
⚠️ 2026-09-29 加「名单管理」时的账(新增一个链接,所以必须从别处挪宽度):
|
||||
· 「任务」230 → 200、「填报时间」160 → 130,共省出 60
|
||||
· 「操作」300 → 340(草稿态有 7 个链接,会折成两行 —— 这是**故意认下的**:
|
||||
名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置)
|
||||
· 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。
|
||||
-->
|
||||
<!--
|
||||
`y: '100%'`:让 Arco 拆开表头与表体(表体自己滚、表头钉住),
|
||||
配合卡片上的 `.g-list-card` 把分页器留在卡片底部。原因见 `styles/global.scss`。
|
||||
-->
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||
:scroll="{ x: 820, y: '100%' }" @page-change="onPageChange"
|
||||
@page-size-change="onPageSizeChange">
|
||||
<template #columns>
|
||||
<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>
|
||||
</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="130">
|
||||
<template #cell="{ record }">
|
||||
<div class="task__muted">{{ fmt(record.startTime) }} 起</div>
|
||||
<div class="task__muted">{{ fmt(record.endTime) }} 止</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<!--
|
||||
「进度」并进「状态」列,是为了给操作列腾宽度。
|
||||
1366 的屏幕减去 200 侧栏,表格可用约 824px;草稿态的操作列要放
|
||||
「编辑 / 配置班级 / 材料规则 / 表单设计 / 发布 / 删除」六个链接,
|
||||
需要 300px(240px 会折成两行)。
|
||||
进度在 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%" ——
|
||||
后者会让人以为"没人填",而真相是"还没有分母"。
|
||||
-->
|
||||
<div class="task__muted">
|
||||
{{ record.stat ? record.stat.submitRate : '待导入名册' }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<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>
|
||||
<a-link @click="onConfigForm(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 @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>
|
||||
<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 @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>
|
||||
<a-link @click="onClose(record)">截止</a-link>
|
||||
</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>
|
||||
|
||||
<template v-else>
|
||||
<a-link @click="onConfigRoster(record)">查看名单</a-link>
|
||||
</template>
|
||||
</a-space>
|
||||
<span v-else class="task__muted">只读</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<TaskFormModal ref="formModalRef" @save-success="load" />
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 收集任务(《03-接口设计》§7.1 ~ §7.5)。
|
||||
*
|
||||
* 这一屏的核心不是"列表 + 增删改",而是**状态机**:
|
||||
* 草稿 →(发布)→ 收集中 →(暂停)⇄(恢复)→ 已暂停 →(截止)→ 已截止 →(归档)→ 已归档
|
||||
*
|
||||
* 两个容易做错的地方,界面上刻意做了区分:
|
||||
* 1. **「暂停」和「截止」是两件事**:暂停可以恢复、已提交的照常审核;截止不可逆。
|
||||
* 所以暂停用普通确认,截止与归档用"不可逆"措辞的二次确认。
|
||||
* 2. **发布可能被拦**:5 项前置校验都是阻断级。发起前不讲清"还差什么",
|
||||
* 用户会以为是系统坏了 —— 所以发布失败时把服务端列出的缺项原样展示,
|
||||
* 并把"仅 P1 提醒、可通过 force 跳过"的那一步单独做成二次确认。
|
||||
*
|
||||
* ⚠️ 表格为什么是 `flex-height` 而不是上一版的 `:scroll="{ x, y }"`:
|
||||
* Naive 的 `n-data-table` **没有 `scroll` 这个 prop**,竖滚靠 `flex-height`
|
||||
* (或 `max-height`)实现。但目的与上一版完全相同 —— 表体自己滚、分页器钉在卡片底部,
|
||||
* 而不是整张卡片被裁出可视区(`.page-card` / `.page-card__body` 就是为它准备的,
|
||||
* 见 `styles/index.scss` 里那段注释)。
|
||||
*
|
||||
* ⚠️ 单元格是 `render` 渲染的(Naive 的列不支持具名插槽),所以这些元素的样式
|
||||
* **走内联**:渲染函数里创建的元素拿不到本组件的 scoped 属性。
|
||||
*/
|
||||
import type { DataTableColumns, PaginationProps } from 'naive-ui'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { BizError } from '@/apis/types'
|
||||
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 {
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NInput,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
NTag,
|
||||
useDialog,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref, useTemplateRef } from 'vue'
|
||||
import * as taskApi from '@/apis/xt/task'
|
||||
import {
|
||||
TASK_STATUS,
|
||||
@@ -169,74 +47,37 @@ import {
|
||||
TASK_STATUS_DRAFT,
|
||||
TASK_STATUS_PAUSED
|
||||
} from '@/apis/xt/task'
|
||||
import { useUserStore } from '@/stores'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
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)。
|
||||
*
|
||||
* 这一屏的核心不是"列表 + 增删改",而是**状态机**:
|
||||
* 草稿 →(发布)→ 收集中 →(暂停)⇄(恢复)→ 已暂停 →(截止)→ 已截止 →(归档)→ 已归档
|
||||
*
|
||||
* 两个容易做错的地方,界面上刻意做了区分:
|
||||
* 1. **「暂停」和「截止」是两件事**:暂停可以恢复、已提交的照常审核;
|
||||
* 截止不可逆。所以暂停用普通确认,截止用"不可逆"措辞的二次确认。
|
||||
* 2. **发布可能被拦**:5 项前置校验都是阻断级。发起前不讲清"还差什么",
|
||||
* 用户会以为是系统坏了。所以发布失败时把服务端列出的缺项原样展示,
|
||||
* 并把"仅 P1 提醒、可通过 force 跳过"的那一步单独做成二次确认。
|
||||
*/
|
||||
defineOptions({ name: 'Task' })
|
||||
|
||||
/** 状态 tab 的一项:tab 的 key 是字符串,发给接口的是 status */
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const user = useUserStore()
|
||||
|
||||
/** 没有 `task:manage` 的人(例如班主任)看得到列表,但一个动作也不给 */
|
||||
const canManage = computed(() => user.has('task:manage'))
|
||||
|
||||
/** 状态 tab 的一项:tab 的 `name` 是字符串,发给接口的是 `status` */
|
||||
interface StatusTab {
|
||||
key: string
|
||||
label: string
|
||||
value: number | undefined
|
||||
}
|
||||
|
||||
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 orgDrawerRef = useTemplateRef('orgDrawerRef')
|
||||
const materialDrawerRef = useTemplateRef('materialDrawerRef')
|
||||
const formDrawerRef = useTemplateRef('formDrawerRef')
|
||||
const rosterDrawerRef = useTemplateRef('rosterDrawerRef')
|
||||
|
||||
/**
|
||||
* 发布被拦时,每个阻断项**去哪里修**。
|
||||
*
|
||||
* 光说"未设置覆盖班级或班主任"是没用的:用户知道缺什么,但不知道在哪补。
|
||||
* 而这 5 项分属 4 个不同的界面(名册 / 材料规则 / 本页的配置班级 / 编辑弹窗),
|
||||
* 让用户自己找是纯粹的浪费。
|
||||
*/
|
||||
const BLOCKING_HINTS: Record<number, string> = {
|
||||
40016: '请点本行的「名单管理」,导入本任务的报考名册',
|
||||
40017: '名册缺少「姓名」或「身份证号」列,请到本行的「名单管理」里重新导入',
|
||||
// 必须说"点本行":「材料规则」也是一个顶层菜单(那个还是里程碑占位页),
|
||||
// 只说「请到『材料规则』里配置」会把人引到那个占位页去
|
||||
40018: '请点本行的「材料规则」,配置至少一项必传材料',
|
||||
40019: '请点本行的「配置班级」,勾选本次收集涉及的班级',
|
||||
40020: '命名规则有问题,请点「编辑」检查文件命名模板'
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态 tab。
|
||||
*
|
||||
* `key` 必须是字符串(Arco 的 tab key 不支持 undefined),
|
||||
* `value` 才是发给接口的 `status` —— 「全部」= 不带 status 参数,
|
||||
* 这个映射只在这一处定义,别在别处再写一份 `?? undefined`。
|
||||
* `name` 必须是字符串(Naive 的 tab 不接受 `undefined`),`value` 才是发给接口的
|
||||
* `status` —— 「全部」= 不带 status 参数。这个映射只在这一处定义,
|
||||
* 别在别处再写一份 `?? undefined`。
|
||||
*/
|
||||
const STATUS_TABS: StatusTab[] = [
|
||||
{ key: 'all', label: '全部', value: undefined },
|
||||
@@ -246,19 +87,99 @@ const STATUS_TABS: StatusTab[] = [
|
||||
{ key: String(TASK_STATUS_CLOSED), label: '已截止', value: TASK_STATUS_CLOSED }
|
||||
]
|
||||
|
||||
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: '' })
|
||||
|
||||
/** 当前选中的 tab(由 statusFilter 反推,保持单一数据源) */
|
||||
const activeStatusKey = computed(() =>
|
||||
STATUS_TABS.find((t) => t.value === statusFilter.value)?.key ?? 'all'
|
||||
const activeStatusKey = computed(
|
||||
() => STATUS_TABS.find((t) => t.value === statusFilter.value)?.key ?? 'all'
|
||||
)
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
/**
|
||||
* 子组件通过 `defineExpose` 暴露的方法。
|
||||
*
|
||||
* 这里显式声明而不是用 `InstanceType<typeof X>`:列表页只需要这几个方法,
|
||||
* 声明出来读代码的人一眼就知道父子之间的契约是什么。
|
||||
*/
|
||||
interface ModalExpose {
|
||||
add: () => void
|
||||
edit: (row: TaskVO) => void
|
||||
}
|
||||
interface DrawerExpose {
|
||||
open: (task: TaskVO) => void
|
||||
}
|
||||
|
||||
const formModalRef = useTemplateRef<ModalExpose>('formModalRef')
|
||||
const orgDrawerRef = useTemplateRef<DrawerExpose>('orgDrawerRef')
|
||||
const materialDrawerRef = useTemplateRef<DrawerExpose>('materialDrawerRef')
|
||||
const formDrawerRef = useTemplateRef<DrawerExpose>('formDrawerRef')
|
||||
const rosterDrawerRef = useTemplateRef<DrawerExpose>('rosterDrawerRef')
|
||||
|
||||
/**
|
||||
* 发布被拦时,每个阻断项**去哪里修**。
|
||||
*
|
||||
* 光说"未设置覆盖班级或班主任"是没用的:用户知道缺什么,但不知道在哪补。
|
||||
* 而这 5 项分属 4 个不同的界面(名册 / 材料规则 / 配置班级 / 编辑弹窗),
|
||||
* 让用户自己找是纯粹的浪费。
|
||||
*/
|
||||
const BLOCKING_HINTS: Record<number, string> = {
|
||||
40016: '请点本行的「名单管理」,导入本任务的报考名册',
|
||||
40017: '名册缺少「姓名」或「身份证号」列,请到本行的「名单管理」里重新导入',
|
||||
40018: '请点本行的「材料规则」,配置至少一项必传材料',
|
||||
40019: '请点本行的「配置班级」,勾选本次收集涉及的班级',
|
||||
40020: '命名规则有问题,请点「编辑」检查文件命名模板'
|
||||
}
|
||||
|
||||
/** 发布前的阻断级错误码。**给 force 也绕不过去**,所以界面上不给这个入口 */
|
||||
const BLOCKING_CODES = [40016, 40017, 40018, 40019, 40020]
|
||||
|
||||
/**
|
||||
* `n-card` 的内容区要变成"能撑满的 flex 列"。
|
||||
*
|
||||
* 不这么写,里面的表格拿不到确定高度,`flex-height` 无从计算 ——
|
||||
* 表现就是分页器被裁在可视区之外(这是上一版踩过的坑)。
|
||||
*/
|
||||
const contentStyle: CSSProperties = {
|
||||
flex: '1 1 auto',
|
||||
minHeight: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
|
||||
// 渲染函数里的样式常量(见文件头那段说明:scoped 样式对这些元素不生效)
|
||||
const S_NAME = 'font-weight: 500; color: #1f2229'
|
||||
const S_MUTED = 'font-size: 12px; color: #8a8a8a'
|
||||
const S_MONO = 'font-family: ui-monospace, monospace; font-size: 12px; color: #8a8a8a'
|
||||
const S_ACTIONS = 'display: flex; flex-wrap: wrap; gap: 4px 8px; justify-content: center'
|
||||
|
||||
const pagination = computed<PaginationProps>(() => ({
|
||||
page: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
itemCount: total.value,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50],
|
||||
prefix: (info) => `共 ${info.itemCount} 条`,
|
||||
// ⚠️ 改每页条数必须重新取数:表格自己也会按 pageSize 切一次数据,
|
||||
// 只改本地条数就会出现"分页器说 50 条/页、表里还是 10 条"的自相矛盾画面
|
||||
onUpdatePage: (page: number) => {
|
||||
query.pageNum = page
|
||||
void load()
|
||||
},
|
||||
onUpdatePageSize: (size: number) => {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
void load()
|
||||
}
|
||||
}))
|
||||
|
||||
function rowKeyOf(row: TaskVO) {
|
||||
return row.id
|
||||
}
|
||||
|
||||
function fmt(v: string | null) {
|
||||
return v ? v.replace('T', ' ').slice(0, 10) : '—'
|
||||
}
|
||||
@@ -266,15 +187,15 @@ function fmt(v: string | null) {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await taskApi.getTaskList({
|
||||
const page = 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
|
||||
list.value = page?.list ?? []
|
||||
total.value = page?.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -282,35 +203,16 @@ async function load() {
|
||||
|
||||
function search() {
|
||||
query.pageNum = 1
|
||||
load()
|
||||
void load()
|
||||
}
|
||||
|
||||
function setStatus(v: number | undefined) {
|
||||
statusFilter.value = v
|
||||
/** 状态 tab 切换(Naive 回的是 tab 的 name) */
|
||||
function onStatusChange(key: string) {
|
||||
statusFilter.value = STATUS_TABS.find((t) => t.key === key)?.value
|
||||
search()
|
||||
}
|
||||
|
||||
/** 状态 tab 切换 */
|
||||
function onStatusChange(key: string | number) {
|
||||
setStatus(STATUS_TABS.find((t) => t.key === String(key))?.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 改每页条数。
|
||||
*
|
||||
* ⚠️ 与「操作日志」同一处坑:Arco 表格在 `pagination` 非 false 时**自己也会切数据**,
|
||||
* 不重新取数就会出现「分页器说 50 条/页、表里还是 10 条」的自相矛盾画面。
|
||||
*/
|
||||
function onPageSizeChange(size: number) {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
load()
|
||||
}
|
||||
// ── 行内动作 ────────────────────────────────────────
|
||||
|
||||
function onAdd() {
|
||||
formModalRef.value?.add()
|
||||
@@ -324,6 +226,10 @@ function onConfigOrgs(row: TaskVO) {
|
||||
orgDrawerRef.value?.open(row)
|
||||
}
|
||||
|
||||
function onConfigRoster(row: TaskVO) {
|
||||
rosterDrawerRef.value?.open(row)
|
||||
}
|
||||
|
||||
function onConfigMaterials(row: TaskVO) {
|
||||
materialDrawerRef.value?.open(row)
|
||||
}
|
||||
@@ -332,13 +238,14 @@ 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('已删除')
|
||||
try {
|
||||
await taskApi.deleteTask(row.id)
|
||||
} catch {
|
||||
// http 层已经弹过原因;这里不关弹窗之外的事,直接返回
|
||||
return false
|
||||
}
|
||||
message.success('已删除')
|
||||
await load()
|
||||
}
|
||||
|
||||
@@ -347,45 +254,55 @@ async function onDelete(row: TaskVO) {
|
||||
*
|
||||
* 先不带 force 试一次:如果服务端说"存在待确认的提醒",说明 5 项阻断级校验都过了,
|
||||
* 只剩 P1 级提醒(比如某班没指定初审人)。这时才用二次确认 + force 重发。
|
||||
* 如果返回的是阻断级错误码(40016/40017/40018/40019/40020),
|
||||
* 说明根本还没配齐 —— 直接展示缺项,**不给 force 的入口**(给了也绕不过去,只会让人白试)。
|
||||
* 如果返回的是阻断级错误码,说明根本还没配齐 —— 直接展示缺项,
|
||||
* **不给 force 的入口**(给了也绕不过去,只会让人白试)。
|
||||
*/
|
||||
async function onPublish(row: TaskVO) {
|
||||
try {
|
||||
const res = await taskApi.publishTask(row.id, false)
|
||||
Message.success('已发布,学生可以开始填报了')
|
||||
const warns = res.data?.warnings ?? []
|
||||
message.success('已发布,学生可以开始填报了')
|
||||
const warns = res?.warnings ?? []
|
||||
if (warns.length) {
|
||||
Modal.info({ title: '发布时的提醒', content: warns.join(';') })
|
||||
dialog.info({
|
||||
title: '发布时的提醒',
|
||||
content: () => h('div', { style: 'line-height: 1.8' }, warns.join(';')),
|
||||
positiveText: '知道了'
|
||||
})
|
||||
}
|
||||
await load()
|
||||
} catch (e) {
|
||||
const err = e as Error & { bizCode?: number }
|
||||
const err = e as BizError
|
||||
const msg = err?.message ?? ''
|
||||
// 阻断级错误码:把缺项讲清楚 + 告诉他去哪里补,不给 force 入口
|
||||
// (给了也绕不过去,只会让人白试一次)
|
||||
const blocking = [40016, 40017, 40018, 40019, 40020]
|
||||
if (err?.bizCode && blocking.includes(err.bizCode)) {
|
||||
if (err?.bizCode && BLOCKING_CODES.includes(err.bizCode)) {
|
||||
const hint = BLOCKING_HINTS[err.bizCode]
|
||||
Modal.warning({
|
||||
dialog.warning({
|
||||
title: '还不能发布',
|
||||
content: `${msg}${hint ? `\n\n👉 ${hint}` : ''}
|
||||
|
||||
这些是阻断级检查,必须补齐后重新发布(点「确定」不会放行)。`,
|
||||
hideCancel: true
|
||||
content: () => h('div', { style: 'line-height: 1.8' }, [
|
||||
h('div', msg),
|
||||
hint ? h('div', { style: 'margin-top: 8px' }, hint) : null,
|
||||
h('div', { style: 'margin-top: 8px' }, '这些是阻断级检查,必须补齐后重新发布(点「确定」不会放行)。')
|
||||
]),
|
||||
positiveText: '知道了'
|
||||
})
|
||||
return
|
||||
}
|
||||
// P1 级提醒:二次确认后可 force
|
||||
Modal.confirm({
|
||||
dialog.warning({
|
||||
title: '发现待确认的提醒',
|
||||
content: `${msg}
|
||||
|
||||
确认无误后可以继续发布。`,
|
||||
okText: '确认并发布',
|
||||
onOk: async () => {
|
||||
await taskApi.publishTask(row.id, true)
|
||||
Message.success('已发布')
|
||||
content: () => h('div', { style: 'line-height: 1.8' }, [
|
||||
h('div', msg),
|
||||
h('div', { style: 'margin-top: 8px' }, '确认无误后可以继续发布。')
|
||||
]),
|
||||
positiveText: '确认并发布',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await taskApi.publishTask(row.id, true)
|
||||
} catch {
|
||||
// 失败时保持弹窗打开,让用户看到 http 层弹的原因
|
||||
return false
|
||||
}
|
||||
message.success('已发布')
|
||||
await load()
|
||||
}
|
||||
})
|
||||
@@ -393,15 +310,21 @@ async function onPublish(row: TaskVO) {
|
||||
}
|
||||
|
||||
function onPause(row: TaskVO) {
|
||||
const reason = ''
|
||||
Modal.confirm({
|
||||
dialog.warning({
|
||||
title: `暂停「${row.taskName}」?`,
|
||||
content: '暂停后学生<b>不能提交</b>,但<b>已提交的照常审核</b>,而且随时可以恢复。'
|
||||
+ '适合"名单要改、规则要调"的场景 —— 如果是不再收集了,请用「截止」。',
|
||||
okText: '暂停',
|
||||
onOk: async () => {
|
||||
await taskApi.pauseTask(row.id, reason || undefined)
|
||||
Message.success('已暂停')
|
||||
content: () => h('div', { style: 'line-height: 1.8' }, [
|
||||
h('div', '暂停后学生不能提交,但已提交的照常审核,而且随时可以恢复。'),
|
||||
h('div', { style: 'margin-top: 8px' }, '适合"名单要改、规则要调"的场景 —— 如果是不再收集了,请用「截止」。')
|
||||
]),
|
||||
positiveText: '暂停',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await taskApi.pauseTask(row.id)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
message.success('已暂停')
|
||||
await load()
|
||||
}
|
||||
})
|
||||
@@ -409,58 +332,273 @@ function onPause(row: TaskVO) {
|
||||
|
||||
async function onResume(row: TaskVO) {
|
||||
await taskApi.resumeTask(row.id)
|
||||
Message.success('已恢复')
|
||||
message.success('已恢复')
|
||||
await load()
|
||||
}
|
||||
|
||||
function onClose(row: TaskVO) {
|
||||
Modal.warning({
|
||||
dialog.warning({
|
||||
title: `截止「${row.taskName}」?`,
|
||||
content: '截止后学生不能提交,且<b>不可逆</b>(不能再恢复为收集中)。已提交的仍可审核。',
|
||||
okText: '确认截止',
|
||||
hideCancel: false,
|
||||
onBeforeOk: async () => {
|
||||
await taskApi.closeTask(row.id)
|
||||
Message.success('已截止')
|
||||
content: () => h('div', { style: 'line-height: 1.8' }, '截止后学生不能提交,且不可逆(不能再恢复为收集中)。已提交的仍可审核。'),
|
||||
positiveText: '确认截止',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await taskApi.closeTask(row.id)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
message.success('已截止')
|
||||
await load()
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onArchive(row: TaskVO) {
|
||||
Modal.warning({
|
||||
dialog.warning({
|
||||
title: `归档「${row.taskName}」?`,
|
||||
content: '归档后<b>连审核也不能做</b>,整体转为只读。有未审完的单子会被拦下。',
|
||||
okText: '确认归档',
|
||||
hideCancel: false,
|
||||
onBeforeOk: async () => {
|
||||
await taskApi.archiveTask(row.id)
|
||||
Message.success('已归档')
|
||||
content: () => h('div', { style: 'line-height: 1.8' }, '归档后连审核也不能做,整体转为只读。有未审完的单子会被拦下。'),
|
||||
positiveText: '确认归档',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await taskApi.archiveTask(row.id)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
message.success('已归档')
|
||||
await load()
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── 列 ─────────────────────────────────────────────
|
||||
|
||||
/** 文字按钮(表格里的动作都是这个形态) */
|
||||
function link(text: string, onClick: () => void, type: 'primary' | 'warning' | 'error' | 'success' = 'primary') {
|
||||
return h(NButton, { text: true, size: 'small', type, onClick }, { default: () => text })
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作列按状态给出不同的动作。
|
||||
*
|
||||
* 三个刻意的地方:
|
||||
* · 名册(准入名单)排在材料之前 —— 没有它,学生连填报页都进不去;
|
||||
* · 「材料规则」的文案与服务端 40018 的提示一字不差,否则用户按提示找不到地方;
|
||||
* · 已截止后名册只能看不能改(抽屉里会说清),所以仍然给入口。
|
||||
*/
|
||||
function actionCell(row: TaskVO) {
|
||||
if (!canManage.value) {
|
||||
return h('span', { style: S_MUTED }, '只读')
|
||||
}
|
||||
const items = []
|
||||
if (row.status === TASK_STATUS_DRAFT) {
|
||||
items.push(link('编辑', () => onEdit(row)))
|
||||
items.push(link('配置班级', () => onConfigOrgs(row)))
|
||||
items.push(link('名单管理', () => onConfigRoster(row)))
|
||||
items.push(link('材料规则', () => onConfigMaterials(row)))
|
||||
items.push(link('表单设计', () => onConfigForm(row)))
|
||||
// 发布是"推进状态"的主动作,但**不**做成成功/告警色:
|
||||
// 它与「暂停 / 恢复」不是一类(那两个才是需要被一眼看见的风险动作)
|
||||
items.push(link('发布', () => void onPublish(row)))
|
||||
items.push(h(NPopconfirm, {
|
||||
onPositiveClick: () => onDelete(row)
|
||||
}, {
|
||||
trigger: () => h(NButton, { text: true, size: 'small', type: 'error' }, { default: () => '删除' }),
|
||||
default: () => '删除后不可恢复,确认删除这个草稿?'
|
||||
}))
|
||||
} else if (row.status === TASK_STATUS_COLLECTING) {
|
||||
items.push(link('配置班级', () => onConfigOrgs(row)))
|
||||
items.push(link('名单管理', () => onConfigRoster(row)))
|
||||
items.push(link('材料规则', () => onConfigMaterials(row)))
|
||||
items.push(link('表单设计', () => onConfigForm(row)))
|
||||
items.push(link('暂停', () => onPause(row), 'warning'))
|
||||
items.push(link('截止', () => onClose(row)))
|
||||
} else if (row.status === TASK_STATUS_PAUSED) {
|
||||
items.push(link('配置班级', () => onConfigOrgs(row)))
|
||||
items.push(link('名单管理', () => onConfigRoster(row)))
|
||||
items.push(link('材料规则', () => onConfigMaterials(row)))
|
||||
items.push(link('表单设计', () => onConfigForm(row)))
|
||||
items.push(link('恢复', () => void onResume(row), 'success'))
|
||||
items.push(link('截止', () => onClose(row)))
|
||||
} else if (row.status === TASK_STATUS_CLOSED) {
|
||||
items.push(link('查看名单', () => onConfigRoster(row)))
|
||||
items.push(link('归档', () => onArchive(row)))
|
||||
} else {
|
||||
items.push(link('查看名单', () => onConfigRoster(row)))
|
||||
}
|
||||
return h('div', { style: S_ACTIONS }, items)
|
||||
}
|
||||
|
||||
/**
|
||||
* 列宽合计 900 —— 这是"1366 宽的屏幕上尽量不出现横向滚动条"的账
|
||||
* (减去 220 侧栏与内容区内边距后剩约 1100),`scroll-x` 取同一个数。
|
||||
* 改列宽前先按这个口径算一遍,否则钉住的「操作」列会把「状态」整个盖住。
|
||||
*/
|
||||
const columns = computed<DataTableColumns<TaskVO>>(() => [
|
||||
{
|
||||
title: '任务',
|
||||
key: 'taskName',
|
||||
width: 200,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => h('div', [
|
||||
h('div', { style: S_NAME }, row.taskName),
|
||||
h('div', { style: S_MONO }, row.taskCode)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '批次 / 专业',
|
||||
key: 'batchNo',
|
||||
width: 120,
|
||||
render: (row) => h('div', [
|
||||
h('div', row.batchNo),
|
||||
h('div', { style: S_MUTED }, row.majorName)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '填报时间',
|
||||
key: 'startTime',
|
||||
width: 130,
|
||||
render: (row) => h('div', [
|
||||
h('div', { style: S_MUTED }, `${fmt(row.startTime)} 起`),
|
||||
h('div', { style: S_MUTED }, `${fmt(row.endTime)} 止`)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '状态 / 进度',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (row) => {
|
||||
const tag = tagOf(TASK_STATUS, row.status, '未知')
|
||||
return h('div', [
|
||||
h(NTag, { size: 'small', bordered: false, type: tag.type }, { default: () => tag.text }),
|
||||
// stat 依赖名册与填报单:还没有名册时后端回 null。
|
||||
// 这里如实显示「待导入名册」,而不是把 null 当成 0 显示成"0%"
|
||||
// (后者会让人以为"没人填",而真相是"还没有分母")
|
||||
h('div', { style: `${S_MUTED}; margin-top: 4px` }, row.stat ? row.stat.submitRate : '待导入名册')
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 340,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
render: (row) => actionCell(row)
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="page-card" :content-style="contentStyle">
|
||||
<template #header>
|
||||
收集任务
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="task__hint">共 {{ total }} 个任务</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
状态切换:**单独一行的 tab**(2026-10-09 客户要求),不再和
|
||||
「新建任务 / 批次号 / 搜索」挤在同一行做成一个个按钮 ——
|
||||
一排长得一样的按钮分不清"哪个是当前视图、哪个是动作"。
|
||||
-->
|
||||
<NTabs class="task__tabs" :value="activeStatusKey" @update:value="onStatusChange">
|
||||
<!--
|
||||
⚠️ 必须用 `#tab` **插槽**,不能用 `tab="..."` 属性:
|
||||
naive-ui 2.45.3 的 Tab 渲染里,属性值走的是
|
||||
`normalizeVNode(() => render(mergedTab ?? name))`,而这个 thunk 返回的是
|
||||
一个 Text VNode → 被当成块 `createBlock(vnode)` 处理 → 只渲染出一个空注释节点。
|
||||
表现是**一排 tab 一个字都不显示、整行高度 12px**(筛选功能等于消失,不报错)。
|
||||
插槽那条分支是 `normalizeVNode(() => defaultSlot())`,返回数组 → 正常渲染。
|
||||
-->
|
||||
<NTabPane v-for="t in STATUS_TABS" :key="t.key" :name="t.key">
|
||||
<template #tab>
|
||||
{{ t.label }}
|
||||
</template>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
|
||||
<!--
|
||||
工具条:**主操作在左、筛选在右**,同一行里所有控件统一默认尺寸。
|
||||
之前全排 `size="small"`,28px 的白底小按钮贴在白底表格上几乎看不出是按钮
|
||||
(客户反馈"按钮不明显");默认尺寸是 34px,且全局给默认按钮加深了描边。
|
||||
-->
|
||||
<div class="toolbar">
|
||||
<NSpace :size="8">
|
||||
<NButton v-if="canManage" type="primary" @click="onAdd">
|
||||
<template #icon>
|
||||
<AppIcon name="plus" :size="16" />
|
||||
</template>
|
||||
新建任务
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NSpace :size="8">
|
||||
<NInput
|
||||
v-model:value="query.batchNo"
|
||||
class="task__batch"
|
||||
placeholder="批次号"
|
||||
clearable
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="query.keyword"
|
||||
class="task__search"
|
||||
placeholder="搜索任务名 / 编码 / 专业"
|
||||
clearable
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
<NButton type="primary" ghost @click="search">
|
||||
<template #icon>
|
||||
<AppIcon name="search" :size="16" />
|
||||
</template>
|
||||
搜索
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<div class="page-card__body">
|
||||
<NDataTable
|
||||
flex-height
|
||||
remote
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="rowKeyOf"
|
||||
:pagination="pagination"
|
||||
:scroll-x="900"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<TaskFormModal ref="formModalRef" @save-success="load" />
|
||||
<TaskOrgDrawer ref="orgDrawerRef" @save-success="load" />
|
||||
<TaskMaterialDrawer ref="materialDrawerRef" @save-success="load" />
|
||||
<TaskFormSchemaDrawer ref="formDrawerRef" @save-success="load" />
|
||||
<TaskRosterDrawer ref="rosterDrawerRef" @save-success="load" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/* 状态 tab 自己占一行(下面的工具栏是另一行) */
|
||||
/* 状态 tab 自己占一行(下面的工具栏是另一行);pane 里没有内容,别让它占位 */
|
||||
&__tabs {
|
||||
:deep(.arco-tabs-nav) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
/* tab 自带下边框(bar 型),与工具条的下边框离得太近会变成两条挨着的横线 */
|
||||
margin-bottom: 14px;
|
||||
|
||||
:deep(.arco-tabs-nav::before) {
|
||||
background-color: var(--color-border-2);
|
||||
:deep(.n-tab-pane) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,21 +609,5 @@ onMounted(load)
|
||||
&__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>
|
||||
|
||||
Reference in New Issue
Block a user