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,208 +1,25 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" :width="1000" :mask-closable="false" :footer="false"
|
||||
unmount-on-close @cancel="close">
|
||||
<template #title>
|
||||
<span>{{ detail?.realName || '填报单' }}</span>
|
||||
<a-tag v-if="detail" class="detail__head-tag" :color="submitTag.color">
|
||||
{{ detail.statusText || submitTag.text }}
|
||||
</a-tag>
|
||||
<span v-if="detail" class="detail__head-sub">
|
||||
{{ detail.taskName }} · {{ detail.orgName || '未分班' }} · {{ fmtTime(detail.submitTime) }} 提交
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-spin :loading="loading" class="detail__spin">
|
||||
<template v-if="detail">
|
||||
<!--
|
||||
操作区放在**最上面**:审核是"看完就点"的动作,按钮在最下面意味着
|
||||
每次都要滚过整页材料 —— 而审核员一天要看几十份。
|
||||
-->
|
||||
<div class="detail__bar">
|
||||
<a-space wrap>
|
||||
<a-button v-if="canApprove" type="primary" size="small" :loading="acting"
|
||||
@click="onApprove">
|
||||
<template #icon><IconCheck /></template>
|
||||
{{ nodeText }}通过
|
||||
</a-button>
|
||||
<a-tooltip v-else :content="detail.approvePolicy?.reason || '当前不能通过'">
|
||||
<a-button type="primary" size="small" disabled>
|
||||
{{ nodeText }}通过
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-button v-if="canReject" status="danger" size="small" @click="onReject">
|
||||
<template #icon><IconClose /></template>
|
||||
驳回
|
||||
</a-button>
|
||||
|
||||
<a-popconfirm v-if="canRevoke" content="撤回这次审核结论?撤回是再写一条记录,历史不会被删掉。"
|
||||
@ok="onRevoke">
|
||||
<a-button size="small" status="warning">撤回审核结论</a-button>
|
||||
</a-popconfirm>
|
||||
|
||||
<a-button size="small" @click="reload">刷新校验结果</a-button>
|
||||
</a-space>
|
||||
|
||||
<a-space>
|
||||
<span class="detail__muted">证件号 {{ detail.idCardMask || '—' }}</span>
|
||||
<a-tag v-if="detail.redCount" color="red">红 {{ detail.redCount }}</a-tag>
|
||||
<a-tag v-if="detail.yellowCount" color="orange">黄 {{ detail.yellowCount }}</a-tag>
|
||||
<a-tag v-if="detail.rejectCount" color="gray">已驳 {{ detail.rejectCount }} 次</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 有红问题时把"为什么不能通过"写在最显眼处,而不是等用户点了才知道 -->
|
||||
<a-alert v-if="redTitles.length" type="error" class="detail__alert">
|
||||
<template #title>
|
||||
有 {{ redTitles.length }} 项阻断级问题,<strong>不能通过</strong>:
|
||||
{{ redTitles.join(';') }}
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-alert v-if="lastReject" type="warning" :show-icon="false" class="detail__alert">
|
||||
<template #title>
|
||||
上次驳回:{{ lastReject.reasonText || lastReject.reasonCode }}
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- ── 材料 ─────────────────────────────── -->
|
||||
<div class="detail__section">材料({{ doneCount }} / {{ materialGroups.length }} 已交)</div>
|
||||
<a-empty v-if="!materialGroups.length" description="这个任务没有配置材料项" />
|
||||
<div v-for="g in materialGroups" :key="g.materialCode" class="detail__group">
|
||||
<div class="detail__group-head">
|
||||
<span class="detail__group-name">{{ g.materialName }}</span>
|
||||
<a-tag v-if="g.required" color="red" size="small">必传</a-tag>
|
||||
<!-- 条件材料对学生不可见时不该要求他交,审核页上要说清 -->
|
||||
<a-tag v-if="!g.visible" color="gray" size="small">本次不适用</a-tag>
|
||||
<a-tag :color="g.done ? 'green' : 'orange'" size="small">
|
||||
{{ g.done ? '已交' : '未交' }}
|
||||
</a-tag>
|
||||
<span class="detail__code">{{ g.materialCode }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="g.files?.length" class="detail__files">
|
||||
<div v-for="f in g.files" :key="f.id" class="detail__file">
|
||||
<FilePreviewImage :file="f" />
|
||||
<div class="detail__file-meta">
|
||||
<div v-if="f.source !== 1" class="detail__file-src">
|
||||
{{ FILE_SOURCE[f.source ?? 0] || '其他来源' }}
|
||||
</div>
|
||||
<div class="detail__muted">
|
||||
{{ f.finalSizeKb ? `${f.finalSizeKb} KB` : '—' }}
|
||||
<template v-if="f.width && f.height"> · {{ f.width }}×{{ f.height }}</template>
|
||||
<template v-if="f.dpi"> · {{ f.dpi }}dpi</template>
|
||||
</div>
|
||||
<a-tag v-if="f.validateStatus !== null"
|
||||
:color="FILE_VALIDATE[f.validateStatus]?.color ?? 'gray'" size="small">
|
||||
{{ FILE_VALIDATE[f.validateStatus]?.text ?? '未知' }}
|
||||
</a-tag>
|
||||
<!-- 识别结果 M4 未做时为 null:不显示"识别中",那是在说谎 -->
|
||||
<div v-if="f.detect" class="detail__muted">
|
||||
识别:{{ f.detect.sourceText || f.detect.source || '—' }}
|
||||
<template v-if="f.detect.confidence !== null && f.detect.confidence !== undefined">
|
||||
(置信度 {{ f.detect.confidence }})
|
||||
</template>
|
||||
<a-tag v-if="f.detect.conflict" color="red" size="small">疑似串档</a-tag>
|
||||
</div>
|
||||
<div v-for="(i, idx) in f.issues ?? []" :key="idx" class="detail__issue-line"
|
||||
:class="`detail__issue-line--${i.severity}`">
|
||||
{{ i.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="detail__muted detail__empty-file">没有文件</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 问题清单 ─────────────────────────── -->
|
||||
<div class="detail__section">
|
||||
校验问题({{ issues.length }})
|
||||
<span class="detail__muted">现场重算,不是提交时的快照</span>
|
||||
</div>
|
||||
<a-empty v-if="!issues.length" description="没有校验问题" />
|
||||
<a-table v-else :data="issues" :pagination="false" size="small" row-key="ruleCode"
|
||||
:bordered="{ cell: true }">
|
||||
<template #columns>
|
||||
<a-table-column title="级别" :width="80" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="ISSUE_SEVERITY[record.severity]?.color ?? 'gray'" size="small">
|
||||
{{ ISSUE_SEVERITY[record.severity]?.text ?? '—' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="规则" :width="110">
|
||||
<template #cell="{ record }">
|
||||
<span class="detail__code">{{ record.ruleCode }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="问题">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.title }}</div>
|
||||
<div class="detail__muted">{{ record.detail }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- ── 学生改过什么 ─────────────────────── -->
|
||||
<template v-if="changedDetail.length">
|
||||
<div class="detail__section">
|
||||
学生改动({{ changedDetail.length }} 项)
|
||||
<span class="detail__muted">预填来自名册,下面是"老师填的 vs 学生交的"</span>
|
||||
</div>
|
||||
<a-table :data="changedDetail" :pagination="false" size="small" row-key="field"
|
||||
:bordered="{ cell: true }">
|
||||
<template #columns>
|
||||
<a-table-column title="字段" :width="160">
|
||||
<template #cell="{ record }">{{ record.label || record.field }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="名册(老师填的)">
|
||||
<template #cell="{ record }">
|
||||
<span class="detail__muted">{{ display(record.rosterValue) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="学生提交的">
|
||||
<template #cell="{ record }">
|
||||
<span class="detail__changed">{{ display(record.studentValue) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<!-- ── 填报表单 ─────────────────────────── -->
|
||||
<div class="detail__section">填报内容</div>
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item v-for="row in formRows" :key="row.key" :label="row.label">
|
||||
<span :class="{ detail__changed: row.changed }">{{ display(row.value) }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- ── 时间轴 ───────────────────────────── -->
|
||||
<div class="detail__section">审核时间轴(最近的在上)</div>
|
||||
<a-empty v-if="!timeline.length" description="暂无记录" />
|
||||
<div v-for="(t, idx) in timeline" :key="`${t.id}-${idx}`" class="detail__tl">
|
||||
<a-tag :color="TIMELINE_NODE[t.node]?.color ?? 'gray'" size="small">
|
||||
{{ t.nodeText || TIMELINE_NODE[t.node]?.text || '—' }}
|
||||
</a-tag>
|
||||
<span class="detail__tl-text">
|
||||
{{ t.actionText || '—' }}
|
||||
<template v-if="t.auditorName"> · {{ t.auditorName }}</template>
|
||||
<template v-if="t.reasonText"> · {{ t.reasonText }}</template>
|
||||
</span>
|
||||
<span class="detail__muted">{{ fmtTime(t.time) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-spin>
|
||||
|
||||
<AuditRejectModal ref="rejectRef" :materials="materialGroups" @rejected="afterAction" />
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditDetail, AuditIssue } from '@/apis/xt/audit'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconCheck, IconClose } from '@arco-design/web-vue/es/icon'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { AuditChangedItem, AuditDetail, AuditIssue } from '@/apis/xt/audit'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDataTable,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NDivider,
|
||||
NDrawer,
|
||||
NDrawerContent,
|
||||
NEmpty,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NTag,
|
||||
NTooltip,
|
||||
useDialog,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, ref, useTemplateRef } from 'vue'
|
||||
import {
|
||||
approveSubmission,
|
||||
AUDIT_ERROR_HINT,
|
||||
@@ -212,30 +29,34 @@ import {
|
||||
getAuditDetail,
|
||||
ISSUE_SEVERITY,
|
||||
revokeSubmission,
|
||||
SUBMISSION_STATUS,
|
||||
TIMELINE_NODE
|
||||
} from '@/apis/xt/audit'
|
||||
import { getFormSchema } from '@/apis/xt/form-schema'
|
||||
import { useUserStore } from '@/stores'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { tagOf, tagType } from '@/utils/tag'
|
||||
import AuditRejectModal from './AuditRejectModal.vue'
|
||||
import FilePreviewImage from './FilePreviewImage.vue'
|
||||
import FilePreview from './FilePreview.vue'
|
||||
|
||||
/**
|
||||
* 审核详情(原型 ③ / ⑥,契约 §11.2)。
|
||||
*
|
||||
* ## 四个"必须这么显示"的地方
|
||||
* ## 五个"必须这么显示"的地方
|
||||
*
|
||||
* 1. **红问题要挡在按钮上**。不能通过的真正原因(红问题)写在按钮旁边的提示里,
|
||||
* 1. **红问题要挡在按钮上**。不能通过的真正原因(红问题)写在按钮旁边,
|
||||
* 而不是等用户点了再弹一个 40032 —— 那会让人以为是系统故障。
|
||||
* 判定仍以**服务端** `approvePolicy.canApprove` 为准,前端不自己算。
|
||||
*
|
||||
* 2. **问题清单是现场重算的**。所以标题里写明"不是提交时的快照",
|
||||
* 否则审核员会拿它去和手机上的红黄计数对,然后怀疑哪边坏了。
|
||||
*
|
||||
* 3. **识别结果可能为 null**(M4 未做)。这时**不显示"识别中"**,
|
||||
* 3. **识别结果可能为 null**(材料识别模块未做)。这时**不显示"识别中"**,
|
||||
* 那是在说谎;直接不显示这一行。
|
||||
*
|
||||
* 4. **撤回是"再写一条记录"**,不是删掉上一条 —— 所以二次确认里要说清,
|
||||
* 免得有人以为点错了会把历史抹掉。
|
||||
* 5. **红问题清单的兜底分支口径是 `severity === 3`(阻断),不是 1**。
|
||||
* 上一版这里写的是 `=== 1`,而按 `ISSUE_SEVERITY` 的口径 1 是"提示" ——
|
||||
* 结果是把提示级的文字摆进"不能通过"的红色告警里。以服务端的
|
||||
* `Issue.ERROR = 3` 为准(`SubmitDtos.Issue`)。
|
||||
*/
|
||||
defineOptions({ name: 'AuditDetailDrawer' })
|
||||
|
||||
@@ -248,6 +69,8 @@ const emit = defineEmits<{
|
||||
(e: 'changed'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const visible = ref(false)
|
||||
@@ -263,28 +86,30 @@ const nodeText = computed(() => (node.value === 2 ? '终审' : '初审'))
|
||||
|
||||
const materialGroups = computed(() => detail.value?.materials ?? [])
|
||||
const issues = computed<AuditIssue[]>(() => detail.value?.issues ?? [])
|
||||
const changedDetail = computed(() => detail.value?.changedDetail ?? [])
|
||||
const changedDetail = computed<AuditChangedItem[]>(() => detail.value?.changedDetail ?? [])
|
||||
const timeline = computed(() => detail.value?.timeline ?? [])
|
||||
|
||||
const doneCount = computed(() => materialGroups.value.filter((g) => g.done).length)
|
||||
|
||||
/** 服务端 policy 优先;没有 policy 时按"阻断级问题"兜底(severity 3 = ERROR) */
|
||||
const redTitles = computed(() => {
|
||||
const fromPolicy = detail.value?.approvePolicy?.redTitles
|
||||
if (fromPolicy?.length) {
|
||||
return fromPolicy
|
||||
}
|
||||
return issues.value.filter((i) => i.severity === 1).map((i) => i.title)
|
||||
return issues.value.filter((i) => i.severity === 3).map((i) => i.title)
|
||||
})
|
||||
|
||||
/** 判定一律以服务端 policy 为准(前端只负责把原因显示出来) */
|
||||
const canApprove = computed(() => detail.value?.approvePolicy?.canApprove === true)
|
||||
const blockReason = computed(() => detail.value?.approvePolicy?.reason || '当前不能通过')
|
||||
const canReject = computed(() => {
|
||||
const s = detail.value?.status
|
||||
return s === 2 || s === 3
|
||||
})
|
||||
/** 撤回既要服务端允许(已导出/已归档时不允许),也要有 `audit:withdraw` */
|
||||
const canRevoke = computed(
|
||||
() => detail.value?.revokePolicy?.canRevoke === true
|
||||
&& userStore.permissions.includes('audit:withdraw')
|
||||
() => detail.value?.revokePolicy?.canRevoke === true && userStore.has('audit:withdraw')
|
||||
)
|
||||
|
||||
const lastReject = computed(() => {
|
||||
@@ -295,17 +120,16 @@ const lastReject = computed(() => {
|
||||
return { reasonCode: d.lastRejectCode, reasonText: d.lastRejectText }
|
||||
})
|
||||
|
||||
const submitTag = computed(() => {
|
||||
const s = detail.value?.status
|
||||
const map: Record<number, { text: string, color: string }> = {
|
||||
1: { text: '草稿', color: 'gray' },
|
||||
2: { text: '待初审', color: 'orange' },
|
||||
3: { text: '待终审', color: 'arcoblue' },
|
||||
4: { text: '已驳回', color: 'red' },
|
||||
5: { text: '已通过', color: 'green' }
|
||||
}
|
||||
return map[s ?? 0] ?? { text: '未知', color: 'gray' }
|
||||
})
|
||||
/**
|
||||
* 状态标签。
|
||||
*
|
||||
* ⚠️ 这里原来写的是 `1 草稿 / 2 待初审 / 3 待终审 / 4 已驳回 / 5 已通过` —— 那是**名册行**的口径,
|
||||
* 套到**填报单**上整体错位一格:文案被服务端的 `statusText` 兜住了看不出来,
|
||||
* 但 tag 的**颜色**只取自本地表,于是"已通过"一直是待终审的蓝。
|
||||
* 正确口径(`0 草稿 / 1 待初审 / 2 待终审 / 3 已通过 / 4 已驳回 / 5 已归档`)现放在
|
||||
* `apis/xt/audit.ts` 的 `SUBMISSION_STATUS`,与接口同处,避免再各写一份。
|
||||
*/
|
||||
const submitTag = computed(() => tagOf(SUBMISSION_STATUS, detail.value?.status, '未知'))
|
||||
|
||||
/** 表单值 → 可读文本 */
|
||||
function display(v: unknown): string {
|
||||
@@ -346,6 +170,47 @@ const formRows = computed(() => {
|
||||
return rows
|
||||
})
|
||||
|
||||
const issueColumns: DataTableColumns<AuditIssue> = [
|
||||
{
|
||||
title: '级别',
|
||||
key: 'severity',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (row) => {
|
||||
const meta = tagOf(ISSUE_SEVERITY, row.severity, '—')
|
||||
return h(NTag, { size: 'small', bordered: false, type: meta.type }, { default: () => meta.text })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '规则',
|
||||
key: 'ruleCode',
|
||||
width: 110,
|
||||
render: (row) => h('span', { class: 'detail__code' }, row.ruleCode)
|
||||
},
|
||||
{
|
||||
title: '问题',
|
||||
key: 'title',
|
||||
render: (row) => h('div', [
|
||||
h('div', row.title),
|
||||
h('div', { class: 'detail__muted' }, row.detail)
|
||||
])
|
||||
}
|
||||
]
|
||||
|
||||
const changedColumns: DataTableColumns<AuditChangedItem> = [
|
||||
{ title: '字段', key: 'label', width: 160, render: (row) => row.label || row.field },
|
||||
{
|
||||
title: '名册(老师填的)',
|
||||
key: 'rosterValue',
|
||||
render: (row) => h('span', { class: 'detail__muted' }, display(row.rosterValue))
|
||||
},
|
||||
{
|
||||
title: '学生提交的',
|
||||
key: 'studentValue',
|
||||
render: (row) => h('span', { class: 'detail__changed' }, display(row.studentValue))
|
||||
}
|
||||
]
|
||||
|
||||
function fmtTime(v: string | null | undefined) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
@@ -353,15 +218,14 @@ function fmtTime(v: string | null | undefined) {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAuditDetail(submissionId.value)
|
||||
detail.value = res.data ?? null
|
||||
detail.value = await getAuditDetail(submissionId.value)
|
||||
const taskId = detail.value?.taskId
|
||||
if (taskId && !Object.keys(labelMap.value).length) {
|
||||
// 字段中文名从**当前**表单定义取。历史提交若用了已删除的扩展字段,就退回显示 key ——
|
||||
// 显示 key 虽然不漂亮,但不会把两个字段的标签张冠李戴。
|
||||
const fs = await getFormSchema(taskId)
|
||||
const map: Record<string, string> = {}
|
||||
for (const f of [...(fs.data?.fields ?? []), ...(fs.data?.extras ?? [])]) {
|
||||
for (const f of [...(fs?.fields ?? []), ...(fs?.extras ?? [])]) {
|
||||
map[f.key] = f.label || f.key
|
||||
}
|
||||
labelMap.value = map
|
||||
@@ -373,7 +237,7 @@ async function load() {
|
||||
|
||||
async function reload() {
|
||||
await load()
|
||||
Message.success('已重新校验')
|
||||
message.success('已重新校验')
|
||||
}
|
||||
|
||||
async function open(id: number) {
|
||||
@@ -399,11 +263,10 @@ async function onApprove() {
|
||||
actionSource: 'BUTTON',
|
||||
finalOpinion: node.value === 2 ? '终审通过' : undefined
|
||||
})
|
||||
const r = res.data
|
||||
if (r?.replayed) {
|
||||
Message.info('这一单刚才已经审过了(重复请求已幂等处理)')
|
||||
if (res?.replayed) {
|
||||
message.info('这一单刚才已经审过了(重复请求已幂等处理)')
|
||||
} else {
|
||||
Message.success(`${nodeText.value}通过`)
|
||||
message.success(`${nodeText.value}通过`)
|
||||
}
|
||||
afterAction()
|
||||
} catch (e) {
|
||||
@@ -411,14 +274,16 @@ async function onApprove() {
|
||||
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||
if (code === 40032) {
|
||||
// 红问题:把服务端的话原样展示 + 提示,并刷新详情(现场重算,计数可能变了)
|
||||
Modal.warning({
|
||||
dialog.warning({
|
||||
title: '不能通过',
|
||||
content: `${(e as Error).message}\n\n${hint}\n\n点「确定」后本页会重新校验一次。`,
|
||||
hideCancel: true,
|
||||
onOk: () => load()
|
||||
positiveText: '确定',
|
||||
onPositiveClick: () => {
|
||||
void load()
|
||||
}
|
||||
})
|
||||
} else if (hint) {
|
||||
Message.error(`${(e as Error).message}(${hint})`)
|
||||
message.error(`${(e as Error).message}(${hint})`)
|
||||
}
|
||||
} finally {
|
||||
acting.value = false
|
||||
@@ -435,155 +300,338 @@ async function onRevoke() {
|
||||
auditRecordId: detail.value?.revokePolicy?.auditRecordId ?? undefined,
|
||||
reason: 'PC 端撤回'
|
||||
})
|
||||
Message.success(res.data?.statusText || '已撤回')
|
||||
message.success(res?.statusText || '已撤回')
|
||||
afterAction()
|
||||
} catch (e) {
|
||||
const code = bizCodeOf(e)
|
||||
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||
Message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||
message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail {
|
||||
&__head-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
<template>
|
||||
<NDrawer v-model:show="visible" :width="1000" placement="right" :mask-closable="false">
|
||||
<NDrawerContent :native-scrollbar="false" closable>
|
||||
<template #header>
|
||||
<span>{{ detail?.realName || '填报单' }}</span>
|
||||
<NTag v-if="detail" class="detail__head-tag" size="small" :bordered="false" :type="submitTag.type">
|
||||
{{ detail.statusText || submitTag.text }}
|
||||
</NTag>
|
||||
<span v-if="detail" class="detail__head-sub">
|
||||
{{ detail.taskName }} · {{ detail.orgName || '未分班' }} · {{ fmtTime(detail.submitTime) }} 提交
|
||||
</span>
|
||||
</template>
|
||||
|
||||
&__head-sub {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<NSpin :show="loading">
|
||||
<template v-if="detail">
|
||||
<!--
|
||||
操作区放在**最上面**:审核是"看完就点"的动作,按钮在最下面意味着
|
||||
每次都要滚过整页材料 —— 而审核员一天要看几十份。
|
||||
-->
|
||||
<div class="detail__bar">
|
||||
<NSpace align="center" :size="8">
|
||||
<!-- 不能通过的原因写在按钮上(而不是等点了再弹 40032) -->
|
||||
<NTooltip :disabled="canApprove" trigger="hover">
|
||||
<template #trigger>
|
||||
<span class="detail__tt">
|
||||
<NButton type="primary" :disabled="!canApprove" :loading="acting" @click="onApprove">
|
||||
<template #icon>
|
||||
<AppIcon name="check" :size="16" />
|
||||
</template>
|
||||
{{ nodeText }}通过
|
||||
</NButton>
|
||||
</span>
|
||||
</template>
|
||||
{{ blockReason }}
|
||||
</NTooltip>
|
||||
|
||||
&__spin {
|
||||
display: block;
|
||||
}
|
||||
<NButton v-if="canReject" type="error" secondary @click="onReject">
|
||||
<template #icon>
|
||||
<AppIcon name="close" :size="16" />
|
||||
</template>
|
||||
驳回
|
||||
</NButton>
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid var(--color-border-1);
|
||||
}
|
||||
<NPopconfirm v-if="canRevoke" @positive-click="onRevoke">
|
||||
<template #trigger>
|
||||
<NButton type="warning" secondary>撤回审核结论</NButton>
|
||||
</template>
|
||||
撤回这次审核结论?撤回是<strong>再写一条记录</strong>,历史不会被删掉。
|
||||
</NPopconfirm>
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
<NButton @click="reload">
|
||||
<template #icon>
|
||||
<AppIcon name="refresh" :size="16" />
|
||||
</template>
|
||||
刷新校验结果
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
&__section {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
<NSpace align="center" :size="6">
|
||||
<span class="detail__muted">证件号 {{ detail.idCardMask || '—' }}</span>
|
||||
<NTag v-if="detail.redCount" size="small" :bordered="false" type="error">红 {{ detail.redCount }}</NTag>
|
||||
<NTag v-if="detail.yellowCount" size="small" :bordered="false" type="warning">黄 {{ detail.yellowCount }}</NTag>
|
||||
<NTag v-if="detail.rejectCount" size="small" :bordered="false">已驳 {{ detail.rejectCount }} 次</NTag>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
.detail__muted {
|
||||
margin-left: 8px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
<!-- 有阻断级问题时把"为什么不能通过"写在最显眼处 -->
|
||||
<NAlert v-if="redTitles.length" type="error" class="detail__alert" title="有阻断级问题,不能通过">
|
||||
{{ redTitles.join(';') }}
|
||||
</NAlert>
|
||||
|
||||
&__group {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
<NAlert v-if="lastReject" type="warning" :show-icon="false" class="detail__alert">
|
||||
上次驳回:{{ lastReject.reasonText || lastReject.reasonCode }}
|
||||
</NAlert>
|
||||
|
||||
&__group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
<!-- ── 材料 ─────────────────────────────── -->
|
||||
<div class="section-title">
|
||||
材料({{ doneCount }} / {{ materialGroups.length }} 已交)
|
||||
</div>
|
||||
<NEmpty v-if="!materialGroups.length" description="这个任务没有配置材料项" />
|
||||
<div v-for="g in materialGroups" :key="g.materialCode" class="detail__group">
|
||||
<div class="detail__group-head">
|
||||
<span class="detail__group-name">{{ g.materialName }}</span>
|
||||
<NTag v-if="g.required" size="small" :bordered="false" type="error">必传</NTag>
|
||||
<!-- 条件材料对学生不可见时不该要求他交,审核页上要说清 -->
|
||||
<NTag v-if="!g.visible" size="small" :bordered="false">本次不适用</NTag>
|
||||
<NTag size="small" :bordered="false" :type="g.done ? 'success' : 'warning'">
|
||||
{{ g.done ? '已交' : '未交' }}
|
||||
</NTag>
|
||||
<span class="detail__code">{{ g.materialCode }}</span>
|
||||
</div>
|
||||
|
||||
&__group-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
<div v-if="g.files?.length" class="detail__files">
|
||||
<div v-for="f in g.files" :key="f.id" class="detail__file">
|
||||
<FilePreview :file="f" />
|
||||
<div class="detail__file-meta">
|
||||
<div v-if="f.source !== 1" class="detail__file-src">
|
||||
{{ FILE_SOURCE[f.source ?? 0] || '其他来源' }}
|
||||
</div>
|
||||
<div class="detail__muted">
|
||||
{{ f.finalSizeKb ? `${f.finalSizeKb} KB` : '—' }}
|
||||
<template v-if="f.width && f.height"> · {{ f.width }}×{{ f.height }}</template>
|
||||
<template v-if="f.dpi"> · {{ f.dpi }}dpi</template>
|
||||
</div>
|
||||
<NTag v-if="f.validateStatus !== null" size="small" :bordered="false"
|
||||
:type="tagType(FILE_VALIDATE[f.validateStatus ?? 0]?.color)">
|
||||
{{ FILE_VALIDATE[f.validateStatus ?? 0]?.text ?? '未知' }}
|
||||
</NTag>
|
||||
<!-- 识别结果未做时为 null:不显示"识别中",那是在说谎 -->
|
||||
<div v-if="f.detect" class="detail__muted">
|
||||
识别:{{ f.detect.sourceText || f.detect.source || '—' }}
|
||||
<template v-if="f.detect.confidence !== null && f.detect.confidence !== undefined">
|
||||
(置信度 {{ f.detect.confidence }})
|
||||
</template>
|
||||
<NTag v-if="f.detect.conflict" size="small" :bordered="false" type="error" style="margin-left: 4px">
|
||||
疑似串档
|
||||
</NTag>
|
||||
</div>
|
||||
<div v-for="(i, idx) in f.issues ?? []" :key="idx" class="detail__issue-line" :class="`detail__issue-line--${i.severity}`">
|
||||
{{ i.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="detail__muted detail__empty-file">没有文件</div>
|
||||
</div>
|
||||
|
||||
&__files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
<!-- ── 问题清单 ─────────────────────────── -->
|
||||
<div class="section-title">
|
||||
校验问题({{ issues.length }})
|
||||
<span class="section-title__note">现场重算,不是提交时的快照</span>
|
||||
</div>
|
||||
<NEmpty v-if="!issues.length" description="没有校验问题" />
|
||||
<!-- 抽屉里的小表用 `:max-height`(这里没有"撑满"的高度链,flex-height 会失效) -->
|
||||
<NDataTable v-else size="small" :columns="issueColumns" :data="issues" :bordered="true"
|
||||
:row-key="(row: AuditIssue) => row.ruleCode" :pagination="false"
|
||||
:max-height="240" :scroll-x="560" />
|
||||
|
||||
&__file {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 340px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
<!-- ── 学生改过什么 ─────────────────────── -->
|
||||
<template v-if="changedDetail.length">
|
||||
<div class="section-title">
|
||||
学生改动({{ changedDetail.length }} 项)
|
||||
<span class="section-title__note">预填来自名册,下面是"老师填的 vs 学生交的"</span>
|
||||
</div>
|
||||
<NDataTable size="small" :columns="changedColumns" :data="changedDetail" :bordered="true"
|
||||
:row-key="(row: AuditChangedItem) => row.field" :pagination="false"
|
||||
:max-height="240" :scroll-x="600" />
|
||||
</template>
|
||||
|
||||
&__file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
<!-- ── 填报表单 ─────────────────────────── -->
|
||||
<div class="section-title">填报内容</div>
|
||||
<NDescriptions :column="2" size="small" bordered>
|
||||
<NDescriptionsItem v-for="row in formRows" :key="row.key" :label="row.label">
|
||||
<span :class="{ detail__changed: row.changed }">{{ display(row.value) }}</span>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
&__file-src {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
<!-- ── 时间轴 ───────────────────────────── -->
|
||||
<div class="section-title">审核时间轴(最近的在上)</div>
|
||||
<NEmpty v-if="!timeline.length" description="暂无记录" />
|
||||
<div v-for="(t, idx) in timeline" :key="`${t.id}-${idx}`" class="detail__tl">
|
||||
<NTag size="small" :bordered="false" :type="tagType(TIMELINE_NODE[t.node]?.color)">
|
||||
{{ t.nodeText || TIMELINE_NODE[t.node]?.text || '—' }}
|
||||
</NTag>
|
||||
<span class="detail__tl-text">
|
||||
{{ t.actionText || '—' }}
|
||||
<template v-if="t.auditorName"> · {{ t.auditorName }}</template>
|
||||
<template v-if="t.reasonText"> · {{ t.reasonText }}</template>
|
||||
</span>
|
||||
<span class="detail__muted">{{ fmtTime(t.time) }}</span>
|
||||
</div>
|
||||
|
||||
&__empty-file {
|
||||
margin-top: 6px;
|
||||
}
|
||||
<NDivider />
|
||||
</template>
|
||||
</NSpin>
|
||||
|
||||
&__issue-line {
|
||||
font-size: 12px;
|
||||
<AuditRejectModal ref="rejectRef" :materials="materialGroups" @rejected="afterAction" />
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
// ⚠️ 1 提示(蓝)/ 2 警告(黄)/ 3 阻断(红)—— 与服务端 Issue 常量同口径。
|
||||
// 写反的后果是把"提示"染成红色,所有人都会去追一个不存在的问题。
|
||||
&--3 {
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
<style scoped>
|
||||
.detail__head-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&--2 {
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
.detail__head-sub {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
&--1 {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
/* 操作条:审核是"看完就点"的动作,所以它必须一眼看到(按钮统一默认尺寸,主操作 primary) */
|
||||
.detail__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid var(--xt-card-border-weak);
|
||||
}
|
||||
|
||||
&__code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
/* 禁用状态的按钮不触发鼠标事件,用 span 兜住才能显示 tooltip */
|
||||
.detail__tt {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.detail__alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__changed {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
/**
|
||||
* 材料分组:抽屉内部的**小格子**(浅灰底 + 弱描边),不是第二层白卡片。
|
||||
* 每一份文件才是白底小格(`.detail__file`),这样"组 → 文件"两层分得开。
|
||||
*/
|
||||
.detail__group {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&__tl {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dashed var(--color-border-1);
|
||||
}
|
||||
.detail__group-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__tl-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail__group-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail__files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.detail__file {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 340px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.detail__file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail__file-src {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text);
|
||||
}
|
||||
|
||||
.detail__empty-file {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.detail__issue-line {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 1 提示(蓝)/ 2 警告(黄)/ 3 阻断(红)—— 与服务端 Issue 常量同口径。
|
||||
写反的后果是把"提示"染成红色,所有人都会去追一个不存在的问题。 */
|
||||
.detail__issue-line--3 {
|
||||
color: #d54941;
|
||||
}
|
||||
|
||||
.detail__issue-line--2 {
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
.detail__issue-line--1 {
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 下面这几个类**同时**出现在渲染函数里(表格列、表单行)。
|
||||
* 渲染函数生成的元素不带 `data-v-*`,写成普通 scoped 选择器匹配不到
|
||||
* (不报错,只是样式静默失效),所以统一用 `:deep()`。
|
||||
*/
|
||||
:deep(.detail__code) {
|
||||
font-family: ui-monospace, sfmono-regular, menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
:deep(.detail__muted) {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
:deep(.detail__changed) {
|
||||
font-weight: 600;
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
.detail__tl {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--xt-card-border-weak);
|
||||
}
|
||||
|
||||
.detail__tl-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,65 +1,32 @@
|
||||
<template>
|
||||
<a-modal v-model:visible="visible" title="驳回填报单" :width="560" :mask-closable="false"
|
||||
:ok-loading="saving" ok-text="确认驳回" @before-ok="onOk" @close="close">
|
||||
<a-alert type="warning" :show-icon="false" class="reject__tip">
|
||||
<template #title>
|
||||
驳回后学生会看到原因,<strong>可以改完重交</strong>。
|
||||
一期还没有通知渠道,所以还需要<strong>口头告知学生</strong>。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item label="驳回原因" required>
|
||||
<a-radio-group v-model="form.reasonCode" direction="vertical" @change="onCodeChange">
|
||||
<a-radio v-for="r in reasonCodes" :key="r.code" :value="r.code" class="reject__radio">
|
||||
{{ r.text }}
|
||||
<span v-if="r.requiresText" class="reject__req">(必须补充说明)</span>
|
||||
</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!--
|
||||
R999「其他原因」必须填说明:不填的话学生只看到"其他原因"四个字,
|
||||
等于什么也没告诉他就让他重做一份。
|
||||
-->
|
||||
<a-form-item :label="requiresText ? '补充说明(必填)' : '补充说明(可选)'"
|
||||
:validate-status="textError ? 'error' : undefined" :help="textError">
|
||||
<a-textarea v-model="form.reasonText" :rows="3" :max-length="200" show-word-limit
|
||||
placeholder="例如:身份证反面缺角看不清,请重新拍一张完整的" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="materials.length" label="是哪几项材料的问题(可选)">
|
||||
<a-checkbox-group v-model="form.materialCodes">
|
||||
<a-checkbox v-for="m in materials" :key="m.materialCode" :value="m.materialCode">
|
||||
{{ m.materialName }}
|
||||
</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="是否需要学生重新上传">
|
||||
<a-switch v-model="form.suggestReupload" />
|
||||
<span class="reject__hint">
|
||||
关掉 = 只是信息填错,改文字即可;开着 = 材料本身有问题,必须重传。
|
||||
</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditMaterialGroup, RejectReasonCode } from '@/apis/xt/audit'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCheckbox,
|
||||
NCheckboxGroup,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NRadio,
|
||||
NRadioGroup,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { getReasonCodes, rejectSubmission } from '@/apis/xt/audit'
|
||||
|
||||
/**
|
||||
* 驳回弹窗(原型 STEP ⑤)。
|
||||
*
|
||||
* 三条设计理由:
|
||||
* 1. **必须点按钮**(原型铁律:班主任端通过可以右滑,驳回不行)——
|
||||
* 三条设计理由(沿用上一版,只换了 UI 库):
|
||||
* 1. **必须点按钮**(原型铁律:班主任端"通过"可以右滑,驳回不行)——
|
||||
* 误触通过的后果是"再撤一次",误触驳回的后果是学生白改一份材料,两者不对称。
|
||||
* 2. **原因快选**:附录 C 的 10 条写在代码里(`RejectReasonCatalog`),
|
||||
* 2. **原因快选**:附录 C 的 10 条写在服务端代码里(`RejectReasonCatalog`),
|
||||
* 字典从服务端取,不在前端硬编 —— 否则加一条原因要发一次前端。
|
||||
* 3. **R999 必须填说明**:只有"其他原因"四个字,学生无从下手。
|
||||
* 3. **R999 必须填说明**:只给学生"其他原因"四个字,他无从下手。
|
||||
*/
|
||||
defineOptions({ name: 'AuditRejectModal' })
|
||||
|
||||
@@ -71,6 +38,8 @@ const emit = defineEmits<{
|
||||
(e: 'rejected'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const visible = ref(false)
|
||||
const saving = ref(false)
|
||||
const submissionId = ref<number>(0)
|
||||
@@ -106,11 +75,10 @@ async function open(id: number) {
|
||||
visible.value = true
|
||||
if (!reasonCodes.value.length) {
|
||||
try {
|
||||
const res = await getReasonCodes()
|
||||
reasonCodes.value = res.data ?? []
|
||||
reasonCodes.value = await getReasonCodes()
|
||||
} catch {
|
||||
// 字典取不到时不静默:让用户知道"现在没法选原因",而不是看到一个空列表乱猜
|
||||
Message.warning('驳回原因字典没取到,请刷新页面重试')
|
||||
message.warning('驳回原因字典没取到,请刷新页面重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,12 +89,12 @@ function close() {
|
||||
|
||||
async function onOk() {
|
||||
if (!form.reasonCode) {
|
||||
Message.warning('请选择驳回原因')
|
||||
return false
|
||||
message.warning('请选择驳回原因')
|
||||
return
|
||||
}
|
||||
if (requiresText.value && !form.reasonText.trim()) {
|
||||
textError.value = '选择「其他原因」时必须写清是什么问题'
|
||||
return false
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -136,23 +104,18 @@ async function onOk() {
|
||||
materialCodes: form.materialCodes.length ? form.materialCodes : undefined,
|
||||
suggestReupload: form.suggestReupload
|
||||
})
|
||||
const r = res.data
|
||||
if (r?.needManualNotice) {
|
||||
Message.warning({
|
||||
content: '已驳回。通知没发出去(一期没有通知渠道),请口头告知学生',
|
||||
duration: 5000
|
||||
})
|
||||
if (res?.needManualNotice) {
|
||||
// 服务端明说"通知没发出去"时,必须让老师知道要去口头告知 ——
|
||||
// 否则学生一直等,而老师以为系统已经通知过了
|
||||
message.warning('已驳回。通知没发出去,请口头告知学生', { duration: 5000 })
|
||||
} else {
|
||||
Message.success(r?.statusText || '已驳回')
|
||||
message.success(res?.statusText || '已驳回')
|
||||
}
|
||||
if (r?.escalated) {
|
||||
Message.info('该单驳回次数已达上限,管理员会收到提醒')
|
||||
if (res?.escalated) {
|
||||
message.info('该单驳回次数已达上限,管理员会收到提醒')
|
||||
}
|
||||
visible.value = false
|
||||
emit('rejected')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -161,26 +124,108 @@ async function onOk() {
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reject {
|
||||
&__tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
title="驳回填报单"
|
||||
:style="{ width: '560px' }"
|
||||
:mask-closable="false"
|
||||
:bordered="false"
|
||||
>
|
||||
<NAlert type="warning" :show-icon="false" class="reject__tip">
|
||||
驳回后学生会看到原因,<strong>可以改完重交</strong>。
|
||||
如果提交后提示「通知没发出去」,还需要<strong>口头告知学生</strong>。
|
||||
</NAlert>
|
||||
|
||||
&__radio {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
<NForm label-placement="top">
|
||||
<NFormItem label="驳回原因" required>
|
||||
<!--
|
||||
十条原因是**单选清单**,给一块浅灰小格子把它与上下两栏分开;
|
||||
限高 + 滚动是因为十条全展开会把弹窗顶得很高(底部按钮被推出可视区)。
|
||||
-->
|
||||
<div class="reject__box">
|
||||
<NRadioGroup v-model:value="form.reasonCode" @update:value="onCodeChange">
|
||||
<NSpace vertical :size="6">
|
||||
<NRadio v-for="r in reasonCodes" :key="r.code" :value="r.code">
|
||||
{{ r.text }}
|
||||
<span v-if="r.requiresText" class="reject__req">(必须补充说明)</span>
|
||||
</NRadio>
|
||||
</NSpace>
|
||||
</NRadioGroup>
|
||||
</div>
|
||||
</NFormItem>
|
||||
|
||||
&__req {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<!--
|
||||
R999「其他原因」必须填说明:不填的话学生只看到"其他原因"四个字,
|
||||
等于什么也没告诉他就让他重做一份。
|
||||
-->
|
||||
<NFormItem
|
||||
:label="requiresText ? '补充说明(必填)' : '补充说明(可选)'"
|
||||
:validation-status="textError ? 'error' : undefined"
|
||||
:feedback="textError || undefined"
|
||||
>
|
||||
<NInput
|
||||
v-model:value="form.reasonText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:maxlength="200"
|
||||
show-count
|
||||
placeholder="例如:身份证反面缺角看不清,请重新拍一张完整的"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
&__hint {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<NFormItem v-if="materials.length" label="是哪几项材料的问题(可选)">
|
||||
<NCheckboxGroup v-model:value="form.materialCodes">
|
||||
<NSpace :size="12">
|
||||
<NCheckbox v-for="m in materials" :key="m.materialCode" :value="m.materialCode" :label="m.materialName" />
|
||||
</NSpace>
|
||||
</NCheckboxGroup>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="是否需要学生重新上传">
|
||||
<NSpace align="center" :size="8">
|
||||
<NSwitch v-model:value="form.suggestReupload" />
|
||||
<span class="reject__hint">
|
||||
关掉 = 只是信息填错,改文字即可;开着 = 材料本身有问题,必须重传。
|
||||
</span>
|
||||
</NSpace>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end" :size="8">
|
||||
<NButton @click="close">取消</NButton>
|
||||
<NButton type="error" :loading="saving" @click="onOk">确认驳回</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reject__tip {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* 弹窗内部的小格子:浅灰底 + 弱描边(不要在这里再套 NCard,弹窗本身就是卡片) */
|
||||
.reject__box {
|
||||
width: 100%;
|
||||
max-height: 236px;
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.reject__req {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.reject__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup lang="ts">
|
||||
import type { AuditFileItem } from '@/apis/xt/audit'
|
||||
import { NButton, NModal, NRadioButton, NRadioGroup, NSpace, NSpin, NTag } from 'naive-ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { FILE_VALIDATE, getAuditFileUrl } from '@/apis/xt/audit'
|
||||
import { tagType } from '@/utils/tag'
|
||||
|
||||
/**
|
||||
* 材料预览(审核端)。
|
||||
*
|
||||
* ## 为什么单独封一个组件
|
||||
*
|
||||
* 待办卡片、审核详情、班主任端都要显示材料,而"取签名链接"这件事**不能各写一份**:
|
||||
* 一处忘了走审核端通道(`/audit/files/*`)就会去调学生端的接口,
|
||||
* 而那条通道校验的是"这份是不是你传的",审核人必然拿不到。
|
||||
*
|
||||
* ## 三条硬规则
|
||||
*
|
||||
* 1. **链接是现取的,不缓存、不入库、不落 localStorage**。七牛空间是私有的,
|
||||
* 下载走签名链接且有有效期(默认 30 分钟);把链接存起来,明天打开就是坏的。
|
||||
* 2. **原始件(`original=true`)要单独再取一次**,而且只在用户真的切到"原始件/并排"
|
||||
* 时才取 —— 审核大多数时候看的是规范件,没必要每次都多打一个接口。
|
||||
* 3. **原始件取不到不是错误**:`cleanExpiredRawFiles` 定时任务默认只保留 30 天,
|
||||
* 过期后原始件已被清理。这种情况给一句说明,而不是弹一个红色报错 ——
|
||||
* "压缩前后对比"本来就是一个抽查动作,不是审核的必经步骤。
|
||||
*/
|
||||
defineOptions({ name: 'FilePreview' })
|
||||
|
||||
const props = defineProps<{
|
||||
file: AuditFileItem
|
||||
/** 已经拿到的链接(待办卡片里的 `materialThumbs` 就是现成的签名链接) */
|
||||
url?: string
|
||||
}>()
|
||||
|
||||
type Mode = 'normal' | 'raw' | 'compare'
|
||||
|
||||
const loading = ref(false)
|
||||
const rawLoading = ref(false)
|
||||
|
||||
const src = ref('')
|
||||
const rawSrc = ref('')
|
||||
const showModal = ref(false)
|
||||
const mode = ref<Mode>('normal')
|
||||
|
||||
const fileName = computed(() => props.file.standardName || props.file.originalName || '(无文件名)')
|
||||
|
||||
const sizeText = computed(() => {
|
||||
const f = props.file
|
||||
return f.finalSizeKb ? `${f.finalSizeKb} KB` : '—'
|
||||
})
|
||||
|
||||
const pixelText = computed(() => {
|
||||
const f = props.file
|
||||
if (!f.width || !f.height) {
|
||||
return '—'
|
||||
}
|
||||
return `${f.width}×${f.height}${f.dpi ? ` · ${f.dpi}dpi` : ''}`
|
||||
})
|
||||
|
||||
const validateMeta = computed(() => {
|
||||
const s = props.file.validateStatus
|
||||
if (s === null || s === undefined) {
|
||||
return null
|
||||
}
|
||||
return FILE_VALIDATE[s] ?? null
|
||||
})
|
||||
|
||||
async function loadNormal() {
|
||||
if (props.url) {
|
||||
src.value = props.url
|
||||
return
|
||||
}
|
||||
if (props.file.previewUrl) {
|
||||
// 详情接口已经带了现取的链接,直接用,省一次往返
|
||||
src.value = props.file.previewUrl
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAuditFileUrl(props.file.id)
|
||||
src.value = res?.url ?? ''
|
||||
} catch {
|
||||
// 取不到链接不是致命错误:材料本身还在,只是这次看不了。
|
||||
// 所以只留一个"预览取不到"的占位,不弹错误 —— 审核几十份材料时弹窗会淹没页面。
|
||||
src.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 原始件:每次需要时现取(签名链接会过期,不能复用上一次的) */
|
||||
async function loadRaw() {
|
||||
if (rawLoading.value) {
|
||||
return
|
||||
}
|
||||
rawLoading.value = true
|
||||
try {
|
||||
const res = await getAuditFileUrl(props.file.id, true)
|
||||
rawSrc.value = res?.url ?? ''
|
||||
} catch {
|
||||
// 原始件已被清理(默认保留 30 天)或链接取不到:留空,由界面给说明
|
||||
rawSrc.value = ''
|
||||
} finally {
|
||||
rawLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(mode, (m) => {
|
||||
if ((m === 'raw' || m === 'compare') && !rawSrc.value) {
|
||||
void loadRaw()
|
||||
}
|
||||
})
|
||||
|
||||
async function open() {
|
||||
if (!src.value) {
|
||||
return
|
||||
}
|
||||
showModal.value = true
|
||||
mode.value = 'normal'
|
||||
// 重新打开时把上一次的原始件链接丢掉:它随时可能过期,留着会显示成"坏图"
|
||||
rawSrc.value = ''
|
||||
}
|
||||
|
||||
function openInNewTab() {
|
||||
const target = mode.value === 'raw' ? rawSrc.value : src.value
|
||||
if (target) {
|
||||
window.open(target, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadNormal)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fp">
|
||||
<div class="fp__box" :class="{ 'fp__box--empty': !src && !loading }" @click="open">
|
||||
<NSpin v-if="loading" :size="16" />
|
||||
<img v-else-if="src" :src="src" class="fp__img" alt="材料预览" />
|
||||
<div v-else class="fp__fail">
|
||||
预览取不到<br>(链接过期或已删除)
|
||||
</div>
|
||||
</div>
|
||||
<div class="fp__name" :title="fileName">{{ fileName }}</div>
|
||||
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:style="{ width: '920px' }"
|
||||
:title="fileName"
|
||||
:bordered="false"
|
||||
>
|
||||
<!-- 弹窗顶栏:切换器与动作按钮统一默认尺寸(同一行不要大小混用) -->
|
||||
<div class="fp__bar">
|
||||
<NRadioGroup v-model:value="mode">
|
||||
<NRadioButton value="normal">规范件</NRadioButton>
|
||||
<NRadioButton value="raw">原始件</NRadioButton>
|
||||
<NRadioButton value="compare">并排对比</NRadioButton>
|
||||
</NRadioGroup>
|
||||
<NSpace align="center" :size="6">
|
||||
<NTag size="small" :bordered="false">{{ sizeText }}</NTag>
|
||||
<NTag size="small" :bordered="false">{{ pixelText }}</NTag>
|
||||
<NTag v-if="validateMeta" size="small" :bordered="false" :type="tagType(validateMeta.color)">
|
||||
{{ validateMeta.text }}
|
||||
</NTag>
|
||||
<NButton quaternary @click="openInNewTab">在新标签打开</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'compare'" class="fp__compare">
|
||||
<div class="fp__pane">
|
||||
<div class="fp__pane-head">规范件(交给机构的那一份)</div>
|
||||
<img v-if="src" :src="src" class="fp__big" alt="规范件" />
|
||||
</div>
|
||||
<div class="fp__pane">
|
||||
<div class="fp__pane-head">原始件(学生传上来的那一份)</div>
|
||||
<NSpin v-if="rawLoading" class="fp__spin" :size="20" />
|
||||
<img v-else-if="rawSrc" :src="rawSrc" class="fp__big" alt="原始件" />
|
||||
<div v-else class="fp__missing">
|
||||
原始件没取到。<br>
|
||||
原始件默认只保留 30 天(用于复核与重出),过期后已被清理 ——
|
||||
规范件不受影响,审核按规范件判断即可。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="fp__single">
|
||||
<NSpin v-if="mode === 'raw' && rawLoading" :size="20" />
|
||||
<template v-else-if="mode === 'raw'">
|
||||
<img v-if="rawSrc" :src="rawSrc" class="fp__big" alt="原始件" />
|
||||
<div v-else class="fp__missing">
|
||||
原始件没取到(默认只保留 30 天,过期已清理)。规范件不受影响。
|
||||
</div>
|
||||
</template>
|
||||
<img v-else :src="src" class="fp__big" alt="规范件" />
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fp {
|
||||
width: 108px;
|
||||
}
|
||||
|
||||
/* 缩略图格子:浅灰底 + 弱描边,与外层白格区分开 */
|
||||
.fp__box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.fp__box--empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fp__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.fp__fail {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fp__name {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-2);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fp__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid var(--xt-card-border-weak);
|
||||
}
|
||||
|
||||
.fp__single {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.fp__compare {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fp__pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fp__pane-head {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.fp__big {
|
||||
width: 100%;
|
||||
max-height: 62vh;
|
||||
object-fit: contain;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.fp__missing {
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
color: var(--xt-text-3);
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px dashed var(--xt-card-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.fp__spin {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<div class="fp">
|
||||
<!--
|
||||
七牛空间是**私有**的 → 预览必须用签名链接,而签名链接有有效期(默认 30 分钟),
|
||||
所以这里**每次挂载现取**,不缓存、不入库、不落 localStorage。
|
||||
-->
|
||||
<div class="fp__box" :class="{ 'fp__box--empty': !src && !loading }" @click="open">
|
||||
<a-spin v-if="loading" :size="16" />
|
||||
<img v-else-if="src" :src="src" class="fp__img" alt="材料预览" />
|
||||
<div v-else class="fp__fail">预览取不到<br>(链接过期或已删除)</div>
|
||||
</div>
|
||||
<div class="fp__name" :title="file.standardName || file.originalName || ''">
|
||||
{{ file.standardName || file.originalName || '(无文件名)' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditFileItem } from '@/apis/xt/audit'
|
||||
import { getAuditFileUrl } from '@/apis/xt/audit'
|
||||
|
||||
/**
|
||||
* 材料缩略图(审核端)。
|
||||
*
|
||||
* 为什么单独封一个组件:审核页与待办卡片都要显示缩略图,而"取签名链接"这件事
|
||||
* **不能各写一份** —— 一处忘了换审核端通道(`/audit/files/*`)就会去调学生端的接口,
|
||||
* 那条通道校验的是"这份是不是你传的",审核人必然拿不到。
|
||||
*/
|
||||
defineOptions({ name: 'FilePreviewImage' })
|
||||
|
||||
const props = defineProps<{
|
||||
file: AuditFileItem
|
||||
/** 用一个已经拿到的链接(待办卡片里的 materialThumbs 就是现成的签名链接) */
|
||||
url?: string
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const src = ref<string>(props.url ?? '')
|
||||
|
||||
async function load() {
|
||||
if (props.url) {
|
||||
src.value = props.url
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAuditFileUrl(props.file.id)
|
||||
src.value = res.data?.url ?? ''
|
||||
} catch {
|
||||
// 取不到链接不是致命错误:材料本身还在,只是这次看不了。
|
||||
// 所以只留一个"预览取不到"的占位,不弹错误 —— 审核几十份材料时弹窗会淹没页面。
|
||||
src.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 点开看原图(新标签页)。链接是现场的,所以点了才取。 */
|
||||
async function open() {
|
||||
if (!src.value) {
|
||||
return
|
||||
}
|
||||
window.open(src.value, '_blank')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fp {
|
||||
width: 108px;
|
||||
|
||||
&__box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
background: var(--color-fill-2);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
|
||||
&--empty {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__fail {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.arco-spin) {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
@@ -1,170 +1,21 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>审核</template>
|
||||
<template #extra>
|
||||
<span class="audit__hint">
|
||||
初审由班主任做(只看得到自己带的班),终审由管理员做
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
节点切换用 tab:初审与终审是**同一份数据的两个阶段**,
|
||||
而不是两个功能。做成两个页面的话,用户会以为自己进错了地方。
|
||||
-->
|
||||
<a-tabs v-model:active-key="node" type="rounded" size="small" @change="onNodeChange">
|
||||
<a-tab-pane :key="1" title="初审(班主任 / 审核员)" />
|
||||
<a-tab-pane v-if="canFinal" :key="2" title="终审(管理员)" />
|
||||
</a-tabs>
|
||||
|
||||
<!--
|
||||
四个计数**不受下方的 level 过滤影响**(服务端算的),
|
||||
否则点一下「有异常」就再也回不到「全部」。
|
||||
-->
|
||||
<div class="audit__stats">
|
||||
<div v-for="c in countCards" :key="c.key" class="audit__stat"
|
||||
:class="[`audit__stat--${c.tone}`, levelFilter === c.level && c.level !== undefined ? 'audit__stat--on' : '']"
|
||||
@click="setLevel(c.level)">
|
||||
<div class="audit__stat-num">{{ c.value }}</div>
|
||||
<div class="audit__stat-label">{{ c.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-button type="primary" size="small" :disabled="!selectedKeys.length" :loading="batching"
|
||||
@click="onBatchApprove">
|
||||
<template #icon><IconCheck /></template>
|
||||
批量通过({{ selectedKeys.length }})
|
||||
</a-button>
|
||||
<span class="audit__hint">
|
||||
只能带<strong>无问题</strong>的单;红黄一律自动跳过并说明原因
|
||||
</span>
|
||||
</a-space>
|
||||
|
||||
<a-space wrap>
|
||||
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
|
||||
class="audit__task" :loading="taskLoading" @change="search">
|
||||
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.waitingOverHours" placeholder="等待时长" allow-clear size="small"
|
||||
class="audit__wait" @change="search">
|
||||
<a-option :value="24">超过 24 小时</a-option>
|
||||
<a-option :value="48">超过 48 小时</a-option>
|
||||
<a-option :value="72">超过 72 小时</a-option>
|
||||
</a-select>
|
||||
<a-input-search v-model="query.keyword" placeholder="搜索姓名 / 班级" allow-clear
|
||||
class="audit__search" @search="search" @clear="search" />
|
||||
<a-button size="small" @click="reload">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
列宽按可用宽度算过:1366 宽的屏幕减 200 侧栏 ≈ 1140,本页在卡片里再减内边距,
|
||||
约 1060 可用。下面 8 列合计 1020,不出现横向滚动(钉住的操作列不会盖住「状态」)。
|
||||
-->
|
||||
<a-table :data="list" :loading="loading" row-key="submissionId" :pagination="pagination"
|
||||
:scroll="{ x: 1020 }" :row-selection="{ type: 'checkbox', showCheckedAll: true }"
|
||||
:selected-keys="selectedKeys" @select="onSelect" @select-all="onSelectAll"
|
||||
@page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="学生" :width="170">
|
||||
<template #cell="{ record }">
|
||||
<div class="audit__name">
|
||||
{{ record.realName }}
|
||||
<a-tag v-if="record.rejectCount" color="gray" size="small">
|
||||
驳回 {{ record.rejectCount }} 次
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="audit__muted">{{ record.orgName || '未分班' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="任务" :width="170" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.taskName }}</div>
|
||||
<div class="audit__muted">{{ fmtTime(record.submitTime) }} 提交</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="材料" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<div class="audit__thumbs">
|
||||
<img v-for="(u, i) in (record.materialThumbs ?? []).slice(0, 3)" :key="i" :src="u"
|
||||
class="audit__thumb" alt="材料缩略图" @click.stop="openDetail(record)">
|
||||
<span v-if="!(record.materialThumbs ?? []).length" class="audit__muted">
|
||||
{{ record.materialCount }} 项
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="校验" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="LEVEL_TAG[record.level]?.color ?? 'gray'" size="small">
|
||||
{{ LEVEL_TAG[record.level]?.text ?? '未知' }}
|
||||
</a-tag>
|
||||
<div v-if="record.redSummary" class="audit__red" :title="record.redSummary">
|
||||
{{ record.redSummary }}
|
||||
</div>
|
||||
<div v-else-if="record.redCount || record.yellowCount" class="audit__muted">
|
||||
红 {{ record.redCount }} · 黄 {{ record.yellowCount }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="学生改过" :width="120" align="center">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.changedFields?.length" class="audit__changed">
|
||||
{{ record.changedFields.length }} 项
|
||||
</span>
|
||||
<span v-else class="audit__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="状态" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small">{{ record.statusText }}</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="已等待" :width="90" align="center">
|
||||
<template #cell="{ record }">
|
||||
<span :class="waitTone(record.waitHours)">{{ fmtWait(record.waitHours) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="操作" :width="200" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space size="mini" wrap>
|
||||
<a-link @click="openDetail(record)">审核</a-link>
|
||||
<!--
|
||||
能批量通过的才给「通过」快捷键(服务端算好的 batchApprovable):
|
||||
有红黄的单必须进去看,给了快捷键就等于鼓励手快全选。
|
||||
-->
|
||||
<a-link v-if="record.batchApprovable" status="success" @click="quickApprove(record)">
|
||||
通过
|
||||
</a-link>
|
||||
<a-link status="danger" @click="openReject(record)">驳回</a-link>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<AuditDetailDrawer ref="detailRef" :node="node" @changed="load" />
|
||||
<AuditRejectModal ref="rejectRef" :materials="[]" @rejected="load" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { AuditTodo } from '@/apis/xt/audit'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconCheck, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NInput,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
NTag,
|
||||
useDialog,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref, useTemplateRef } from 'vue'
|
||||
import {
|
||||
approveSubmission,
|
||||
AUDIT_ERROR_HINT,
|
||||
@@ -174,26 +25,34 @@ import {
|
||||
LEVEL_TAG
|
||||
} from '@/apis/xt/audit'
|
||||
import { getTaskList } from '@/apis/xt/task'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
import AuditDetailDrawer from './AuditDetailDrawer.vue'
|
||||
import AuditRejectModal from './AuditRejectModal.vue'
|
||||
|
||||
/**
|
||||
* 审核台(原型 ② / ⑥,契约 §11.1)。
|
||||
*
|
||||
* ## 这一屏最容易做错的三件事
|
||||
* ## 这一屏最容易做错的三件事(照抄上一版,只换了 UI 库)
|
||||
*
|
||||
* 1. **不要在前端按班级过滤**。"权限 ≠ 范围"由服务端保证(SQL 里显式 `1 = 0`),
|
||||
* 前端再过滤一次只会在口径不一致时制造假象 —— 而且前端过滤不了越权。
|
||||
* 所以下面的筛选项里**没有班级**,只有任务 / 等待时长 / 关键字。
|
||||
* 2. **四个计数不能跟着 level 过滤变**。否则点一下「有异常」,其它计数变成 0,
|
||||
* 用户就回不到「全部」了。服务端已经这么算,前端照显示即可。
|
||||
* 用户就回不到「全部」了。愿望是服务端算好的(`counts`),前端照显示即可 ——
|
||||
* 所以 `load()` 里把 `counts` 与 `list` 一起接,**不要**在本地改它。
|
||||
* 3. **批量通过必须逐条说清谁被跳过**。只说"过了 3 个"会让用户反复点那个大按钮。
|
||||
*/
|
||||
defineOptions({ name: 'Audit' })
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const canFinal = computed(() => userStore.permissions.includes('audit:final'))
|
||||
/** 终审是管理员的动作;没有 `audit:final` 就连 tab 都不给(点了必然 40301) */
|
||||
const canFinal = computed(() => userStore.has('audit:final'))
|
||||
|
||||
const node = ref<number>(1)
|
||||
const loading = ref(false)
|
||||
@@ -201,9 +60,10 @@ const batching = ref(false)
|
||||
const list = ref<AuditTodo[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref({ all: 0, clean: 0, abnormal: 0, overTime: 0 })
|
||||
const selectedKeys = ref<number[]>([])
|
||||
/** 勾选的填报单 id(naive 的 row-key 是数字,勾选键集合也是数字) */
|
||||
const checkedKeys = ref<number[]>([])
|
||||
const levelFilter = ref<number | undefined>(undefined)
|
||||
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
|
||||
const taskOptions = ref<Array<{ label: string, value: number }>>([])
|
||||
const taskLoading = ref(false)
|
||||
|
||||
const query = reactive({
|
||||
@@ -214,9 +74,38 @@ const query = reactive({
|
||||
waitingOverHours: undefined as number | undefined
|
||||
})
|
||||
|
||||
/** 等待时长的三个档位(写在这里而不是模板里:模板里的多行字面量会被 vue/comma-dangle 要求补尾逗号) */
|
||||
const WAIT_OPTIONS = [
|
||||
{ label: '超过 24 小时', value: 24 },
|
||||
{ label: '超过 48 小时', value: 48 },
|
||||
{ label: '超过 72 小时', value: 72 }
|
||||
]
|
||||
|
||||
const detailRef = useTemplateRef('detailRef')
|
||||
const rejectRef = useTemplateRef('rejectRef')
|
||||
|
||||
/**
|
||||
* `n-card` 的内容区要变成"能撑满的 flex 列"(高度链的中间一环)。
|
||||
*
|
||||
* ⚠️ 曾经用 `:deep(.n-card__content)` 写这条,**但那是个不存在的类名**:
|
||||
* naive-ui 2.45.3 的内容区是 `.n-card-content`(`Card.mjs` 用 `cB("card-content")`)。
|
||||
* 选择器匹配不到 → 内容区仍是 `display: block` → `.page-card__body` 的 `flex: 1` 无处可长
|
||||
* → 表格只有 81px 高、分页器浮在卡片中间(**不报错**,只是"看起来没贴底")。
|
||||
* 所以这里改用 `content-style` 直接写在元素上,不依赖类名。
|
||||
*/
|
||||
const contentStyle: CSSProperties = {
|
||||
flex: '1 1 auto',
|
||||
minHeight: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态色词由 `@/utils/tag` 统一转换(接口里的 `color` 是 Arco 色词,
|
||||
* 直接给 `n-tag` 会静默变灰 —— 见那个文件的注释)。
|
||||
*/
|
||||
|
||||
const countCards = computed(() => [
|
||||
{ key: 'all', label: '全部待办', value: counts.value.all, tone: 'blue', level: undefined as number | undefined },
|
||||
{ key: 'clean', label: '无问题', value: counts.value.clean, tone: 'green', level: 0 },
|
||||
@@ -224,46 +113,153 @@ const countCards = computed(() => [
|
||||
{ key: 'overTime', label: '已超时', value: counts.value.overTime, tone: 'red', level: undefined }
|
||||
])
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
/** 分页器:`remote` 模式下 naive 不会自己切数据,"每页条数"必须重新取数(见 composables/pagination) */
|
||||
const pagination = computed(() => listPagination({
|
||||
page: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
itemCount: total.value
|
||||
}))
|
||||
|
||||
function fmtTime(v: string | null) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function fmtWait(h: number | null) {
|
||||
if (h === null || h === undefined) {
|
||||
function fmtWait(hours: number | null) {
|
||||
if (hours === null || hours === undefined) {
|
||||
return '—'
|
||||
}
|
||||
if (h < 24) {
|
||||
return `${h} 小时`
|
||||
if (hours < 24) {
|
||||
return `${hours} 小时`
|
||||
}
|
||||
return `${Math.floor(h / 24)} 天`
|
||||
return `${Math.floor(hours / 24)} 天`
|
||||
}
|
||||
|
||||
/** 等得越久越显眼:超过 48 小时变红 —— 超时是这一屏唯一要主动推的事 */
|
||||
function waitTone(h: number | null) {
|
||||
if (h === null || h === undefined) {
|
||||
function waitClass(hours: number | null) {
|
||||
if (hours === null || hours === undefined) {
|
||||
return 'audit__muted'
|
||||
}
|
||||
if (h >= 48) {
|
||||
if (hours >= 48) {
|
||||
return 'audit__wait--bad'
|
||||
}
|
||||
if (h >= 24) {
|
||||
if (hours >= 24) {
|
||||
return 'audit__wait--warn'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<AuditTodo>>(() => [
|
||||
// `multiple: true` 显式写出来:naive 的默认值是 undefined(渲染成复选框),
|
||||
// 但写成 `false` 会变成单选 —— 批量通过就没法用了,所以不靠默认值。
|
||||
{ type: 'selection', multiple: true },
|
||||
{
|
||||
title: '学生',
|
||||
key: 'realName',
|
||||
width: 170,
|
||||
render: (row) => h('div', [
|
||||
h('div', { class: 'audit__name' }, [
|
||||
row.realName,
|
||||
row.rejectCount
|
||||
? h(NTag, { size: 'small', bordered: false, style: 'margin-left:6px' }, { default: () => `驳回 ${row.rejectCount} 次` })
|
||||
: null
|
||||
]),
|
||||
h('div', { class: 'audit__muted' }, row.orgName || '未分班')
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '任务',
|
||||
key: 'taskName',
|
||||
width: 170,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => h('div', [
|
||||
h('div', row.taskName),
|
||||
h('div', { class: 'audit__muted' }, `${fmtTime(row.submitTime)} 提交`)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '材料',
|
||||
key: 'materialThumbs',
|
||||
width: 150,
|
||||
render: (row) => {
|
||||
const thumbs = (row.materialThumbs ?? []).slice(0, 3)
|
||||
if (!thumbs.length) {
|
||||
return h('span', { class: 'audit__muted' }, `${row.materialCount} 项`)
|
||||
}
|
||||
return h('div', { class: 'audit__thumbs' }, thumbs.map((u, i) => h('img', {
|
||||
key: i,
|
||||
src: u,
|
||||
class: 'audit__thumb',
|
||||
alt: '材料缩略图',
|
||||
onClick: (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
openDetail(row)
|
||||
}
|
||||
})))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '校验',
|
||||
key: 'level',
|
||||
width: 130,
|
||||
render: (row) => {
|
||||
const meta = tagOf(LEVEL_TAG, row.level, '未知')
|
||||
return h('div', [
|
||||
h(NTag, { size: 'small', bordered: false, type: meta.type }, { default: () => meta.text }),
|
||||
row.redSummary
|
||||
? h('div', { class: 'audit__red', title: row.redSummary }, row.redSummary)
|
||||
: (row.redCount || row.yellowCount
|
||||
? h('div', { class: 'audit__muted' }, `红 ${row.redCount} · 黄 ${row.yellowCount}`)
|
||||
: null)
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '学生改过',
|
||||
key: 'changedFields',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (row) => (row.changedFields?.length
|
||||
? h('span', { class: 'audit__changed' }, `${row.changedFields.length} 项`)
|
||||
: h('span', { class: 'audit__muted' }, '—'))
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'statusText',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false }, { default: () => row.statusText })
|
||||
},
|
||||
{
|
||||
title: '已等待',
|
||||
key: 'waitHours',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (row) => h('span', { class: waitClass(row.waitHours) }, fmtWait(row.waitHours))
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 190,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
render: (row) => h(NSpace, { size: 12, wrap: true, justify: 'center' }, {
|
||||
default: () => [
|
||||
h(NButton, { text: true, size: 'small', onClick: () => openDetail(row) }, { default: () => '审核' }),
|
||||
// 能批量通过的才给「通过」快捷键(服务端算好的 `batchApprovable`):
|
||||
// 有红黄的单必须进去看,给了快捷键就等于鼓励手快全选。
|
||||
row.batchApprovable
|
||||
? h(NButton, { text: true, size: 'small', type: 'primary', onClick: () => quickApprove(row) }, { default: () => '通过' })
|
||||
: null,
|
||||
h(NButton, { text: true, size: 'small', type: 'error', onClick: () => openReject(row) }, { default: () => '驳回' })
|
||||
]
|
||||
})
|
||||
}
|
||||
])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAuditTodos({
|
||||
const page = await getAuditTodos({
|
||||
node: node.value,
|
||||
taskId: query.taskId,
|
||||
level: levelFilter.value,
|
||||
@@ -272,9 +268,9 @@ async function load() {
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
list.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
counts.value = res.data?.counts ?? { all: 0, clean: 0, abnormal: 0, overTime: 0 }
|
||||
list.value = page?.list ?? []
|
||||
total.value = page?.total ?? 0
|
||||
counts.value = page?.counts ?? { all: 0, clean: 0, abnormal: 0, overTime: 0 }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -283,8 +279,8 @@ async function load() {
|
||||
async function loadTasks() {
|
||||
taskLoading.value = true
|
||||
try {
|
||||
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
|
||||
const page = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (page?.list ?? []).map((t) => ({ label: t.taskName, value: t.id }))
|
||||
} finally {
|
||||
taskLoading.value = false
|
||||
}
|
||||
@@ -292,13 +288,14 @@ async function loadTasks() {
|
||||
|
||||
function search() {
|
||||
query.pageNum = 1
|
||||
selectedKeys.value = []
|
||||
checkedKeys.value = []
|
||||
load()
|
||||
}
|
||||
|
||||
function onNodeChange() {
|
||||
// 换节点等于换一份待办:分级过滤与勾选都要清掉(否则会把初审的勾选带到终审)
|
||||
levelFilter.value = undefined
|
||||
selectedKeys.value = []
|
||||
checkedKeys.value = []
|
||||
search()
|
||||
}
|
||||
|
||||
@@ -317,12 +314,21 @@ function onPageChange(page: number) {
|
||||
load()
|
||||
}
|
||||
|
||||
function onSelect(keys: (string | number)[]) {
|
||||
selectedKeys.value = keys as number[]
|
||||
/** 勾选变化(用命名函数而不是模板内联赋值:内联写 `checkedKeys = keys` 依赖 ref 解包,读起来也绕) */
|
||||
function onCheckedChange(keys: Array<string | number>) {
|
||||
checkedKeys.value = keys as number[]
|
||||
}
|
||||
|
||||
function onSelectAll(checked: boolean) {
|
||||
selectedKeys.value = checked ? list.value.map((r) => r.submissionId) : []
|
||||
/**
|
||||
* 改「每页条数」必须重新取数。
|
||||
*
|
||||
* ⚠️ 上一版踩过:只改本地的 pageSize 而不重新请求,于是分页器说 50 条/页、
|
||||
* 表里还是 20 条 —— 看起来像表格坏了。`remote` 模式下这也是唯一正确的做法。
|
||||
*/
|
||||
function onPageSizeChange(size: number) {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function openDetail(row: AuditTodo) {
|
||||
@@ -333,49 +339,49 @@ function openReject(row: AuditTodo) {
|
||||
rejectRef.value?.open(row.submissionId)
|
||||
}
|
||||
|
||||
/** 列表里的快捷通过:进详情再点的动作这里省掉,但只对"无问题"的单开放 */
|
||||
/** 列表里的快捷通过:只对"无问题"的单开放(红黄项服务端也会拒,见 40032) */
|
||||
async function quickApprove(row: AuditTodo) {
|
||||
try {
|
||||
const res = await approveSubmission(row.submissionId, { actionSource: 'BUTTON' })
|
||||
Message.success(res.data?.replayed ? '这一单刚才已经审过了' : '已通过')
|
||||
message.success(res?.replayed ? '这一单刚才已经审过了' : '已通过')
|
||||
search()
|
||||
} catch (e) {
|
||||
const code = bizCodeOf(e)
|
||||
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||
Message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||
message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function onBatchApprove() {
|
||||
const ids = selectedKeys.value
|
||||
const ids = checkedKeys.value
|
||||
if (!ids.length) {
|
||||
Message.warning('请先勾选要通过的单子')
|
||||
message.warning('请先勾选要通过的单子')
|
||||
return
|
||||
}
|
||||
batching.value = true
|
||||
try {
|
||||
const res = await batchApprove(ids)
|
||||
const r = res.data
|
||||
const skipped = r?.details?.filter((d) => d.skipped) ?? []
|
||||
const skipped = (res?.details ?? []).filter((d) => d.skipped)
|
||||
if (!skipped.length) {
|
||||
Message.success(`已通过 ${r?.approved ?? 0} 份`)
|
||||
message.success(`已通过 ${res?.approved ?? 0} 份`)
|
||||
} else {
|
||||
// 逐条列出被跳过的原因 —— 这是"用户下次能不能自己判断"的关键。
|
||||
// ⚠️ 这里用 <pre> 纯文本而不是富文本:本页是 `lang="ts"` 的 SFC,
|
||||
// 在里面写 JSX 会直接编译失败(历史上踩过),而为了一个弹窗改成 tsx 不值得。
|
||||
// 用 `pre-wrap` 的纯文本而不是富文本:一个弹窗不值得引入 JSX
|
||||
// (本仓库的 SFC 是 `lang="ts"`,写 JSX 会直接编译失败,历史上踩过)。
|
||||
const lines = skipped.map((d) => `· ${d.realName || `#${d.submissionId}`}:${d.reason || '不符合批量通过条件'}`)
|
||||
Modal.info({
|
||||
title: `通过 ${r?.approved ?? 0} 份,跳过 ${skipped.length} 份`,
|
||||
content: `下面这些没有被通过:\n\n${lines.join('\n')}`,
|
||||
hideCancel: true
|
||||
const skippedText = `下面这些没有被通过:\n\n${lines.join('\n')}`
|
||||
dialog.warning({
|
||||
title: `通过 ${res?.approved ?? 0} 份,跳过 ${skipped.length} 份`,
|
||||
content: () => h('div', { style: 'white-space: pre-wrap; line-height: 1.8' }, skippedText),
|
||||
positiveText: '知道了'
|
||||
})
|
||||
}
|
||||
selectedKeys.value = []
|
||||
checkedKeys.value = []
|
||||
await load()
|
||||
} catch (e) {
|
||||
const code = bizCodeOf(e)
|
||||
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||
Message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||
message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||
} finally {
|
||||
batching.value = false
|
||||
}
|
||||
@@ -392,124 +398,256 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.audit {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<template>
|
||||
<NCard class="page-card audit" :bordered="false" title="审核" :content-style="contentStyle">
|
||||
<template #header-extra>
|
||||
<span class="toolbar__hint">
|
||||
初审由班主任做(只看得到自己带的班),终审由学校管理员做
|
||||
</span>
|
||||
</template>
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
<!--
|
||||
节点切换用 tab:初审与终审是**同一份数据的两个阶段**,
|
||||
而不是两个功能。做成两个页面的话,用户会以为自己进错了地方。
|
||||
-->
|
||||
<NTabs v-model:value="node" type="segment" class="audit__tabs" @update:value="onNodeChange">
|
||||
<!-- ⚠️ 用 `#tab` 插槽而不是 `tab` 属性,否则标签一个字都不显示(原因见 task/index.vue 同处注释) -->
|
||||
<NTabPane :name="1">
|
||||
<template #tab>
|
||||
初审(班主任)
|
||||
</template>
|
||||
</NTabPane>
|
||||
<NTabPane v-if="canFinal" :name="2">
|
||||
<template #tab>
|
||||
终审(学校管理员)
|
||||
</template>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
|
||||
&__stat {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
<!-- 四个计数由服务端算,**不随下面的 level 过滤变化**(否则点一下「有异常」就回不到「全部」) -->
|
||||
<div class="audit__stats">
|
||||
<div
|
||||
v-for="c in countCards"
|
||||
:key="c.key"
|
||||
class="audit__stat"
|
||||
:class="{ 'audit__stat--on': c.level !== undefined && levelFilter === c.level }"
|
||||
@click="setLevel(c.level)"
|
||||
>
|
||||
<div class="audit__stat-num" :class="`audit__num--${c.tone}`">{{ c.value }}</div>
|
||||
<div class="audit__stat-label">{{ c.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border-2);
|
||||
}
|
||||
<!--
|
||||
工具条:主操作在左、筛选在右。**同一行所有控件统一默认尺寸** ——
|
||||
之前全排 `size="small"`,28px 的白底小按钮贴在白底表格上几乎看不出是按钮。
|
||||
-->
|
||||
<div class="toolbar">
|
||||
<NSpace align="center" :size="8">
|
||||
<NButton type="primary" :disabled="!checkedKeys.length" :loading="batching" @click="onBatchApprove">
|
||||
<template #icon>
|
||||
<AppIcon name="check" :size="16" />
|
||||
</template>
|
||||
批量通过({{ checkedKeys.length }})
|
||||
</NButton>
|
||||
<span class="toolbar__hint">
|
||||
只能带<strong>无问题</strong>的单;红黄一律自动跳过并说明原因
|
||||
</span>
|
||||
</NSpace>
|
||||
|
||||
&--on {
|
||||
border-color: rgb(var(--primary-6));
|
||||
box-shadow: 0 0 0 2px rgb(var(--primary-1));
|
||||
}
|
||||
<NSpace align="center" :size="8">
|
||||
<NSelect
|
||||
v-model:value="query.taskId"
|
||||
class="audit__task"
|
||||
clearable
|
||||
placeholder="全部任务"
|
||||
:loading="taskLoading"
|
||||
:options="taskOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.waitingOverHours"
|
||||
class="audit__wait"
|
||||
clearable
|
||||
placeholder="等待时长"
|
||||
:options="WAIT_OPTIONS"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="query.keyword"
|
||||
class="audit__search"
|
||||
clearable
|
||||
placeholder="搜索姓名 / 班级"
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<NButton @click="search">
|
||||
<template #icon>
|
||||
<AppIcon name="search" :size="16" />
|
||||
</template>
|
||||
搜索
|
||||
</NButton>
|
||||
<NButton @click="reload">
|
||||
<template #icon>
|
||||
<AppIcon name="refresh" :size="16" />
|
||||
</template>
|
||||
刷新
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
&--blue .audit__stat-num {
|
||||
color: rgb(var(--blue-6));
|
||||
}
|
||||
<div class="page-card__body audit__body">
|
||||
<!--
|
||||
尺寸口径(Naive 与 Arco 不同,照抄 Arco 的 `:scroll="{ x, y }"` 会**被静默忽略**):
|
||||
· 横向:`:scroll-x="1140"`(数值,8 列合计宽 + 勾选列);
|
||||
· 纵向:`flex-height` 撑满 `.page-card__body`(高度链见 styles/index.scss 的 .page-card 注释),
|
||||
这样表体自己滚、分页器钉在卡片底部 —— 上一版在 Arco 上踩过"分页器被裁在可视区外"。
|
||||
-->
|
||||
<NDataTable
|
||||
remote
|
||||
flex-height
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="(row: AuditTodo) => row.submissionId"
|
||||
:checked-row-keys="checkedKeys"
|
||||
:pagination="pagination"
|
||||
:scroll-x="1140"
|
||||
@update:checked-row-keys="onCheckedChange"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
&--green .audit__stat-num {
|
||||
color: rgb(var(--green-6));
|
||||
}
|
||||
<AuditDetailDrawer ref="detailRef" :node="node" @changed="load" />
|
||||
<AuditRejectModal ref="rejectRef" :materials="[]" @rejected="load" />
|
||||
</template>
|
||||
|
||||
&--orange .audit__stat-num {
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
<style scoped>
|
||||
/**
|
||||
* 高度链的中间一环由 `contentStyle` 写在卡片内容元素上(原因见 script 里那段说明)。
|
||||
* 这里只负责"内容区里的固定区块不被挤压":tabs / 计数格 / 工具条都是 `flex: none`,
|
||||
* 剩下的空间全给表格 —— 分页器才钉得住卡片底部。
|
||||
*/
|
||||
.audit__tabs {
|
||||
flex: none;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&--red .audit__stat-num {
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
}
|
||||
/* 四个计数格:卡片**内部**的小格子,用浅灰底 + 弱描边,而不是第二层白卡片 */
|
||||
.audit__stats {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.audit__stat {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.audit__stat:hover {
|
||||
border-color: #d0d5dd;
|
||||
}
|
||||
|
||||
&__task {
|
||||
width: 180px;
|
||||
}
|
||||
.audit__stat--on {
|
||||
border-color: #0052d9;
|
||||
box-shadow: 0 0 0 2px rgb(0 82 217 / 12%);
|
||||
}
|
||||
|
||||
&__wait {
|
||||
width: 130px;
|
||||
}
|
||||
.audit__stat-num {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__search {
|
||||
width: 200px;
|
||||
}
|
||||
.audit__num--blue {
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.audit__num--green {
|
||||
color: #00a870;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.audit__num--orange {
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
&__thumbs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.audit__num--red {
|
||||
color: #d54941;
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: zoom-in;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.audit__stat-label {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
&__red {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--red-6));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.audit__task {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
&__changed {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
.audit__wait {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
&__wait {
|
||||
&--warn {
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
.audit__search {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
&--bad {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
}
|
||||
/* ⚠️ 下面的类都出现在**渲染函数**里(表格列),渲染函数生成的元素不带 `data-v-*`,
|
||||
写成 `.audit__muted` 这种普通 scoped 选择器**匹配不到**(不报错,只是样式全丢) */
|
||||
:deep(.audit__name) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.audit__muted) {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
:deep(.audit__thumbs) {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.audit__thumb) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: zoom-in;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(.audit__red) {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: #d54941;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.audit__changed) {
|
||||
font-weight: 600;
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
:deep(.audit__wait--warn) {
|
||||
color: #ed7b2f;
|
||||
}
|
||||
|
||||
:deep(.audit__wait--bad) {
|
||||
font-weight: 600;
|
||||
color: #d54941;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,693 +0,0 @@
|
||||
<template>
|
||||
<GiPageLayout margin collapsed>
|
||||
<a-row justify="space-between" class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<!-- ⚠️ GiButton **没有 `text` prop**:文案要么用 type 的预设(新增/编辑/…),
|
||||
要么用**默认插槽**。写成 `text="..."` 不会报错,但页面上显示的是预设文案 -->
|
||||
<GiButton type="upload" @click="openCreate">上传压缩包整理</GiButton>
|
||||
<a-button @click="loadRecords">刷新</a-button>
|
||||
</a-space>
|
||||
<a-space wrap>
|
||||
<a-input-search
|
||||
v-model="query.keyword"
|
||||
placeholder="按整理任务名称搜索"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
@search="onSearch"
|
||||
/>
|
||||
<a-select
|
||||
v-model="query.status"
|
||||
placeholder="全部状态"
|
||||
allow-clear
|
||||
style="width: 140px"
|
||||
:options="STATUS_OPTIONS"
|
||||
@change="onSearch"
|
||||
/>
|
||||
<a-button @click="onReset">重置</a-button>
|
||||
</a-space>
|
||||
</a-row>
|
||||
|
||||
<a-table
|
||||
:data="records"
|
||||
:loading="listLoading"
|
||||
row-key="id"
|
||||
:pagination="pagination"
|
||||
:bordered="{ cell: true }"
|
||||
:scroll="{ x: 1100 }"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="序号" :width="68" align="center">
|
||||
<template #cell="{ rowIndex }">{{ rowIndex + 1 }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="整理任务" data-index="jobName" :width="240" ellipsis tooltip />
|
||||
<a-table-column title="状态" :width="110" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="statusColor(record.status)">{{ record.statusText }}</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="进度" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.status === 3">{{ record.totalFiles ?? 0 }} 个文件</span>
|
||||
<span v-else-if="record.status === 4" class="cl-dim">—</span>
|
||||
<a-progress v-else :percent="(record.progress || 0) / 100" size="small" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="压缩率" :width="100" align="center">
|
||||
<!--
|
||||
⚠️ `—` 有**两个**含义,都不要替换成 0%:
|
||||
① 还没算出来(任务在跑);
|
||||
② 这个包**本来就不适用压缩** —— 里面的文件已经是压过的(jpg),
|
||||
或总量太小,重新打 zip 的包头开销比省下来的还多。
|
||||
实测 3 个 40 字节的小文件:raw=120B、final=802B,
|
||||
按公式算是 `-568.3%` —— **负数压缩率是没有意义的**,
|
||||
所以服务端也回 `—`(同一个符号,都是"这个数说明不了什么")。
|
||||
-->
|
||||
<template #cell="{ record }">{{ record.compressRatio || '—' }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="结果" :width="110" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag v-if="record.status === 3 && record.targetTaskId" color="success">已导入</a-tag>
|
||||
<a-tag v-else-if="record.status === 3" color="arcoblue">待导入</a-tag>
|
||||
<a-tag v-else-if="record.status === 4" color="danger">失败</a-tag>
|
||||
<span v-else class="cl-dim">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" data-index="createTime" :width="170" />
|
||||
<a-table-column title="操作" :width="90" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-button type="text" size="mini" @click="openDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!--
|
||||
上传弹窗。**刻意先选文件、再填名字**:用户的动作顺序是"我有个包要整理",
|
||||
而"整理任务名叫什么"是他看到包之后才想的事。
|
||||
-->
|
||||
<a-modal
|
||||
v-model:visible="createVisible"
|
||||
title="上传压缩包整理"
|
||||
:mask-closable="false"
|
||||
:ok-loading="creating"
|
||||
ok-text="开始整理"
|
||||
@before-ok="submitCreate"
|
||||
@close="resetCreate"
|
||||
>
|
||||
<a-form :model="createForm" layout="vertical">
|
||||
<a-form-item field="file" label="压缩包(zip)" required>
|
||||
<a-upload :auto-upload="false" :limit="1" accept=".zip" @change="onFileChange" />
|
||||
<template #extra>
|
||||
<div class="cl-tip">
|
||||
包里单个文件不超过 50MB、总量不超过 2GB;macOS 的
|
||||
<code>__MACOSX</code> 会被自动忽略(否则报告里的文件数会虚高)。
|
||||
</div>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="jobName" label="整理任务名称" required>
|
||||
<a-input v-model="createForm.jobName" placeholder="如 24中技数控1班-历史整理" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="rosterTaskId" label="关联到已有任务的名册">
|
||||
<a-select
|
||||
v-model="createForm.rosterTaskId"
|
||||
placeholder="不关联也能跑,但会少两项关键检查"
|
||||
allow-clear
|
||||
allow-search
|
||||
:loading="taskLoading"
|
||||
:options="taskOptions"
|
||||
/>
|
||||
<!--
|
||||
⚠️ 这句是这一屏最该说清的话(契约 §13.2 的原话):
|
||||
没有名册就只能做文件名规范化;**有名单才能做证件号校验、疑似串档检测** ——
|
||||
而"疑似串档 1 例"正是报告里最有价值的一条。
|
||||
不说清的话,用户会在报告里看到那两项是 0,然后以为功能没做。
|
||||
-->
|
||||
<template #extra>
|
||||
<div class="cl-tip">
|
||||
<strong>有关联才有证件号校验与串档检测</strong> ——
|
||||
「疑似串档 1 例」是体检报告里最有价值的一条,而没有名册就无从比对。
|
||||
</div>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 详情:进度 / 报告 / 导入 -->
|
||||
<a-drawer
|
||||
v-model:visible="detailVisible"
|
||||
:width="760"
|
||||
:title="detail?.jobName || '整理详情'"
|
||||
:footer="false"
|
||||
unmount-on-close
|
||||
@close="stopPolling"
|
||||
>
|
||||
<a-spin :loading="detailLoading" style="display: block">
|
||||
<template v-if="detail">
|
||||
<a-descriptions :column="2" bordered size="medium">
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="statusColor(detail.status)">{{ detail.statusText }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="进度">{{ detail.progress }}%</a-descriptions-item>
|
||||
<a-descriptions-item label="文件数">{{ detail.totalFiles ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="压缩率">
|
||||
{{ detail.compressRatio || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="解析出学员">
|
||||
{{ detail.parsedStudents ?? '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="材料种类">
|
||||
{{ detail.parsedMaterials ?? '—' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!--
|
||||
失败时**必须把 errorMsg 原样显示**:它是唯一能让用户知道"为什么"的东西。
|
||||
常见原因(不是 zip、单文件超 50MB)服务端都有明确文案,
|
||||
换成笼统的"整理失败"等于把已经写好的解释扔掉。
|
||||
-->
|
||||
<a-alert v-if="detail.status === 4" type="error" class="cl-alert" title="整理失败">
|
||||
{{ detail.errorMsg || '服务端没有返回原因,请联系管理员查日志' }}
|
||||
</a-alert>
|
||||
|
||||
<a-progress
|
||||
v-else-if="detail.status !== 3"
|
||||
:percent="(detail.progress || 0) / 100"
|
||||
class="cl-alert"
|
||||
animation
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="report" class="cl-headline">
|
||||
<div class="cl-headline__main">{{ headlineOf(report) }}</div>
|
||||
<div class="cl-headline__sub">
|
||||
这一句是报告的全部意义:<strong>系统做了什么</strong>,以及
|
||||
<strong>还剩多少要人判断</strong>。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-row v-if="report" :gutter="12" class="cl-stats">
|
||||
<a-col v-for="s in reportStats" :key="s.label" :span="6">
|
||||
<div class="cl-stat" :class="{ 'cl-stat--warn': s.warn }">
|
||||
<div class="cl-stat__num">{{ s.value }}</div>
|
||||
<div class="cl-stat__label">{{ s.label }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider>重点({{ (report?.highlights || []).length }})</a-divider>
|
||||
<a-empty v-if="!(report?.highlights || []).length" description="没有需要人工处理的" />
|
||||
<div v-for="(h, i) in report?.highlights || []" :key="i" class="cl-hl">
|
||||
<a-space>
|
||||
<a-tag :color="levelColor(h.level)">
|
||||
{{ h.level >= 3 ? '必须人工' : h.level === 2 ? '警告' : '提示' }}
|
||||
</a-tag>
|
||||
<strong>{{ h.title }}</strong>
|
||||
<span v-if="h.realName" class="cl-hl__who">{{ h.realName }}</span>
|
||||
</a-space>
|
||||
<div class="cl-hl__detail">{{ h.detail }}</div>
|
||||
<div v-if="h.suggestion" class="cl-hl__suggest">建议:{{ h.suggestion }}</div>
|
||||
</div>
|
||||
|
||||
<a-divider>系统改过的文件名(样例)</a-divider>
|
||||
<a-empty v-if="!(report?.nameFixedSamples || []).length" description="没有需要改名的" />
|
||||
<a-table
|
||||
v-else
|
||||
:data="report?.nameFixedSamples || []"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:bordered="{ cell: true }"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="原名" data-index="original" ellipsis tooltip />
|
||||
<a-table-column title="改成" data-index="fixed" ellipsis tooltip />
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-divider>证件号冲突</a-divider>
|
||||
<a-empty v-if="!(report?.idcardConflicts || []).length" description="没有证件号冲突" />
|
||||
<a-table
|
||||
v-else
|
||||
:data="report?.idcardConflicts || []"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:bordered="{ cell: true }"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="姓名" data-index="realName" :width="100" />
|
||||
<a-table-column title="名册上的" data-index="rosterValue" :width="160" />
|
||||
<a-table-column title="文件名里的" data-index="fileNameValue" :width="160" />
|
||||
<a-table-column title="判定" data-index="verdictText" ellipsis tooltip />
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
</template>
|
||||
</a-spin>
|
||||
|
||||
<div v-if="detail" class="cl-footer">
|
||||
<a-space>
|
||||
<a-button v-if="detail.status === 3" type="primary" @click="openImport">
|
||||
导入为正式任务
|
||||
</a-button>
|
||||
<a-button v-if="detail.status === 3" :loading="downloading" @click="downloadResult">
|
||||
下载整理结果
|
||||
</a-button>
|
||||
<a-button @click="refresh">刷新</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 导入为正式任务 -->
|
||||
<a-modal
|
||||
v-model:visible="importVisible"
|
||||
title="导入为正式任务"
|
||||
:mask-closable="false"
|
||||
:ok-loading="importing"
|
||||
ok-text="导入"
|
||||
@before-ok="submitImport"
|
||||
>
|
||||
<a-alert type="normal" class="cl-alert">
|
||||
导入会建一个<strong>草稿</strong>任务,并把解析出的学员建成名册。
|
||||
<br />
|
||||
<strong>不会导入文件</strong>:整理结果里的材料要让学生/老师重新传上来
|
||||
—— 一期还没有「把外部文件挂到指定填报单」的入口。
|
||||
</a-alert>
|
||||
<a-form :model="importForm" layout="vertical">
|
||||
<a-form-item field="taskName" label="新任务名称" required>
|
||||
<a-input v-model="importForm.taskName" />
|
||||
</a-form-item>
|
||||
<a-form-item field="batchNo" label="批次号" required>
|
||||
<a-input v-model="importForm.batchNo" placeholder="如 202609" />
|
||||
</a-form-item>
|
||||
<a-form-item field="majorName" label="专业名称">
|
||||
<a-input v-model="importForm.majorName" placeholder="留空则从关联名册继承" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CleanupJobVO, CleanupReportVO } from '@/apis/xt/cleanup'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { computed, onUnmounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
|
||||
createCleanupJob,
|
||||
downloadCleanupResult,
|
||||
getCleanupJob,
|
||||
getCleanupJobs,
|
||||
getCleanupReport,
|
||||
headlineOf,
|
||||
importCleanupJob,
|
||||
levelColor,
|
||||
statusColor
|
||||
} from '@/apis/xt/cleanup'
|
||||
import { getTaskList } from '@/apis/xt/task'
|
||||
import { GiButton, GiPageLayout } from '@/components/index'
|
||||
|
||||
defineOptions({ name: 'SettingCleanup' })
|
||||
|
||||
// ⚠️ 这里用 `a-table + records + 手写分页`,**不用 Gi 模板的 `useTable`**:
|
||||
// `useTable` 认的是 Gi 的 `{ records, total }`,而我们的契约为 `{ list, total }`。
|
||||
// 项目里其它 XT 页面(导出中心、审核台…)都是这个写法 —— 保持一致,
|
||||
// 免得同一个项目里出现两套列表加载方式。
|
||||
//
|
||||
// ⚠️ 分页对象**自己 computed**,不用 Gi 的 `usePagination()`:那个 hook 的第一个参数是
|
||||
// "翻页时重新加载"的回调,而我们已经用 `@page-change` 在重新加载 ——
|
||||
// 两处都触发会让一次翻页打两次接口(同一个页码打两次)。
|
||||
// 这个 computed 的写法与 `views/export/index.vue` 一致。
|
||||
|
||||
const records = ref<CleanupJobVO[]>([])
|
||||
const total = ref(0)
|
||||
const listLoading = ref(false)
|
||||
const query = reactive<{ pageNum: number, pageSize: number, keyword: string, status?: number }>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
status: undefined
|
||||
})
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: '排队中', value: 0 },
|
||||
{ label: '解析中', value: 1 },
|
||||
{ label: '处理中', value: 2 },
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '失败', value: 4 }
|
||||
]
|
||||
|
||||
async function loadRecords() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await getCleanupJobs({
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status
|
||||
})
|
||||
records.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
|
||||
function onSearch() {
|
||||
query.pageNum = 1
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
query.keyword = ''
|
||||
query.status = undefined
|
||||
onSearch()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
loadRecords()
|
||||
|
||||
// ── 上传 ────────────────────────────────────────
|
||||
|
||||
const createVisible = ref(false)
|
||||
const creating = ref(false)
|
||||
const createForm = reactive<{
|
||||
file: File | null
|
||||
jobName: string
|
||||
rosterTaskId?: number
|
||||
}>({ file: null, jobName: '', rosterTaskId: undefined })
|
||||
|
||||
const taskLoading = ref(false)
|
||||
const taskOptions = ref<{ label: string, value: number }[]>([])
|
||||
|
||||
function openCreate() {
|
||||
createVisible.value = true
|
||||
if (!taskOptions.value.length) {
|
||||
taskLoading.value = true
|
||||
getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
.then((res) => {
|
||||
taskOptions.value = (res.data?.list || []).map((t) => ({
|
||||
label: t.taskName,
|
||||
value: t.id
|
||||
}))
|
||||
})
|
||||
.finally(() => {
|
||||
taskLoading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreate() {
|
||||
createForm.file = null
|
||||
createForm.jobName = ''
|
||||
createForm.rosterTaskId = undefined
|
||||
}
|
||||
|
||||
/** `auto-upload=false` 时 a-upload 给的是 fileList,里面 `file` 才是真正的 File。 */
|
||||
function onFileChange(_: unknown, currentFile: { file?: File }) {
|
||||
createForm.file = currentFile?.file || null
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.file) {
|
||||
Message.warning('请先选择一个 zip 压缩包')
|
||||
return false
|
||||
}
|
||||
if (!createForm.jobName.trim()) {
|
||||
Message.warning('请填写整理任务名称')
|
||||
return false
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await createCleanupJob(createForm.file, {
|
||||
jobName: createForm.jobName.trim(),
|
||||
rosterTaskId: createForm.rosterTaskId
|
||||
})
|
||||
Message.success(`已开始整理(任务 ${res.data.jobId}),可以关掉这个窗口等它跑完`)
|
||||
createVisible.value = false
|
||||
resetCreate()
|
||||
onSearch()
|
||||
return true
|
||||
} catch {
|
||||
// http 封装已经提示过了;这里只保证弹窗不关掉
|
||||
return false
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 详情与轮询 ──────────────────────────────────
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<CleanupJobVO | null>(null)
|
||||
const report = ref<CleanupReportVO | null>(null)
|
||||
let timer: number | undefined
|
||||
|
||||
async function openDetail(row: CleanupJobVO) {
|
||||
detailVisible.value = true
|
||||
detail.value = row
|
||||
report.value = null
|
||||
await loadDetail(row.id)
|
||||
// 还在跑就轮询:整理是异步的,而用户会盯着这一屏等结果
|
||||
if (!detail.value || detail.value.status === 0 || detail.value.status === 1
|
||||
|| detail.value.status === 2) {
|
||||
startPolling(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail(id: number) {
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await getCleanupJob(id)
|
||||
detail.value = res.data
|
||||
if (res.data?.status === 3) {
|
||||
stopPolling()
|
||||
await loadReport(id)
|
||||
} else if (res.data?.status === 4) {
|
||||
stopPolling()
|
||||
}
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReport(id: number) {
|
||||
try {
|
||||
const res = await getCleanupReport(id)
|
||||
report.value = res.data
|
||||
} catch {
|
||||
// 报告拿不到不影响下载结果包,所以不打断;用户会看到"重点"那块是空的
|
||||
report.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(id: number) {
|
||||
stopPolling()
|
||||
timer = window.setInterval(() => {
|
||||
loadDetail(id)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/** 抽屉关掉就停 —— 否则关了还在后台每 3 秒打一次接口。 */
|
||||
function stopPolling() {
|
||||
if (timer) {
|
||||
window.clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
async function refresh() {
|
||||
if (detail.value) {
|
||||
await loadDetail(detail.value.id)
|
||||
loadRecords()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 报告里的数字 ────────────────────────────────
|
||||
|
||||
const reportStats = computed(() => {
|
||||
const s = report.value?.summary
|
||||
if (!s) return []
|
||||
return [
|
||||
{ label: '不规范文件名', value: s.nameInvalid, warn: false },
|
||||
{ label: '已自动修复证件号', value: s.idcardAutoFixed, warn: false },
|
||||
{ label: '疑似串档', value: s.crossFileSuspected, warn: s.crossFileSuspected > 0 },
|
||||
{ label: '无法归位', value: s.orphanImages, warn: s.orphanImages > 0 }
|
||||
]
|
||||
})
|
||||
|
||||
// ── 下载结果包 ──────────────────────────────────
|
||||
|
||||
const downloading = ref(false)
|
||||
|
||||
/**
|
||||
* ⚠️ 走 `fetch` + Blob 而不是 `<a href>`:结果接口要带 Authorization 头,
|
||||
* 而 `<a href>` 带不了 —— 直接导航过去会 401,用户看到的是"下载没反应"。
|
||||
*/
|
||||
async function downloadResult() {
|
||||
if (!detail.value) return
|
||||
downloading.value = true
|
||||
try {
|
||||
// 下载放 api 层(与导出中心的 downloadExportFile 同一写法):它要带
|
||||
// Authorization 头取 blob,`<a href>` 带不了 —— 直接导航过去会 401
|
||||
await downloadCleanupResult(detail.value.id, detail.value.jobName)
|
||||
} catch (e) {
|
||||
Message.error((e as Error).message)
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 导入为正式任务 ──────────────────────────────
|
||||
|
||||
const importVisible = ref(false)
|
||||
const importing = ref(false)
|
||||
const importForm = reactive({ taskName: '', batchNo: '', majorName: '' })
|
||||
|
||||
function openImport() {
|
||||
if (!detail.value) return
|
||||
if (detail.value.targetTaskId) {
|
||||
// 服务端是幂等的(再导一次返回原来那个任务),但先说清更好:
|
||||
// 否则用户以为点了会建第二个任务
|
||||
Message.info(
|
||||
`这份整理已经导入过了(任务 ${detail.value.targetTaskId}),再点会返回同一个任务`
|
||||
)
|
||||
}
|
||||
importForm.taskName = `${detail.value.jobName}-导入`
|
||||
importForm.batchNo = new Date().toISOString().slice(0, 7).replace('-', '')
|
||||
importForm.majorName = ''
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
if (!detail.value) return false
|
||||
if (!importForm.taskName.trim() || !importForm.batchNo.trim()) {
|
||||
Message.warning('任务名称与批次号都要填')
|
||||
return false
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
const res = await importCleanupJob(detail.value.id, {
|
||||
taskName: importForm.taskName.trim(),
|
||||
batchNo: importForm.batchNo.trim(),
|
||||
majorName: importForm.majorName.trim() || undefined
|
||||
})
|
||||
Message.success(`已建任务 ${res.data.taskId},名册 ${res.data.rosterCount} 人`)
|
||||
importVisible.value = false
|
||||
await refresh()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.cl-tip {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cl-dim {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.cl-alert {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.cl-footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border-1);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cl-headline {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background: var(--color-fill-1);
|
||||
border-left: 3px solid rgb(var(--arcoblue-6));
|
||||
border-radius: 4px;
|
||||
|
||||
&__main {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 6px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.cl-stats {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cl-stat {
|
||||
padding: 12px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
|
||||
&--warn .cl-stat__num {
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
|
||||
&__num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.cl-hl {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--color-border-1);
|
||||
|
||||
&__who {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
&__detail {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__suggest {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<IconClockCircle />
|
||||
<span>{{ menu?.title ?? route.meta.title ?? '功能' }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-tag color="orange">{{ milestone }}</a-tag>
|
||||
</template>
|
||||
|
||||
<a-alert type="info" :show-icon="false">
|
||||
<template #title>
|
||||
<strong>这个页面还没做。</strong>
|
||||
{{ menu?.note ?? '该功能尚未实现。' }}
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<p class="coming-soon__why">
|
||||
左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它,
|
||||
说明你的角色确实有这个权限 —— 只是界面还没排到。
|
||||
</p>
|
||||
</a-card>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconClockCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { findMenuByPath } from '@/config/xt-routes'
|
||||
|
||||
/**
|
||||
* 「待建」页。
|
||||
*
|
||||
* 为什么不直接隐藏未实现的菜单:
|
||||
* 1. 菜单本身就是要展示的东西(权限驱动的导航);
|
||||
* 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息;
|
||||
* 3. 一个页面服务所有占位路由,成本极低。
|
||||
*
|
||||
* ⚠️ 这一页**只服务"确实还没做"的菜单**(现在只剩「角色与授权」)。
|
||||
* 曾经它还要区分另一种情况 —— "功能已经有了、只是入口在别处"(名单管理 / 表单设计 /
|
||||
* 材料规则,都在「收集任务」行内的链接里)。那三条菜单已按客户要求**直接去掉**:
|
||||
* 导航里挂着一条点进去只有指路的条目,既占位置又让人以为这里应该有事可做。
|
||||
* 所以 `entryAt` 那套分支("功能已就绪"标签 + 跳转按钮)也一并删了 ——
|
||||
* 留着就是一段永远不会被走到的代码。
|
||||
*
|
||||
* ⚠️ 按**路径**找,不要按 `route.meta.menu`。
|
||||
*
|
||||
* 原来写的是 `findMenu(route.meta.menu ?? '')`,而 `formatAsyncRoutes` 构造的 meta 里
|
||||
* **没有 `menu` 这个键**(只有 hidden/keepAlive/title/icon/... )——
|
||||
* 于是这里永远拿到 `undefined`,页面永远显示兜底文案
|
||||
* 「功能 / 规划中 / 该功能尚未实现」,**每个菜单项自己的 note 从来没显示出来过**。
|
||||
* 而 note 恰恰是这一页最有用的东西。
|
||||
*/
|
||||
defineOptions({ name: 'ComingSoon' })
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const menu = computed(() => findMenuByPath(route.path))
|
||||
|
||||
/** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
|
||||
const MILESTONES: Record<string, string> = {
|
||||
role: '二期'
|
||||
}
|
||||
|
||||
const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.coming-soon__why {
|
||||
margin: 16px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
color: var(--color-text-2);
|
||||
|
||||
strong {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,333 +1,155 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<!-- 欢迎条 -->
|
||||
<a-card :bordered="false" class="dash__welcome">
|
||||
<div class="dash__hello">
|
||||
<a-avatar :size="46" class="dash__avatar">{{ avatarText }}</a-avatar>
|
||||
<div>
|
||||
<h3 class="dash__name">{{ userStore.name || '—' }},欢迎回来</h3>
|
||||
<p class="dash__meta">
|
||||
{{ userStore.tenantName || '平台运营' }} ·
|
||||
{{ roleLabel || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
<a-tag class="dash__scope" color="arcoblue">一期范围:学校收集 + 自主认定</a-tag>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 统计 -->
|
||||
<a-row :gutter="14" class="dash__stats">
|
||||
<a-col v-for="s in stats" :key="s.label" :xs="12" :sm="12" :md="6">
|
||||
<a-card :bordered="false" class="dash__stat">
|
||||
<div class="dash__stat-label">
|
||||
<component :is="s.icon" />
|
||||
<span>{{ s.label }}</span>
|
||||
</div>
|
||||
<div class="dash__stat-num" :class="s.tone">
|
||||
{{ s.value }}<small v-if="s.unit">{{ s.unit }}</small>
|
||||
</div>
|
||||
<div class="dash__stat-foot">{{ s.foot }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<a-card :bordered="false">
|
||||
<template #title>快捷入口</template>
|
||||
<template #extra>
|
||||
<span class="dash__hint">当前角色可访问的页面</span>
|
||||
</template>
|
||||
<a-space wrap>
|
||||
<a-button v-for="m in shortcuts" :key="m.key" @click="router.push(m.path)">
|
||||
<template #icon>
|
||||
<component :is="m.icon" />
|
||||
</template>
|
||||
{{ m.title }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
|
||||
<!--
|
||||
数据看板(M7-01/02)。
|
||||
⚠️ 这里原来是一张「开发进度」卡,写着「M0 后端已完成,界面建设中」——
|
||||
而项目早已是 120/121 个接口可用。**首页上说一件不真的事**比空着更糟,
|
||||
所以换成了真实的统计数据(两个接口都是现成的,见 §7.11 与 §11.6)。
|
||||
-->
|
||||
<a-card :bordered="false">
|
||||
<template #title>数据看板</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<!--
|
||||
平台运营必须先选学校:任务、统计、审核数都是**租户内**的数据,
|
||||
而服务端对平台的租户过滤是关掉的 —— 不选就会把各学校的任务混在一起。
|
||||
-->
|
||||
<GiTenantPicker
|
||||
v-if="isPlatform"
|
||||
v-model="actingStore.tenantId"
|
||||
width="200px"
|
||||
placeholder="选择学校(平台运营)"
|
||||
/>
|
||||
<a-select
|
||||
v-model="boardTaskId"
|
||||
placeholder="选择一个任务"
|
||||
allow-search
|
||||
style="width: 260px"
|
||||
:loading="taskLoading"
|
||||
:disabled="needPickTenant"
|
||||
:options="taskOptions"
|
||||
@change="loadBoard"
|
||||
/>
|
||||
<a-button :loading="boardLoading" :disabled="needPickTenant" @click="loadBoard">刷新</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-empty v-if="needPickTenant" description="平台运营看的是某一所学校的进度:请先选择学校" />
|
||||
|
||||
<a-empty v-else-if="!boardTaskId" description="选一个任务,看它的填报进度与审核情况" />
|
||||
|
||||
<a-spin v-else :loading="boardLoading" style="display: block">
|
||||
<!-- 进度四格:应填 / 已提交 / 已通过 / 待审 -->
|
||||
<a-row :gutter="12">
|
||||
<a-col v-for="c in boardCards" :key="c.label" :xs="12" :md="6">
|
||||
<div class="dash__board-card">
|
||||
<div class="dash__board-label">{{ c.label }}</div>
|
||||
<div class="dash__board-num" :class="c.tone">{{ c.value }}</div>
|
||||
<div class="dash__board-foot">{{ c.foot }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" class="dash__board-tables">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<div class="dash__board-title">分班进度</div>
|
||||
<!--
|
||||
⚠️ 提交率与通过率**分两列**,不合并成一列"完成率":
|
||||
已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。
|
||||
-->
|
||||
<a-table
|
||||
:data="stat?.byOrg ?? []"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:bordered="{ cell: true }"
|
||||
:scroll="{ y: 240 }"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="班级" data-index="orgName" ellipsis tooltip />
|
||||
<a-table-column title="应填" data-index="rosterTotal" :width="70" align="center" />
|
||||
<a-table-column title="已提交" data-index="submittedCount" :width="80" align="center" />
|
||||
<a-table-column title="已通过" data-index="approvedCount" :width="80" align="center" />
|
||||
<a-table-column title="提交率" data-index="submitRate" :width="86" align="center" />
|
||||
</template>
|
||||
</a-table>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<div class="dash__board-title">
|
||||
审核情况
|
||||
<span class="dash__board-note">
|
||||
时效口径:班级 {{ audit?.classTimeoutHours ?? '—' }} 小时 /
|
||||
后台 {{ audit?.adminTimeoutHours ?? '—' }} 小时
|
||||
</span>
|
||||
</div>
|
||||
<a-table
|
||||
v-if="canViewAllProgress"
|
||||
:data="auditRows"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:bordered="{ cell: true }"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="指标" data-index="label" :width="150" />
|
||||
<a-table-column title="值" data-index="value" />
|
||||
<a-table-column title="说明" data-index="foot" ellipsis tooltip />
|
||||
</template>
|
||||
</a-table>
|
||||
<!--
|
||||
班主任(只有 progress:viewOwn)在这里看到的是**空表**,而空表不解释任何事。
|
||||
如实说明"这里为什么不给你看",比留一张空表更清楚。
|
||||
-->
|
||||
<a-empty v-else description="审核统计按全租户口径,需要「全租户进度」权限" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<template v-if="(audit?.byReason ?? []).length">
|
||||
<div class="dash__board-title">驳回原因分布(前几名)</div>
|
||||
<a-space wrap>
|
||||
<a-tag v-for="r in audit?.byReason ?? []" :key="r.reasonCode" color="orange">
|
||||
{{ r.reasonText }} × {{ r.count }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { OrgTreeVO } from '@/apis/xt/org'
|
||||
import { IconFolder, IconIdcard, IconSafe, IconStamp } from '@arco-design/web-vue/es/icon'
|
||||
import { NAvatar, NButton, NCard, NDataTable, NEmpty, NGrid, NGridItem, NSelect, NSpin, NTag } from 'naive-ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as auditApi from '@/apis/xt/audit'
|
||||
import * as orgApi from '@/apis/xt/org'
|
||||
import * as taskApi from '@/apis/xt/task'
|
||||
import * as tenantApi from '@/apis/xt/tenant'
|
||||
import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes'
|
||||
import { useActingTenantStore, useUserStore } from '@/stores'
|
||||
import { roleLabel, XT_MENUS } from '@/config/xt-routes'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useActingTenantStore } from '@/stores/acting-tenant'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { dash } from '@/utils/format'
|
||||
|
||||
/**
|
||||
* 工作台。
|
||||
*
|
||||
* 取舍:**只显示真实能查到的数字**。
|
||||
* 原型里的「异常待处理」这类统计需要 M1/M2 的任务与校验数据,
|
||||
* 现在没有对应接口,所以不做假数据充数 —— 空着比编数字诚实。
|
||||
* 三条取舍:
|
||||
* 1. **只显示真实能查到的数字**。没有对应接口的统计不做假数据 —— 空着比编数字诚实。
|
||||
* 2. **按权限决定发不发请求**。`/orgs/tree` 要 `org:view`、`/audit/statistics` 要
|
||||
* `progress:viewAll`,班主任两个都没有;而 403 是 HTTP 状态码,`http.ts` 会先弹一个
|
||||
* 「无权限访问(403)」,`try/catch` 拦不住那个提示 —— 唯一正确的做法是**不去调它**,
|
||||
* 并把那一格如实显示成「—」。
|
||||
* 3. **平台没选学校时不发租户内数据的请求**(选学校的入口在顶栏):不选就请求,
|
||||
* 拿到的是各学校混在一起的数据,而界面上看不出这件事。
|
||||
*/
|
||||
defineOptions({ name: 'Dashboard' })
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const user = useUserStore()
|
||||
const acting = useActingTenantStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const orgCount = ref<number | null>(null)
|
||||
const tenantCount = ref<number | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const avatarText = computed(() => (userStore.name || '—').slice(0, 1))
|
||||
const roleLabel = computed(() =>
|
||||
userStore.roles.map((c) => ROLE_NAMES[c] ?? c).join(' / ')
|
||||
)
|
||||
// 注意是 menuKeys 而不是 menus —— 写错不会报错,
|
||||
// 只会让这个判断恒为 false(之前就是这样:平台管理员也看到"仅平台运营可见")
|
||||
const isPlatform = computed(() => userStore.menuKeys?.includes('tenant') ?? false)
|
||||
const isPlatform = computed(() => user.isPlatform)
|
||||
const needPickTenant = computed(() => isPlatform.value && !acting.tenantId)
|
||||
|
||||
/**
|
||||
* ⚠️ 两个"能不能调"的判断,缺一个就会在页面上弹 403。
|
||||
*
|
||||
* 工作台是所有角色共用的落地页,但它里面有两个接口是**按角色开放的**:
|
||||
* · `GET /orgs/tree` 要 `org:view` —— 班主任/副班主任/审核员都没有;
|
||||
* · `GET /audit/statistics` 要 `progress:viewAll` —— 班主任只有 `progress:viewOwn`。
|
||||
*
|
||||
* 以前这两个是无条件调的,于是**班主任一进工作台就弹「拒绝访问(403)」**(实测)。
|
||||
* 而 403 来自 HTTP 状态码,`try/catch` 拦不住它的 toast —— 前端唯一正确的做法是
|
||||
* **不去调它**,并把那一格如实显示成"—"(本页的取舍就是"只显示真实能查到的数字")。
|
||||
*/
|
||||
const canViewOrg = computed(() => userStore.permissions.includes('org:view'))
|
||||
/**
|
||||
* ⚠️ 权限码是 **`progress:viewAll`**(驼峰),不是 `progress:view:all` ——
|
||||
* 权威来源是后端 `security/PermissionDef`(`PROGRESS_VIEW_ALL("progress:viewAll", ...)`)。
|
||||
* 我第一版就写成了带冒号的,结果这个判断**恒为 false**:「审核情况」对**所有人**都不显示,
|
||||
* 而 typecheck / lint / 后端全都不报错(字符串对不上是纯运行期的事)。
|
||||
* 现在 `scripts/check-wiring.py` 会做这层对齐检查。
|
||||
*/
|
||||
const canViewAllProgress = computed(() => userStore.permissions.includes('progress:viewAll'))
|
||||
/** 权限码的权威来源是后端 `security/PermissionDef`,写错只会"恒为 false"而不报错 */
|
||||
const canViewOrg = computed(() => user.has('org:view'))
|
||||
const canViewAllProgress = computed(() => user.has('progress:viewAll'))
|
||||
|
||||
/**
|
||||
* 平台运营的「当前学校」(请求头 `X-Tenant-Id`,见 `@/apis/xt/acting-tenant`)。
|
||||
*
|
||||
* ⚠️ 工作台也必须有这个选择:这里的**任务、统计、审核数**全是租户内的数据,
|
||||
* 而服务端对平台的租户过滤是关掉的 —— 不给选择就会把各学校的任务混在一个下拉里,
|
||||
* 选中之后看到的进度是"另一所学校的",而界面上没有任何地方会提示这件事。
|
||||
*
|
||||
* 选择与「组织架构 / 账号管理」共用同一个 store(见 `useActingTenantStore`)。
|
||||
*/
|
||||
const actingStore = useActingTenantStore()
|
||||
const needPickTenant = computed(() => isPlatform.value && !actingStore.tenantId)
|
||||
const avatarText = computed(() => (user.name || '—').slice(0, 1))
|
||||
const roles = computed(() => roleLabel(user.roles))
|
||||
|
||||
/**
|
||||
* 「组织节点」这一格能不能显示真实数字。
|
||||
*
|
||||
* 平台运营要先选定学校(选定后就是那所学校的节点数);没选时显示"—",
|
||||
* 而不是让 `/orgs/tree` 返回一个"0 个"(那会让人以为全平台没有组织)。
|
||||
*/
|
||||
/** 当前角色能进的页面(菜单键由后端下发,这里只做过滤) */
|
||||
const shortcuts = computed(() => XT_MENUS.filter((m) => user.menus.includes(m.key)))
|
||||
|
||||
/** 「组织节点」这一格能不能显示真实数字:要有权限,且平台要先选定学校 */
|
||||
const orgCardShowable = computed(() => canViewOrg.value && !needPickTenant.value)
|
||||
|
||||
/** 已实现且当前角色可访问的页面 */
|
||||
const shortcuts = computed(() =>
|
||||
XT_MENUS.filter((m) => m.ready && userStore.menuKeys.includes(m.key))
|
||||
)
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: '组织节点',
|
||||
icon: IconFolder,
|
||||
icon: 'org-group',
|
||||
value: orgCardShowable.value && !loading.value ? (orgCount.value ?? '—') : '—',
|
||||
unit: orgCardShowable.value ? '个' : '',
|
||||
foot: isPlatform.value
|
||||
? (needPickTenant.value ? '选定学校后展示该校的组织' : '所选学校的节点数')
|
||||
: (canViewOrg.value ? '学校 / 院系 / 班级' : '需要「组织查看」权限'),
|
||||
tone: ''
|
||||
: (canViewOrg.value ? '学校 / 院系 / 班级' : '需要「组织查看」权限')
|
||||
},
|
||||
{
|
||||
label: '租户',
|
||||
icon: IconStamp,
|
||||
icon: 'tenant',
|
||||
value: isPlatform.value ? (loading.value ? '—' : (tenantCount.value ?? '—')) : '—',
|
||||
unit: isPlatform.value ? '个' : '',
|
||||
foot: isPlatform.value ? '平台可见' : '仅平台运营可见',
|
||||
tone: 'is-success'
|
||||
foot: isPlatform.value ? '平台可见' : '仅平台运营可见'
|
||||
},
|
||||
{
|
||||
label: '我的角色',
|
||||
icon: IconSafe,
|
||||
value: userStore.roles.length,
|
||||
icon: 'role',
|
||||
value: user.roles.length,
|
||||
unit: '个',
|
||||
foot: roleLabel.value || '—',
|
||||
tone: ''
|
||||
foot: roles.value
|
||||
},
|
||||
{
|
||||
label: '可用权限',
|
||||
icon: IconIdcard,
|
||||
value: userStore.permissions.length,
|
||||
icon: 'check',
|
||||
value: user.permissions.length,
|
||||
unit: '项',
|
||||
foot: '按角色由服务端下发',
|
||||
tone: ''
|
||||
foot: '按角色由服务端下发'
|
||||
}
|
||||
])
|
||||
|
||||
// ── 数据看板(M7-01/02)─────────────────────────
|
||||
// ── 数据看板 ────────────────────────────────────────
|
||||
|
||||
const boardTaskId = ref<number | undefined>(undefined)
|
||||
const boardTaskId = ref<number | null>(null)
|
||||
const taskOptions = ref<Array<{ label: string, value: number }>>([])
|
||||
const taskLoading = ref(false)
|
||||
const boardLoading = ref(false)
|
||||
const stat = ref<taskApi.TaskStatisticsVO | null>(null)
|
||||
const audit = ref<auditApi.AuditStatisticsVO | null>(null)
|
||||
|
||||
/** 进度四格。比率直接用服务端给的字符串(`—` 也是它给的,不要在前端算)。 */
|
||||
/** 进度四格。比率直接用服务端给的字符串(`—` 也是它给的,前端不要自己算) */
|
||||
const boardCards = computed(() => {
|
||||
const s = stat.value?.stat
|
||||
return [
|
||||
{
|
||||
label: '应填',
|
||||
value: s?.rosterTotal ?? '—',
|
||||
foot: '名册人数',
|
||||
tone: ''
|
||||
},
|
||||
{
|
||||
label: '已提交',
|
||||
value: s?.submittedCount ?? '—',
|
||||
foot: `提交率 ${s?.submitRate ?? '—'}`,
|
||||
tone: ''
|
||||
},
|
||||
{
|
||||
label: '已通过',
|
||||
value: s?.approvedCount ?? '—',
|
||||
foot: `通过率 ${s?.passRate ?? '—'}`,
|
||||
tone: 'is-success'
|
||||
},
|
||||
{ label: '应填', value: s?.rosterTotal ?? '—', foot: '名册人数' },
|
||||
{ label: '已提交', value: s?.submittedCount ?? '—', foot: `提交率 ${s?.submitRate ?? '—'}` },
|
||||
{ label: '已通过', value: s?.approvedCount ?? '—', foot: `通过率 ${s?.passRate ?? '—'}` },
|
||||
{
|
||||
label: '待审',
|
||||
value: canViewAllProgress.value ? (audit.value?.pendingCount ?? '—') : '—',
|
||||
foot: canViewAllProgress.value
|
||||
? (audit.value?.timeoutCount ? `其中超时 ${audit.value.timeoutCount} 件` : '没有超时的')
|
||||
: '需要「全租户进度」权限',
|
||||
tone: audit.value?.timeoutCount ? 'is-warn' : ''
|
||||
: '需要「全租户进度」权限'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const orgColumns: DataTableColumns<taskApi.TaskStatOrgRow> = [
|
||||
{ title: '班级', key: 'orgName', ellipsis: { tooltip: true } },
|
||||
{ title: '应填', key: 'rosterTotal', width: 70, align: 'center' },
|
||||
{ title: '已提交', key: 'submittedCount', width: 80, align: 'center' },
|
||||
{ title: '已通过', key: 'approvedCount', width: 80, align: 'center' },
|
||||
{ title: '提交率', key: 'submitRate', width: 86, align: 'center' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 把"小时"按量级换单位显示。
|
||||
*
|
||||
* ⚠️ 三种值必须分开,别用同一个 `0` 表达:
|
||||
* · `null` = **还没有样本**(不是"审核很快");
|
||||
* · `0` = 有样本,但不足 1 分钟;
|
||||
* · 其它 = 分钟 / 小时。
|
||||
* 直接显示「0 小时」会让人读成"零小时通过",而那是另一个结论。
|
||||
*/
|
||||
function fmtHours(h: number | null | undefined): string {
|
||||
if (h === null || h === undefined) {
|
||||
return '—'
|
||||
}
|
||||
if (h <= 0) {
|
||||
return '不到 1 分钟'
|
||||
}
|
||||
if (h < 1) {
|
||||
return `${Math.max(1, Math.round(h * 60))} 分钟`
|
||||
}
|
||||
return h >= 10 ? `${Math.round(h)} 小时` : `${h} 小时`
|
||||
}
|
||||
|
||||
const auditRows = computed(() => {
|
||||
const a = audit.value
|
||||
if (!a) return []
|
||||
if (!a) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{ label: '待审', value: a.pendingCount, foot: '还在队列里' },
|
||||
{ label: '已通过', value: a.approvedCount, foot: '' },
|
||||
{ label: '已驳回', value: a.rejectedCount, foot: '' },
|
||||
{ label: '待审', value: dash(a.pendingCount), foot: '还在队列里' },
|
||||
{ label: '已通过', value: dash(a.approvedCount), foot: '' },
|
||||
{ label: '已驳回', value: dash(a.rejectedCount), foot: '' },
|
||||
{
|
||||
label: '平均审核时长',
|
||||
value: fmtHours(a.avgAuditHours),
|
||||
@@ -335,66 +157,50 @@ const auditRows = computed(() => {
|
||||
? '还没有已审的记录'
|
||||
: '从提交到审完的平均间隔'
|
||||
},
|
||||
{ label: '超时未审', value: a.timeoutCount, foot: '超过时效口径的件数' }
|
||||
{ label: '超时未审', value: dash(a.timeoutCount), foot: '超过时效口径的件数' }
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 把"小时"这个数字**按量级换单位**显示。
|
||||
*
|
||||
* ⚠️ 三种值必须分开,别用同一个 `0` 表达:
|
||||
* - `null` = **还没有样本**(不是"审核很快");
|
||||
* - `0` = 有样本,但不足 1 分钟(E2E 的演示数据就是这样:提交与审核差几秒);
|
||||
* - 其它按分钟/小时显示。
|
||||
*
|
||||
* 直接显示 `0.0 小时` 会让人读成"零小时",而那是另一个结论。
|
||||
*/
|
||||
function fmtHours(h: number | null | undefined): string {
|
||||
if (h === null || h === undefined) return '—'
|
||||
if (h <= 0) return '不到 1 分钟'
|
||||
if (h < 1) return `${Math.max(1, Math.round(h * 60))} 分钟`
|
||||
return h >= 10 ? `${Math.round(h)} 小时` : `${h} 小时`
|
||||
function countNodes(nodes: OrgTreeVO[]): number {
|
||||
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
|
||||
}
|
||||
|
||||
/** 组织节点数(平台运营按所选学校;其它角色就是自己租户的) */
|
||||
async function loadOrgCount() {
|
||||
// 组织树要 `org:view`(班主任/副班主任/审核员都没有);平台没选学校时也不该问
|
||||
async function loadOrgCount(): Promise<void> {
|
||||
if (!orgCardShowable.value) {
|
||||
orgCount.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await orgApi.getOrgTree({ tenantId: actingStore.tenantId })
|
||||
orgCount.value = countNodes(res.data ?? [])
|
||||
const tree = await orgApi.getOrgTree({ tenantId: acting.tenantId })
|
||||
orgCount.value = countNodes(tree ?? [])
|
||||
} catch {
|
||||
// 提示已由 http 层弹过,这里只把数字留空
|
||||
orgCount.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
// 平台没选学校时不发请求:拿到的是空列表,界面上会显示成"这所学校没有任务"
|
||||
async function loadTasks(): Promise<void> {
|
||||
if (needPickTenant.value) {
|
||||
taskOptions.value = []
|
||||
boardTaskId.value = undefined
|
||||
boardTaskId.value = null
|
||||
stat.value = null
|
||||
audit.value = null
|
||||
return
|
||||
}
|
||||
taskLoading.value = true
|
||||
try {
|
||||
const res = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 }, actingStore.tenantId)
|
||||
const list = res.data?.list ?? []
|
||||
const page = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 }, acting.tenantId)
|
||||
const list = page.list ?? []
|
||||
taskOptions.value = list.map((x) => ({
|
||||
label: `${x.taskName}(${taskApi.TASK_STATUS_TEXT[x.status] ?? ''})`,
|
||||
value: x.id
|
||||
}))
|
||||
// 默认选第一个还在收集中的任务 —— 那才是看板要回答的问题
|
||||
// 默认选第一个「收集中」的任务 —— 那才是看板要回答的问题
|
||||
const collecting = list.find((x) => x.status === taskApi.TASK_STATUS_COLLECTING)
|
||||
boardTaskId.value = collecting?.id ?? null
|
||||
if (collecting) {
|
||||
boardTaskId.value = collecting.id
|
||||
loadBoard()
|
||||
await loadBoard()
|
||||
} else {
|
||||
boardTaskId.value = undefined
|
||||
stat.value = null
|
||||
audit.value = null
|
||||
}
|
||||
@@ -403,186 +209,367 @@ async function loadTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBoard() {
|
||||
if (!boardTaskId.value) return
|
||||
async function loadBoard(): Promise<void> {
|
||||
if (!boardTaskId.value) {
|
||||
return
|
||||
}
|
||||
boardLoading.value = true
|
||||
try {
|
||||
// ⚠️ 审核统计要 `progress:viewAll`,班主任没有 —— 不能"调了再 catch":
|
||||
// 403 是 HTTP 状态码,`http.ts` 的传输层分支会**先弹一个「拒绝访问(403)」**,
|
||||
// catch 只能拦住异常,拦不住那个提示。所以按权限**决定发不发这个请求**。
|
||||
const [s, a] = await Promise.allSettled([
|
||||
taskApi.getTaskStatistics(boardTaskId.value, actingStore.tenantId),
|
||||
taskApi.getTaskStatistics(boardTaskId.value, acting.tenantId),
|
||||
// ⚠️ 没有 progress:viewAll 就不发这个请求(见文件头第 2 条)
|
||||
canViewAllProgress.value
|
||||
? auditApi.getAuditStatistics({ taskId: boardTaskId.value }, actingStore.tenantId)
|
||||
? auditApi.getAuditStatistics({ taskId: boardTaskId.value }, acting.tenantId)
|
||||
: Promise.resolve(null)
|
||||
])
|
||||
stat.value = s.status === 'fulfilled' ? s.value.data : null
|
||||
audit.value = a.status === 'fulfilled' ? (a.value?.data ?? null) : null
|
||||
stat.value = s.status === 'fulfilled' ? s.value : null
|
||||
audit.value = a.status === 'fulfilled' ? a.value : null
|
||||
} finally {
|
||||
boardLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function countNodes(nodes: OrgTreeVO[]): number {
|
||||
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
|
||||
}
|
||||
|
||||
// 换学校 = 换一整套数字(组织节点、任务下拉、看板)。不清空的话,
|
||||
// 上一所学校的任务还留在下拉里,选中它看到的是**另一所学校**的进度
|
||||
watch(() => actingStore.tenantId, () => {
|
||||
loadOrgCount()
|
||||
loadTasks()
|
||||
// 换学校 = 换一整套数字(组织节点、任务下拉、看板)。
|
||||
// 不清空的话,上一所学校的任务还留在下拉里,而选中它看到的是**另一所学校**的进度
|
||||
watch(() => acting.tenantId, () => {
|
||||
void loadOrgCount()
|
||||
void loadTasks()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOrgCount()
|
||||
if (isPlatform.value) {
|
||||
try {
|
||||
const res = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 })
|
||||
tenantCount.value = res.data?.total ?? null
|
||||
const page = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 })
|
||||
tenantCount.value = page.total ?? null
|
||||
} catch {
|
||||
tenantCount.value = null
|
||||
}
|
||||
}
|
||||
await loadOrgCount()
|
||||
loading.value = false
|
||||
// 看板要任务列表;没有 task:view 权限的角色会拿到空列表,界面显示空态即可
|
||||
loadTasks()
|
||||
// 没有 task:view 的角色会拿到空列表,界面显示空态即可
|
||||
await loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dash {
|
||||
&__welcome {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
<template>
|
||||
<div class="page-plain">
|
||||
<!-- 欢迎条 -->
|
||||
<NCard :bordered="false" size="small">
|
||||
<div class="dash__hello">
|
||||
<NAvatar round :size="44" class="dash__avatar">{{ avatarText }}</NAvatar>
|
||||
<div class="dash__hello-text">
|
||||
<h3 class="dash__name">{{ user.name || '—' }},欢迎回来</h3>
|
||||
<p class="dash__meta">{{ user.tenantName || '平台运营' }} · {{ roles }}</p>
|
||||
</div>
|
||||
<NTag type="info" :bordered="false" size="small">一期范围:学校收集 + 自主认定</NTag>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
&__hello {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
<!-- 统计 -->
|
||||
<!--
|
||||
⚠️ `cols` 的键必须是 **断点名**(defaultBreakpoints = xs/s/m/l/xl/xxl),
|
||||
**不是像素值**。之前写的 `cols="1 640:2 1100:4"` 里 "640" / "1100" 查不到,
|
||||
seemly 的 parseResponsivePropValue 会一路回退到 `classObj['']` = 1
|
||||
→ 四张卡永远挤成一列(工作台"错乱"的真正原因,且不报错)。
|
||||
断点:xs<640 / s≥640 / m≥1024 / l≥1280。
|
||||
-->
|
||||
<NGrid cols="1 s:2 m:4" :x-gap="16" :y-gap="16" responsive="screen">
|
||||
<NGridItem v-for="s in stats" :key="s.label">
|
||||
<NCard :bordered="false" size="small" class="dash__stat">
|
||||
<div class="dash__stat-label">
|
||||
<span class="dash__stat-icon"><AppIcon :name="s.icon" :size="15" /></span>
|
||||
<span>{{ s.label }}</span>
|
||||
</div>
|
||||
<div class="dash__stat-num">
|
||||
{{ s.value }}<small v-if="s.unit">{{ s.unit }}</small>
|
||||
</div>
|
||||
<div class="dash__stat-foot">{{ s.foot }}</div>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
&__avatar {
|
||||
flex: none;
|
||||
font-size: 18px;
|
||||
background: rgb(var(--primary-6));
|
||||
}
|
||||
<!-- 快捷入口 -->
|
||||
<NCard :bordered="false" size="small">
|
||||
<template #header>
|
||||
快捷入口
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="dash__hint">当前角色可访问的页面</span>
|
||||
</template>
|
||||
<div class="dash__shortcuts">
|
||||
<NButton
|
||||
v-for="m in shortcuts"
|
||||
:key="m.key"
|
||||
class="dash__shortcut"
|
||||
size="small"
|
||||
@click="router.push(m.path)"
|
||||
>
|
||||
<template #icon>
|
||||
<AppIcon :name="m.icon" :size="16" />
|
||||
</template>
|
||||
{{ m.title }}
|
||||
</NButton>
|
||||
<span v-if="!shortcuts.length" class="dash__hint">
|
||||
当前账号还没有可见的业务菜单(可点右上角头像 → 刷新我的权限)
|
||||
</span>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
&__name {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
<!-- 数据看板 -->
|
||||
<NCard :bordered="false" size="small">
|
||||
<template #header>
|
||||
数据看板
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<div class="dash__board-tools">
|
||||
<NSelect
|
||||
v-model:value="boardTaskId"
|
||||
class="dash__task-select"
|
||||
size="small"
|
||||
filterable
|
||||
placeholder="选择一个任务"
|
||||
:options="taskOptions"
|
||||
:loading="taskLoading"
|
||||
:disabled="needPickTenant"
|
||||
@update:value="loadBoard"
|
||||
/>
|
||||
<NButton size="small" :loading="boardLoading" :disabled="needPickTenant" @click="loadBoard">
|
||||
刷新
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
&__meta {
|
||||
margin: 5px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<NEmpty
|
||||
v-if="needPickTenant"
|
||||
description="平台运营看的是某一所学校的进度:请先在右上角选择学校"
|
||||
/>
|
||||
<NEmpty
|
||||
v-else-if="!boardTaskId"
|
||||
description="选一个任务,看它的填报进度与审核情况"
|
||||
/>
|
||||
|
||||
&__scope {
|
||||
margin-left: auto;
|
||||
}
|
||||
<NSpin v-else :show="boardLoading">
|
||||
<!-- 进度四格 -->
|
||||
<NGrid cols="1 s:2 m:4" :x-gap="16" :y-gap="16" responsive="screen">
|
||||
<NGridItem v-for="c in boardCards" :key="c.label">
|
||||
<div class="dash__board-card">
|
||||
<div class="dash__board-label">{{ c.label }}</div>
|
||||
<div class="dash__board-num">{{ c.value }}</div>
|
||||
<div class="dash__board-foot">{{ c.foot }}</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
&__stats {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
<div class="dash__board-tables">
|
||||
<div class="dash__board-block">
|
||||
<div class="section-title">
|
||||
分班进度
|
||||
<!-- 提交率与通过率分两列:已提交 ≠ 已通过,合并会让"快完成了"与"快通过了"混为一谈 -->
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="orgColumns"
|
||||
:data="stat?.byOrg ?? []"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:max-height="260"
|
||||
/>
|
||||
</div>
|
||||
|
||||
&__stat {
|
||||
height: 100%;
|
||||
}
|
||||
<div class="dash__board-block">
|
||||
<div class="section-title">
|
||||
审核情况
|
||||
<span class="section-title__note">
|
||||
时效口径:班级 {{ audit?.classTimeoutHours ?? '—' }} 小时 /
|
||||
后台 {{ audit?.adminTimeoutHours ?? '—' }} 小时
|
||||
</span>
|
||||
</div>
|
||||
<!--
|
||||
班主任(只有 progress:viewOwn)在这里看到的是空表,而空表不解释任何事。
|
||||
如实说明"为什么不给你看"比留一张空表更清楚。
|
||||
-->
|
||||
<NDataTable
|
||||
v-if="canViewAllProgress"
|
||||
:columns="[
|
||||
{ title: '指标', key: 'label', width: 140 },
|
||||
{ title: '值', key: 'value', width: 120 },
|
||||
{ title: '说明', key: 'foot', ellipsis: { tooltip: true } },
|
||||
]"
|
||||
:data="auditRows"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="审核统计按全租户口径,需要「全租户进度」权限" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
&__stat-label {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<template v-if="(audit?.byReason ?? []).length">
|
||||
<div class="section-title">
|
||||
驳回原因分布(前几名)
|
||||
</div>
|
||||
<div class="dash__by-reason">
|
||||
<NTag v-for="r in audit?.byReason ?? []" :key="r.reasonCode" type="warning" size="small">
|
||||
{{ r.reasonText }} × {{ r.count }}
|
||||
</NTag>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
&__stat-num {
|
||||
margin-top: 8px;
|
||||
font-size: 27px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--color-text-1);
|
||||
<style scoped>
|
||||
.dash__hello {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-left: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.dash__avatar {
|
||||
font-size: 18px;
|
||||
background: #0052d9;
|
||||
}
|
||||
|
||||
&.is-success {
|
||||
color: rgb(var(--success-6));
|
||||
}
|
||||
}
|
||||
.dash__hello-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__stat-foot {
|
||||
margin-top: 7px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.dash__name {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.dash__meta {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
&__board-card {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dash__stat {
|
||||
/* 四张卡等高:格子被 grid 拉伸,卡片跟着撑满,否则数字位数不同就会参差不齐 */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__board-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.dash__stat-label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
&__board-num {
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--color-text-1);
|
||||
/* 图标给一块浅蓝底,卡片才有"标识—内容"的层次,而不是一行灰字 */
|
||||
.dash__stat-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: #0052d9;
|
||||
background: #eef4ff;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
&.is-success {
|
||||
color: rgb(var(--success-6));
|
||||
}
|
||||
.dash__stat-num {
|
||||
margin-top: 10px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: #1f2229;
|
||||
}
|
||||
|
||||
&.is-warn {
|
||||
color: rgb(var(--warning-6));
|
||||
}
|
||||
}
|
||||
.dash__stat-num small {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
&__board-foot {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.dash__stat-foot {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
&__board-tables {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.dash__hint {
|
||||
font-size: 12px;
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
&__board-title {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 快捷入口:自适应网格的"入口砖",比一排白底小按钮更像卡片区的成员 */
|
||||
.dash__shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__board-note {
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.dash__shortcut {
|
||||
height: 40px;
|
||||
font-weight: 500;
|
||||
justify-content: flex-start;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
&__note:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.dash__board-tools {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dash__task-select {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
/**
|
||||
* 看板四格:卡片**内部**的小格子,所以用浅灰底 + 描边,而不是第二层白卡片
|
||||
* (白色卡片套白色卡片会糊成一片,见 styles/index.scss 里"卡里套卡"那段)。
|
||||
*/
|
||||
.dash__board-card {
|
||||
height: 100%;
|
||||
padding: 12px 14px;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid #eef1f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dash__board-label {
|
||||
font-size: 13px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.dash__board-num {
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f2229;
|
||||
}
|
||||
|
||||
.dash__board-foot {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
/* 两张表并排;窄屏自动落成上下两行(原来用 flex + min-width 320,撞到换行时高度会突然跳) */
|
||||
.dash__board-tables {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dash__board-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash__by-reason {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,445 +1,628 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>字典管理</template>
|
||||
<template #extra>
|
||||
<span class="dict__hint">
|
||||
内置项照抄官方表格,**不可改名**(改名会让导出汇总表与官方模板对不上);
|
||||
要加值请用「新增项」追加
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="dict__layout">
|
||||
<!-- 左:字典列表 -->
|
||||
<div class="dict__side">
|
||||
<a-spin :loading="loadingDicts" class="dict__spin">
|
||||
<div v-for="d in dicts" :key="d.dictCode" class="dict__row"
|
||||
:class="{ 'dict__row--on': current?.dictCode === d.dictCode }"
|
||||
@click="pick(d)">
|
||||
<span class="dict__name">{{ d.dictName }}</span>
|
||||
<a-tag v-if="d.builtin" size="small" color="arcoblue">内置</a-tag>
|
||||
<span class="dict__count">{{ d.itemCount }}</span>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 右:字典项 -->
|
||||
<div class="dict__main">
|
||||
<div v-if="!current" class="dict__empty">从左边选一个字典</div>
|
||||
<template v-else>
|
||||
<div class="dict__bar">
|
||||
<span class="dict__title">
|
||||
{{ current.dictName }}
|
||||
<span class="dict__code">{{ current.dictCode }}</span>
|
||||
</span>
|
||||
<a-space>
|
||||
<a-button size="small" @click="reload">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" size="small" @click="openCreate">
|
||||
新增项
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- ⚠️ 三种来源的说明必须摆在界面上:不说清的话,
|
||||
管理员会以为"内置项改不了"是权限不够 -->
|
||||
<a-alert v-if="current.builtin" type="normal" class="dict__note">
|
||||
<template #title>
|
||||
<template v-if="current.dictCode === 'OCCUPATION'">
|
||||
这一项来自**国标职业库**(`sys_occupation_ref`),只读。
|
||||
一期这张表是空的 —— 需要客户提供或采购国标职业数据后导入。
|
||||
</template>
|
||||
<template v-else>
|
||||
这是内置字典:项名不可改、不可删,只能**停用**(可一键恢复默认)。
|
||||
要加值请用「新增项」—— 追加的项可以随时改名或删除。
|
||||
</template>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-table :data="items" :loading="loadingItems" row-key="itemCode" :pagination="false"
|
||||
:scroll="{ x: 800 }">
|
||||
<template #columns>
|
||||
<a-table-column title="项码" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<span :class="record.builtin ? 'dict__muted' : ''">{{ record.itemCode }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="项名" :width="260">
|
||||
<template #cell="{ record }">
|
||||
<span :class="record.status === 0 ? 'dict__off' : ''">{{ record.itemName }}</span>
|
||||
<a-tag v-if="record.builtin === true" size="small" class="dict__tag">内置</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column v-if="hierarchical" title="上级项" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<span class="dict__muted">{{ record.parentItemCode || '—' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="排序" :width="80">
|
||||
<template #cell="{ record }">
|
||||
<span class="dict__muted">{{ record.sortNo ?? '—' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" :width="90">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small" :color="record.status === 0 ? 'gray' : 'green'">
|
||||
{{ record.status === 0 ? '已停用' : '启用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="200">
|
||||
<template #cell="{ record }">
|
||||
<!--
|
||||
内置项与自定义项的可用动作**不同**,这是刻意的:
|
||||
· 内置项(没有 id)→ 只能停用/恢复(走 toggle)
|
||||
· 自定义项 → 可改名、可删
|
||||
把两套按钮混在一起,用户一定会点到"改名内置项"然后收到 40001。
|
||||
-->
|
||||
<a-space v-if="record.builtin === true">
|
||||
<a-link v-if="current.dictCode === 'OCCUPATION'" disabled>只读</a-link>
|
||||
<a-link v-else @click="toggle(record)">
|
||||
{{ record.status === 0 ? '恢复默认' : '停用' }}
|
||||
</a-link>
|
||||
</a-space>
|
||||
<a-space v-else>
|
||||
<a-link @click="openEdit(record)">编辑</a-link>
|
||||
<a-link :status="record.status === 0 ? 'success' : 'warning'"
|
||||
@click="toggleCustom(record)">
|
||||
{{ record.status === 0 ? '启用' : '停用' }}
|
||||
</a-link>
|
||||
<a-popconfirm content="删除这一项?已经用过它的历史数据不会变,但以后选不到了"
|
||||
@ok="remove(record)">
|
||||
<a-link status="danger">删除</a-link>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增 / 编辑 -->
|
||||
<a-modal v-model:visible="modalVisible" :title="editing ? '编辑字典项' : '新增字典项'"
|
||||
:ok-loading="saving" @ok="save" @cancel="modalVisible = false">
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item label="项码" required>
|
||||
<!-- 项码建后不可改是**结构性**的:编辑时直接禁用输入,
|
||||
并且后端接口的请求体里也没有这个字段。
|
||||
`editing !== null` 必须写全 —— 直接写 `editing` 会因为它可能是 null
|
||||
而被 vue-tsc 拦下(:disabled 只要 boolean) -->
|
||||
<a-input v-model="form.itemCode" :disabled="editing !== null"
|
||||
placeholder="如 大学本科 / TECH_SCHOOL(建后不可改)" />
|
||||
<template #extra>
|
||||
项码是历史数据里真正存的值,**建后不可改** ——
|
||||
改了它,过去填过的值会指向不存在的项,而查询不报错、只显示空白。
|
||||
</template>
|
||||
</a-form-item>
|
||||
<a-form-item label="项名" required>
|
||||
<a-input v-model="form.itemName" placeholder="界面上给用户看的文字" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="hierarchical" label="上级项码">
|
||||
<a-input v-model="form.parentItemCode" placeholder="留空 = 顶层项" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model="form.sortNo" :min="0" :step="10" placeholder="数字越小越靠前" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { DictItemVO, DictVO } from '@/apis/xt/dict'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NEmpty,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, ref } from 'vue'
|
||||
import {
|
||||
createDictItem,
|
||||
deleteDictItem,
|
||||
DICT_ERROR_HINT,
|
||||
getDictItems,
|
||||
getDicts,
|
||||
toggleBuiltinItem,
|
||||
updateDictItem
|
||||
} from '@/apis/xt/dict'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
|
||||
/**
|
||||
* 字典管理(《03-接口设计》§13.3)。
|
||||
* 字典管理。
|
||||
*
|
||||
* ## 界面上的核心分叉:内置项 vs 自定义项
|
||||
*
|
||||
* 内置项**没有 id**(它们在代码里,没有数据库行),所以:
|
||||
* · 操作只能按 `itemCode` 走 `toggle`(停用/恢复);
|
||||
* · 操作只能按 `itemCode` 走 `toggle`(停用 / 恢复默认);
|
||||
* · 没有"编辑名称"与"删除"。
|
||||
* 自定义项才有 id,可以改名、停用、删除。
|
||||
*
|
||||
* 把两套按钮混在一起,用户一定会点到"改名内置项"然后收到 40001 ——
|
||||
* 而那时他会以为"是权限不够",实际是设计如此。
|
||||
*
|
||||
* ## 为什么内置项不能改名(这条必须写在界面上)
|
||||
*
|
||||
* 因为内置选项**照抄官方表格**。有人把「四级/中级工」改成「中级」之后,
|
||||
* 导出的汇总表就与官方模板对不上了 —— 而那时**没有任何地方会报错**。
|
||||
*
|
||||
* ⚠️ 业务错误不再自己弹 toast:`@/utils/http` 已经把服务端 msg 弹过一次,
|
||||
* 这里再弹一次就是两条重复提示。
|
||||
*/
|
||||
defineOptions({ name: 'DictManage' })
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const loadingDicts = ref(false)
|
||||
const loadingItems = ref(false)
|
||||
const saving = ref(false)
|
||||
const dicts = ref<DictVO[]>([])
|
||||
const current = ref<DictVO | null>(null)
|
||||
const items = ref<DictItemVO[]>([])
|
||||
// 层级支持来自**字典项接口**(不在字典列表的 VO 上),所以单独记一个
|
||||
/** 层级支持来自**字典项接口**(不在字典列表的 VO 上),所以单独记一个标记 */
|
||||
const hierarchical = ref(false)
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const editing = ref<DictItemVO | null>(null)
|
||||
|
||||
/**
|
||||
* 表单模型:可空字段一律声明成 `| null`。
|
||||
*
|
||||
* 因为 `n-input` / `n-input-number` 被清空时回传的是 `null`,
|
||||
* 声明成可选(`?: string`)会在 v-model 上类型打架(运行期没事,vue-tsc 会红)。
|
||||
*/
|
||||
const form = ref<{
|
||||
itemCode: string
|
||||
itemName: string
|
||||
parentItemCode?: string
|
||||
sortNo?: number
|
||||
}>({ itemCode: '', itemName: '', parentItemCode: undefined, sortNo: undefined })
|
||||
parentItemCode: string | null
|
||||
sortNo: number | null
|
||||
}>({
|
||||
itemCode: '',
|
||||
itemName: '',
|
||||
parentItemCode: null,
|
||||
sortNo: null
|
||||
})
|
||||
|
||||
/** 错误码 → 人话。界面直接显示后端 msg 也可以,但那句话对"我该做什么"帮助有限。 */
|
||||
function hint(e: unknown) {
|
||||
const err = e as { code?: number, message?: string }
|
||||
if (err?.code && DICT_ERROR_HINT[err.code]) {
|
||||
return DICT_ERROR_HINT[err.code]
|
||||
/** 内置字典里「职业」来自国标职业库,整列只读(连停用都不给) */
|
||||
const readOnlyDict = computed(() => current.value?.dictCode === 'OCCUPATION')
|
||||
|
||||
const columns = computed<DataTableColumns<DictItemVO>>(() => {
|
||||
const cols: DataTableColumns<DictItemVO> = [
|
||||
{
|
||||
title: '项码',
|
||||
key: 'itemCode',
|
||||
width: 150,
|
||||
render: (row) => h('span', { class: row.builtin ? 'dict__muted' : '' }, row.itemCode)
|
||||
},
|
||||
{
|
||||
title: '项名',
|
||||
key: 'itemName',
|
||||
width: 260,
|
||||
render: (row) => h('span', { class: row.status === 0 ? 'dict__off' : '' }, [
|
||||
row.itemName,
|
||||
row.builtin === true
|
||||
? h(NTag, { size: 'tiny', bordered: false, style: 'margin-left:6px' }, { default: () => '内置' })
|
||||
: null
|
||||
])
|
||||
}
|
||||
]
|
||||
|
||||
if (hierarchical.value) {
|
||||
cols.push({
|
||||
title: '上级项',
|
||||
key: 'parentItemCode',
|
||||
width: 150,
|
||||
render: (row) => h('span', { class: 'dict__muted' }, row.parentItemCode || '—')
|
||||
})
|
||||
}
|
||||
return err?.message || '操作失败'
|
||||
}
|
||||
|
||||
async function loadDicts() {
|
||||
cols.push(
|
||||
{
|
||||
title: '排序',
|
||||
key: 'sortNo',
|
||||
width: 80,
|
||||
render: (row) => h('span', { class: 'dict__muted' }, row.sortNo ?? '—')
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (row) => h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, type: row.status === 0 ? 'default' : 'success' },
|
||||
{ default: () => (row.status === 0 ? '已停用' : '启用') }
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (row) => {
|
||||
// 内置项:只能停用 / 恢复默认(没有 id,所以按 itemCode 走 toggle)
|
||||
if (row.builtin === true) {
|
||||
if (readOnlyDict.value) {
|
||||
return h('span', { class: 'dict__muted' }, '只读(国标)')
|
||||
}
|
||||
return h(
|
||||
NButton,
|
||||
{ text: true, type: row.status === 0 ? 'primary' : 'warning', size: 'tiny', onClick: () => toggle(row) },
|
||||
{ default: () => (row.status === 0 ? '恢复默认' : '停用') }
|
||||
)
|
||||
}
|
||||
// 自定义项:可改名、可启停、可删
|
||||
return h(NSpace, { size: 10, wrap: false }, {
|
||||
default: () => [
|
||||
h(NButton, { text: true, type: 'primary', size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: row.status === 0 ? 'primary' : 'warning', size: 'tiny', onClick: () => toggleCustom(row) },
|
||||
{ default: () => (row.status === 0 ? '启用' : '停用') }
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => remove(row) },
|
||||
{
|
||||
trigger: () => h(NButton, { text: true, type: 'error', size: 'tiny' }, { default: () => '删除' }),
|
||||
default: () => '删除这一项?已经用过它的历史数据不会变,但以后选不到了'
|
||||
}
|
||||
)
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return cols
|
||||
})
|
||||
|
||||
async function loadDicts(): Promise<void> {
|
||||
loadingDicts.value = true
|
||||
try {
|
||||
const res = await getDicts()
|
||||
dicts.value = res.data ?? []
|
||||
dicts.value = await getDicts() ?? []
|
||||
} finally {
|
||||
loadingDicts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
async function loadItems(): Promise<void> {
|
||||
if (!current.value) {
|
||||
return
|
||||
}
|
||||
loadingItems.value = true
|
||||
try {
|
||||
const res = await getDictItems(current.value.dictCode)
|
||||
items.value = res.data?.list ?? []
|
||||
hierarchical.value = res.data?.hierarchical === true
|
||||
const vo = await getDictItems(current.value.dictCode)
|
||||
items.value = vo.list ?? []
|
||||
hierarchical.value = vo.hierarchical === true
|
||||
} finally {
|
||||
loadingItems.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pick(d: DictVO) {
|
||||
function pick(d: DictVO): void {
|
||||
current.value = d
|
||||
// 先清掉上一次的层级标记:否则从一个有层级的字典切到没有的字典时,
|
||||
// 「上级项」这一列会继续显示(而它对新字典毫无意义)
|
||||
hierarchical.value = false
|
||||
loadItems()
|
||||
void loadItems()
|
||||
}
|
||||
|
||||
function reload() {
|
||||
loadDicts()
|
||||
loadItems()
|
||||
function reload(): void {
|
||||
void loadDicts()
|
||||
void loadItems()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
function openCreate(): void {
|
||||
editing.value = null
|
||||
form.value = { itemCode: '', itemName: '', parentItemCode: undefined, sortNo: undefined }
|
||||
form.value = { itemCode: '', itemName: '', parentItemCode: null, sortNo: null }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: DictItemVO) {
|
||||
function openEdit(row: DictItemVO): void {
|
||||
editing.value = row
|
||||
form.value = {
|
||||
itemCode: row.itemCode,
|
||||
itemName: row.itemName,
|
||||
parentItemCode: row.parentItemCode ?? undefined,
|
||||
sortNo: row.sortNo
|
||||
parentItemCode: row.parentItemCode ?? null,
|
||||
sortNo: row.sortNo ?? null
|
||||
}
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
async function save(): Promise<void> {
|
||||
if (!current.value) {
|
||||
return
|
||||
}
|
||||
if (!form.value.itemCode.trim() || !form.value.itemName.trim()) {
|
||||
Message.warning('项码与项名都必填')
|
||||
message.warning('项码与项名都必填')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value?.id) {
|
||||
// ⚠️ 只传项名与排序:项码不在接口契约里(结构性保证)
|
||||
// ⚠️ 只传项名与排序:项码不在修改接口的契约里(结构性保证,不是界面藏起来)
|
||||
await updateDictItem(editing.value.id, {
|
||||
itemName: form.value.itemName.trim(),
|
||||
sortNo: form.value.sortNo
|
||||
sortNo: form.value.sortNo ?? undefined
|
||||
})
|
||||
Message.success('已保存')
|
||||
message.success('已保存')
|
||||
} else {
|
||||
await createDictItem(current.value.dictCode, {
|
||||
itemCode: form.value.itemCode.trim(),
|
||||
itemName: form.value.itemName.trim(),
|
||||
parentItemCode: form.value.parentItemCode?.trim() || undefined,
|
||||
sortNo: form.value.sortNo
|
||||
sortNo: form.value.sortNo ?? undefined
|
||||
})
|
||||
Message.success('已新增')
|
||||
message.success('已新增')
|
||||
}
|
||||
modalVisible.value = false
|
||||
reload()
|
||||
} catch (e) {
|
||||
Message.error(hint(e))
|
||||
} catch {
|
||||
// 服务端 msg 已由 http 层提示过(如 40036「下面还有子项」、40001「内置项不能改名」)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 内置项:停用 / 恢复默认。 */
|
||||
async function toggle(row: DictItemVO) {
|
||||
/** 内置项:停用 / 恢复默认 */
|
||||
async function toggle(row: DictItemVO): Promise<void> {
|
||||
if (!current.value) {
|
||||
return
|
||||
}
|
||||
const enable = row.status === 0
|
||||
try {
|
||||
await toggleBuiltinItem(current.value.dictCode, row.itemCode, enable)
|
||||
Message.success(enable ? '已恢复默认' : '已停用')
|
||||
message.success(enable ? '已恢复默认' : '已停用')
|
||||
reload()
|
||||
} catch (e) {
|
||||
Message.error(hint(e))
|
||||
} catch {
|
||||
// 同上:提示已由 http 层给出
|
||||
}
|
||||
}
|
||||
|
||||
/** 自定义项:改状态。 */
|
||||
async function toggleCustom(row: DictItemVO) {
|
||||
/** 自定义项:改状态 */
|
||||
async function toggleCustom(row: DictItemVO): Promise<void> {
|
||||
if (!row.id) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateDictItem(row.id, { status: row.status === 0 ? 1 : 0 })
|
||||
Message.success(row.status === 0 ? '已启用' : '已停用')
|
||||
message.success(row.status === 0 ? '已启用' : '已停用')
|
||||
reload()
|
||||
} catch (e) {
|
||||
Message.error(hint(e))
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: DictItemVO) {
|
||||
async function remove(row: DictItemVO): Promise<void> {
|
||||
if (!row.id) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteDictItem(row.id)
|
||||
Message.success('已删除')
|
||||
message.success('已删除')
|
||||
reload()
|
||||
} catch (e) {
|
||||
Message.error(hint(e))
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
loadDicts()
|
||||
void loadDicts()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dict {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<template>
|
||||
<div class="md">
|
||||
<!-- 左:字典列表(独立卡片,与右栏之间留 16px) -->
|
||||
<NCard class="md__side" :bordered="false" size="small">
|
||||
<template #header>
|
||||
<div class="md__side-head">
|
||||
<AppIcon name="dict" :size="15" />
|
||||
<span>字典</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="md__hint">{{ dicts.length }} 个</span>
|
||||
</template>
|
||||
|
||||
&__layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
<NSpin :show="loadingDicts" class="md__list-wrap">
|
||||
<NEmpty v-if="!dicts.length" class="md__empty" description="没有可维护的字典" />
|
||||
<div
|
||||
v-for="d in dicts"
|
||||
:key="d.dictCode"
|
||||
class="md__row"
|
||||
:class="{ 'md__row--on': current?.dictCode === d.dictCode }"
|
||||
@click="pick(d)"
|
||||
>
|
||||
<span class="md__row-name">{{ d.dictName }}</span>
|
||||
<NTag v-if="d.builtin" size="small" :bordered="false" type="info">
|
||||
内置
|
||||
</NTag>
|
||||
<span class="md__hint">{{ d.itemCount }}</span>
|
||||
</div>
|
||||
</NSpin>
|
||||
</NCard>
|
||||
|
||||
&__side {
|
||||
flex: none;
|
||||
width: 220px;
|
||||
border-right: 1px solid var(--color-border-1);
|
||||
}
|
||||
<!-- 右:字典项(卡片本体就是列表页高度链的第一环) -->
|
||||
<div class="md__main">
|
||||
<NCard v-if="!current" class="md__blank" :bordered="false" size="small">
|
||||
<NEmpty description="从左边选一个字典" />
|
||||
</NCard>
|
||||
|
||||
&__spin {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
<NCard v-else class="md__main-card page-card" :bordered="false" size="small">
|
||||
<template #header>
|
||||
<div class="dict__head">
|
||||
<span class="dict__title">{{ current.dictName }}</span>
|
||||
<span class="dict__code">{{ current.dictCode }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NSpace :size="8">
|
||||
<NButton @click="reload">
|
||||
<template #icon>
|
||||
<AppIcon name="refresh" :size="16" />
|
||||
</template>
|
||||
刷新
|
||||
</NButton>
|
||||
<NButton type="primary" :disabled="readOnlyDict" @click="openCreate">
|
||||
新增项
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
<!-- ⚠️ 三种来源的说明必须摆在界面上:不说清的话,管理员会以为"内置项改不了"是权限不够 -->
|
||||
<NAlert v-if="current.builtin" class="dict__note" type="default" :show-icon="false">
|
||||
<template v-if="readOnlyDict">
|
||||
这一项来自<strong>国标职业库</strong>,整列只读(那是国家标准,不是本系统的取值)。
|
||||
</template>
|
||||
<template v-else>
|
||||
这是内置字典:项名<strong>不可改、不可删</strong>,只能<strong>停用</strong>(可一键恢复默认)。
|
||||
要加值请用「新增项」—— 追加的项可以随时改名或删除。
|
||||
内置项照抄官方表格,改名会让导出汇总表与官方模板对不上。
|
||||
</template>
|
||||
</NAlert>
|
||||
|
||||
&:hover {
|
||||
background: var(--color-fill-1);
|
||||
}
|
||||
<div class="page-card__body">
|
||||
<!--
|
||||
⚠️ Naive 的表格**没有** `scroll` prop(那是 Arco 的 API):
|
||||
横向用 `:scroll-x`(数值 = 列宽合计;有「上级项」列时多 150),
|
||||
纵向靠 `flex-height` + 高度链
|
||||
-->
|
||||
<NDataTable
|
||||
flex-height
|
||||
style="height: 100%"
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:loading="loadingItems"
|
||||
:row-key="row => String(row.itemCode)"
|
||||
:scroll-x="hierarchical ? 930 : 780"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
&--on {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
}
|
||||
<!-- 新增 / 编辑 -->
|
||||
<NModal
|
||||
v-model:show="modalVisible"
|
||||
preset="card"
|
||||
:title="editing ? '编辑字典项' : '新增字典项'"
|
||||
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<!--
|
||||
⚠️ `NForm` / `NFormItem` 必须显式 import(本仓库没有 auto-import):
|
||||
少了它们,`<NForm>` 会被当成未知元素原样渲染,label 与 feedback 样式全丢,
|
||||
而且**不报错**(这是这一页踩过的坑)。
|
||||
-->
|
||||
<NForm label-placement="top">
|
||||
<NFormItem label="项码" required>
|
||||
<!-- 项码建后不可改是**结构性**的:编辑时禁用输入,后端接口的请求体里也没有这个字段 -->
|
||||
<NInput
|
||||
v-model:value="form.itemCode"
|
||||
:disabled="editing !== null"
|
||||
placeholder="如 大学本科 / TECH_SCHOOL(建后不可改)"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="dict__hint">
|
||||
项码是历史数据里真正存的值,<strong>建后不可改</strong> ——
|
||||
改了它,过去填过的值会指向不存在的项,而查询不报错、只显示空白。
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
<NFormItem label="项名" required>
|
||||
<NInput v-model:value="form.itemName" placeholder="界面上给用户看的文字" />
|
||||
</NFormItem>
|
||||
|
||||
&__count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<NFormItem v-if="hierarchical" label="上级项码">
|
||||
<NInput v-model:value="form.parentItemCode" placeholder="留空 = 顶层项" />
|
||||
</NFormItem>
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
<NFormItem label="排序">
|
||||
<NInputNumber v-model:value="form.sortNo" :min="0" :step="10" placeholder="数字越小越靠前" class="dict__num" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="modalVisible = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="saving" @click="save">
|
||||
保存
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
<style scoped>
|
||||
/**
|
||||
* 主从版面:左右各一张独立卡片,间隔 16px(`--xt-gap`)。
|
||||
*
|
||||
* ⚠️ 高度算式与全局 `.page-card` **同源**(`--xt-header-h` / `--xt-content-pad`):
|
||||
* 写死 `calc(100vh - 56px - 28px)` 会比内容区实际高度多 4px,多出一条无意义的滚动条。
|
||||
*/
|
||||
.md {
|
||||
display: flex;
|
||||
gap: var(--xt-gap);
|
||||
height: calc(100vh - var(--xt-header-h) - var(--xt-content-pad) * 2);
|
||||
}
|
||||
|
||||
&__code {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
/* 左栏:卡片自己就是 flex 列(Naive 的 .n-card 已是 column),宽度固定、不参与伸缩 */
|
||||
.md__side {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
width: 240px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
/**
|
||||
* 卡片内容区变成"能撑满的 flex 列",列表才有确定高度、自己滚(高度链的第一环)。
|
||||
*
|
||||
* ⚠️ 类名是 `.n-card-content`(naive 的 `cB("card-content")`),**不是** `.n-card__content`:
|
||||
* 后者不匹配任何元素,写错了整条规则静默失效。
|
||||
*/
|
||||
.md__side :deep(.n-card-content) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 40px;
|
||||
color: var(--color-text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.md__side-head {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.md__hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
&__off {
|
||||
color: var(--color-text-3);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.md__list-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
margin-left: 6px;
|
||||
.md__empty {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.md__row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 7px 10px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.md__row:hover {
|
||||
background: var(--xt-band-bg);
|
||||
}
|
||||
|
||||
.md__row--on {
|
||||
background: #eef4ff;
|
||||
border-color: #cfe0ff;
|
||||
}
|
||||
|
||||
.md__row-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 右栏:卡片就是列表页高度链的第一环,所以它要撑满左栏高度、且能被压缩到 0 以下不溢出 */
|
||||
.md__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.md__main-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 高度链第二环:卡片内容区变成"能撑满的 flex 列",下面的表格才拿得到确定高度 */
|
||||
.md__main-card :deep(.n-card-content) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 空态也占一张卡片:右侧留一块灰底会让人以为"页面没加载完" */
|
||||
.md__blank {
|
||||
flex: 1;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.md__blank :deep(.n-card-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dict__head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.dict__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dict__code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.dict__note {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dict__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.dict__num {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dict__muted,
|
||||
:deep(.dict__muted) {
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.dict__off,
|
||||
:deep(.dict__off) {
|
||||
color: #c2c2c2;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* ≤1100px:左栏收窄一档,别把右栏挤到撑破容器 */
|
||||
@media (max-width: 1100px) {
|
||||
.md__side {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<ErrorPage :code="403"></ErrorPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ErrorPage from './components/ErrorPage.vue'
|
||||
|
||||
/** 403:已登录但**没有这个权限**(后端用真实 HTTP 403 返回,不是业务码) */
|
||||
defineOptions({ name: 'Error403' })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<template>
|
||||
<ErrorPage :code="403" />
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<ErrorPage :code="404"></ErrorPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ErrorPage from './components/ErrorPage.vue'
|
||||
|
||||
/** 404:路由没匹配上(也叫"菜单指向了不存在的页面") */
|
||||
defineOptions({ name: 'Error404' })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<template>
|
||||
<ErrorPage :code="404" />
|
||||
</template>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<ErrorPage :code="500"></ErrorPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ErrorPage from './components/ErrorPage.vue'
|
||||
|
||||
defineOptions({ name: 'Error500' })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -1,48 +1,52 @@
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<section class="error__container">
|
||||
<div class="error__img">
|
||||
<component :is="IconMap[props.code]" class="error__icon"></component>
|
||||
</div>
|
||||
|
||||
<div class="error__tip">
|
||||
<div class="error__tip--a">抱歉!</div>
|
||||
<div class="error__tip--b">当前页面不存在...</div>
|
||||
<div class="error__tip--c">请检查您输入的网址是否正确,或点击下面的按钮返回首页</div>
|
||||
<a-button type="primary" shape="round" size="large" @click="back">{{ countDownTime || '' }} 返回首页</a-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import Icon403 from '@/components/icons/Icon403.vue'
|
||||
import Icon404 from '@/components/icons/Icon404.vue'
|
||||
import Icon500 from '@/components/icons/Icon500.vue'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
code?: number
|
||||
}
|
||||
/**
|
||||
* 错误页(403 / 404 共用)。
|
||||
*
|
||||
* 两种码的**文案必须不同**:403 是"你没有这个权限",404 是"地址不对"。
|
||||
* 上一版把两者都写成"当前页面不存在",于是被权限拦下来的人会去反复检查网址 ——
|
||||
* 而问题根本不在网址上。
|
||||
*
|
||||
* 倒计时只用来提示"还有几秒回到首页",**到 0 不自动跳**:
|
||||
* 自动跳转会把原因文案一起带走,用户还没看清就被送到别处。
|
||||
*/
|
||||
defineOptions({ name: 'ErrorPage' })
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
code: 403
|
||||
})
|
||||
const props = withDefaults(defineProps<{ code?: number }>(), { code: 403 })
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const IconMap: Record<number, Component> = {
|
||||
403: Icon403,
|
||||
404: Icon404,
|
||||
500: Icon500
|
||||
}
|
||||
|
||||
const countDownTime = ref(5)
|
||||
const seconds = ref(5)
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const content = computed(() => props.code === 403
|
||||
? {
|
||||
title: '没有访问权限',
|
||||
desc: '这个页面需要更高的权限。如果你认为自己应该能看到它,请联系本单位管理员调整角色后再试。'
|
||||
}
|
||||
: {
|
||||
title: '页面不存在',
|
||||
desc: '请检查网址是否正确,或从左侧菜单重新进入。'
|
||||
})
|
||||
|
||||
function back(): void {
|
||||
void router.replace('/')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onCountDownTime()
|
||||
timer = setInterval(() => {
|
||||
if (seconds.value > 0) {
|
||||
seconds.value -= 1
|
||||
return
|
||||
}
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -50,114 +54,50 @@ onBeforeUnmount(() => {
|
||||
clearInterval(timer)
|
||||
}
|
||||
})
|
||||
|
||||
// 返回页面
|
||||
const back = () => {
|
||||
router.replace({ path: '/' })
|
||||
}
|
||||
|
||||
// 倒计时
|
||||
function onCountDownTime() {
|
||||
timer = setInterval(() => {
|
||||
if (countDownTime.value) {
|
||||
countDownTime.value--
|
||||
} else {
|
||||
// back()
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.error-page {
|
||||
<template>
|
||||
<div class="err">
|
||||
<div class="err__code">{{ code }}</div>
|
||||
<div class="err__title">{{ content.title }}</div>
|
||||
<p class="err__desc">{{ content.desc }}</p>
|
||||
<NButton type="primary" size="medium" @click="back">
|
||||
返回首页{{ seconds > 0 ? `(${seconds}s)` : '' }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.err {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-bg-1);
|
||||
height: 100vh;
|
||||
padding: 0 24px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.error {
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__img {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
max-width: 90%;
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&--a {
|
||||
margin-bottom: 20px;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
line-height: 40px;
|
||||
color: rgb(var(--primary-6));
|
||||
opacity: 0;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
&--b {
|
||||
margin-bottom: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
line-height: 24px;
|
||||
color: var(--color-text-1);
|
||||
opacity: 0;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-delay: 0.1s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
&--c {
|
||||
padding: 0 30px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--color-text-2);
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-delay: 0.2s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
}
|
||||
.err__code {
|
||||
font-size: 96px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #0052d9;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(60px);
|
||||
}
|
||||
.err__title {
|
||||
margin-top: 18px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.err__desc {
|
||||
max-width: 520px;
|
||||
margin: 10px 0 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--xt-text-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,83 +1,23 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" :width="900" :mask-closable="false" :footer="false"
|
||||
unmount-on-close @cancel="close">
|
||||
<template #title>
|
||||
<span>导出详情</span>
|
||||
<a-tag v-if="detail" class="ed__head-tag" :color="EXPORT_STATUS[detail.status]?.color ?? 'gray'">
|
||||
{{ detail.statusText || EXPORT_STATUS[detail.status]?.text }}
|
||||
</a-tag>
|
||||
<span v-if="detail" class="ed__head-sub">{{ detail.exportName }}</span>
|
||||
</template>
|
||||
|
||||
<a-spin :loading="loading" class="ed__spin">
|
||||
<template v-if="detail">
|
||||
<a-alert v-if="detail.status === 3" type="error" class="ed__alert">
|
||||
<template #title>打包失败:{{ detail.errorMsg || '未知原因' }}</template>
|
||||
<template #default>
|
||||
失败不会产生任何交付物,重新导出即可(**不会**因此产生重复计费或脏数据)。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- 进度:处理中每 2 秒自动刷新一次,完成后自动停 -->
|
||||
<div class="ed__bar">
|
||||
<a-progress :percent="(detail.progress ?? 0) / 100" :status="progressStatus"
|
||||
:animation="detail.status === 1" />
|
||||
<div class="ed__muted">
|
||||
{{ detail.stepText || EXPORT_STATUS[detail.status]?.text }}
|
||||
<template v-if="detail.fileCount"> · 共 {{ detail.fileCount }} 人</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-descriptions :column="2" size="small" bordered class="ed__desc">
|
||||
<a-descriptions-item label="任务">{{ detail.taskName || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ fmt(detail.createTime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="完成时间">{{ fmt(detail.finishTime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="保留到">{{ fmt(detail.expireTime) }}(到期后需重新导出)</a-descriptions-item>
|
||||
<a-descriptions-item label="原始体积">
|
||||
{{ detail.stats?.rawSizeMb ?? '—' }} MB
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="包体 / 压缩率">
|
||||
{{ detail.stats?.finalSizeMb ?? '—' }} MB
|
||||
<span class="ed__muted">({{ detail.stats?.compressRatio || '—' }})</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 产物 -->
|
||||
<div class="ed__section">产物</div>
|
||||
<a-empty v-if="!(detail.resultFiles ?? []).length" description="还没有产物(未完成或失败)" />
|
||||
<a-table v-else :data="detail.resultFiles ?? []" :pagination="false" size="small"
|
||||
row-key="name" :bordered="{ cell: true }">
|
||||
<template #columns>
|
||||
<a-table-column title="文件名" data-index="name" />
|
||||
<a-table-column title="体积" :width="100" align="center">
|
||||
<template #cell="{ record }">{{ record.sizeMb }} MB</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="校验值(sha256)" :width="240">
|
||||
<template #cell="{ record }">
|
||||
<span class="ed__code" :title="record.checksum">{{ shortHash(record.checksum) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="110" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-link :loading="downloading" @click="onDownload(record.part ?? 0)">下载</a-link>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<div class="ed__muted ed__tip">
|
||||
下载的是 ZIP 包,解压后目录结构为「{批次号}-{任务名称}/班级/材料类型/文件」+ 根目录的汇总表。
|
||||
校验值用于核对传输是否完整(本机核对:<span class="ed__code">certutil -hashfile 包名 SHA256</span>)。
|
||||
</div>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ExportTaskVO } from '@/apis/xt/export-task'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { ExportResultFile, ExportTaskVO } from '@/apis/xt/export-task'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDataTable,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NDrawer,
|
||||
NDrawerContent,
|
||||
NEmpty,
|
||||
NProgress,
|
||||
NSpin,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onBeforeUnmount, ref } from 'vue'
|
||||
import { downloadExportFile, EXPORT_STATUS, getExportDetail } from '@/apis/xt/export-task'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
|
||||
/**
|
||||
* 导出详情(进度 + 产物 + 下载)。
|
||||
@@ -86,11 +26,12 @@ import { downloadExportFile, EXPORT_STATUS, getExportDetail } from '@/apis/xt/ex
|
||||
*
|
||||
* 打包是后台任务,进度只能轮询。**完成后必须停**:不停的话用户开着这个抽屉
|
||||
* 就等于一直在打接口;而"忘了停"在演示时看不出来,只在监控里变成莫名其妙的 QPS。
|
||||
* 所以 `load()` 每次拿到状态后自己决定继续还是停(并且关闭抽屉、组件卸载时都清一次)。
|
||||
*
|
||||
* ## 下载为什么要走 axios/fetch 而不是直接给个链接
|
||||
* ## 下载为什么要取 blob 而不是直接给个链接
|
||||
*
|
||||
* `/exports/{id}/file` 是要登录的(带 `Authorization` 头),
|
||||
* 直接 `window.open` 会 401。所以走 `downloadExportFile`(取 blob 再本地触发下载)。
|
||||
* 直接 `window.open` 会 401。所以统一走 `downloadExportFile`(取 blob 再本地触发下载)。
|
||||
*/
|
||||
defineOptions({ name: 'ExportDetailDrawer' })
|
||||
|
||||
@@ -98,25 +39,63 @@ const emit = defineEmits<{
|
||||
(e: 'changed'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloadingPart = ref<number | null>(null)
|
||||
const detail = ref<ExportTaskVO | null>(null)
|
||||
const exportId = ref<number>(0)
|
||||
let timer: number | null = null
|
||||
/** 完成/失败只通知列表刷新一次(轮询每 2 秒会反复拿到同一个终态) */
|
||||
let finishedNotified = false
|
||||
|
||||
const progressStatus = computed(() => {
|
||||
const progressStatus = computed<'success' | 'error' | undefined>(() => {
|
||||
const s = detail.value?.status
|
||||
if (s === 3) {
|
||||
// Arco 的 Progress 状态里"失败"叫 danger(不是 error)
|
||||
return 'danger'
|
||||
return 'error'
|
||||
}
|
||||
if (s === 2) {
|
||||
return 'success'
|
||||
}
|
||||
return 'normal'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const running = computed(() => {
|
||||
const s = detail.value?.status
|
||||
return s === 0 || s === 1
|
||||
})
|
||||
|
||||
const columns: DataTableColumns<ExportResultFile> = [
|
||||
{ title: '文件名', key: 'name', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '体积',
|
||||
key: 'sizeMb',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (row) => `${row.sizeMb} MB`
|
||||
},
|
||||
{
|
||||
title: '校验值(sha256)',
|
||||
key: 'checksum',
|
||||
width: 220,
|
||||
render: (row) => h('span', { class: 'ed__code', title: row.checksum }, shortHash(row.checksum))
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (row) => h(NButton, {
|
||||
text: true,
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
loading: downloadingPart.value === (row.part ?? 0),
|
||||
onClick: () => onDownload(row.part ?? 0)
|
||||
}, { default: () => '下载' })
|
||||
}
|
||||
]
|
||||
|
||||
function fmt(v: string | null) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
@@ -129,13 +108,16 @@ function shortHash(v: string | null) {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await getExportDetail(exportId.value)
|
||||
detail.value = res.data ?? null
|
||||
// 只在"还在跑"的时候继续轮询;完成/失败各刷一次就停
|
||||
if (detail.value && (detail.value.status === 0 || detail.value.status === 1)) {
|
||||
detail.value = await getExportDetail(exportId.value) ?? null
|
||||
if (running.value) {
|
||||
startTimer()
|
||||
} else {
|
||||
// 完成 / 失败:各刷一次就停
|
||||
stopTimer()
|
||||
if (detail.value && !finishedNotified) {
|
||||
finishedNotified = true
|
||||
emit('changed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +139,7 @@ async function open(id: number) {
|
||||
exportId.value = id
|
||||
visible.value = true
|
||||
loading.value = true
|
||||
finishedNotified = false
|
||||
try {
|
||||
await load()
|
||||
} finally {
|
||||
@@ -164,21 +147,29 @@ async function open(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
stopTimer()
|
||||
visible.value = false
|
||||
emit('changed')
|
||||
/**
|
||||
* 抽屉收起时:停轮询 + 通知列表刷新。
|
||||
*
|
||||
* ⚠️ 关抽屉这条路径必须**自己**收尾:`closable` 的 × 是直接改 `show`,
|
||||
* 不会走任何"关闭回调"—— 不在 `update:show` 里停轮询,用户关掉抽屉后
|
||||
* 请求还会一直打下去(演示时看不出来,只在监控里变成莫名其妙的 QPS)。
|
||||
*/
|
||||
function onShowChange(v: boolean) {
|
||||
if (!v) {
|
||||
stopTimer()
|
||||
emit('changed')
|
||||
}
|
||||
}
|
||||
|
||||
async function onDownload(fileIndex: number) {
|
||||
downloading.value = true
|
||||
downloadingPart.value = fileIndex
|
||||
try {
|
||||
await downloadExportFile(exportId.value, fileIndex)
|
||||
Message.success('已开始下载')
|
||||
message.success('已开始下载')
|
||||
} catch (e) {
|
||||
Message.error((e as Error).message || '下载失败')
|
||||
message.error((e as Error).message || '下载失败')
|
||||
} finally {
|
||||
downloading.value = false
|
||||
downloadingPart.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,50 +178,126 @@ onBeforeUnmount(stopTimer)
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ed {
|
||||
&__head-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
<template>
|
||||
<NDrawer v-model:show="visible" :width="900" placement="right" :mask-closable="false" @update:show="onShowChange">
|
||||
<NDrawerContent :native-scrollbar="false" closable>
|
||||
<template #header>
|
||||
<span>导出详情</span>
|
||||
<NTag v-if="detail" class="ed__head-tag" size="small" :bordered="false" :type="tagOf(EXPORT_STATUS, detail.status).type">
|
||||
{{ detail.statusText || tagOf(EXPORT_STATUS, detail.status).text }}
|
||||
</NTag>
|
||||
<span v-if="detail" class="ed__head-sub">{{ detail.exportName }}</span>
|
||||
</template>
|
||||
|
||||
&__head-sub {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<NSpin :show="loading">
|
||||
<template v-if="detail">
|
||||
<NAlert v-if="detail.status === 3" type="error" class="ed__alert" title="打包失败">
|
||||
{{ detail.errorMsg || '未知原因' }}
|
||||
<div class="ed__muted">失败不会产生任何交付物,重新导出即可(不会因此产生重复计费或脏数据)。</div>
|
||||
</NAlert>
|
||||
|
||||
&__spin {
|
||||
display: block;
|
||||
}
|
||||
<!-- 进度:处理中每 2 秒自动刷新一次,完成后自动停 -->
|
||||
<div class="ed__bar">
|
||||
<NProgress
|
||||
type="line"
|
||||
:percentage="Math.max(0, Math.min(100, detail.progress ?? 0))"
|
||||
:status="progressStatus"
|
||||
:processing="running"
|
||||
/>
|
||||
<div class="ed__muted">
|
||||
{{ detail.stepText || tagOf(EXPORT_STATUS, detail.status).text }}
|
||||
<template v-if="detail.fileCount"> · 共 {{ detail.fileCount }} 人</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
<NDescriptions :column="2" size="small" bordered>
|
||||
<NDescriptionsItem label="任务">{{ detail.taskName || '—' }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="创建时间">{{ fmt(detail.createTime) }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="完成时间">{{ fmt(detail.finishTime) }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="保留到">
|
||||
{{ fmt(detail.expireTime) }}(到期后需重新导出)
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="原始体积">{{ detail.stats?.rawSizeMb ?? '—' }} MB</NDescriptionsItem>
|
||||
<NDescriptionsItem label="包体 / 压缩率">
|
||||
{{ detail.stats?.finalSizeMb ?? '—' }} MB
|
||||
<span class="ed__muted">({{ detail.stats?.compressRatio || '—' }})</span>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
&__bar {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
<!-- 产物:文件名 / 体积 / sha256 / 下载 -->
|
||||
<div class="section-title">
|
||||
产物
|
||||
</div>
|
||||
<NEmpty v-if="!(detail.resultFiles ?? []).length" description="还没有产物(未完成或失败)" />
|
||||
<NDataTable
|
||||
v-else
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data="detail.resultFiles ?? []"
|
||||
:bordered="true"
|
||||
:row-key="(row: ExportResultFile) => `${row.part}-${row.name}`"
|
||||
:pagination="false"
|
||||
:max-height="240"
|
||||
:scroll-x="700"
|
||||
/>
|
||||
|
||||
&__section {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
<div class="ed__muted ed__tip">
|
||||
下载的是 ZIP 包,解压后目录结构为「{批次号}-{任务名称}/班级/材料类型/文件」+ 根目录的汇总表。
|
||||
校验值用于核对传输是否完整(本机核对:<span class="ed__code">certutil -hashfile 包名 SHA256</span>)。
|
||||
</div>
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<div v-if="running" class="ed__muted ed__tip">
|
||||
打包进行中,本页每 2 秒自动刷新;完成或失败后会自动停止。
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
&__code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
<style scoped>
|
||||
.ed__head-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
margin-top: 10px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.ed__head-sub {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.ed__alert {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* 进度块:抽屉内部的浅灰小格子(不再套一层白卡片) */
|
||||
.ed__bar {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ed__muted {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.ed__bar .ed__muted {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ⚠️ `.ed__code` 出现在**渲染函数**里(产物表的 sha256 列)。
|
||||
渲染函数生成的元素不带 `data-v-*`,普通 scoped 选择器匹配不到(不报错、样式静默失效)。 */
|
||||
:deep(.ed__code) {
|
||||
font-family: ui-monospace, sfmono-regular, menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ed__tip {
|
||||
margin-top: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,244 +1,26 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>导出中心</template>
|
||||
<template #extra>
|
||||
<span class="exp__hint">交付物:ZIP 包 + 根目录汇总表,目录结构按机构规范生成</span>
|
||||
</template>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-select v-model="taskId" placeholder="选择要导出的任务" size="small" class="exp__task"
|
||||
:loading="taskLoading" @change="onTaskChange">
|
||||
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">
|
||||
{{ t.taskName }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<a-radio-group v-model="onlyApproved" type="button" size="small" :disabled="!taskId"
|
||||
@change="reload">
|
||||
<a-radio :value="false">全部已提交</a-radio>
|
||||
<a-radio :value="true">仅终审通过</a-radio>
|
||||
</a-radio-group>
|
||||
<a-button size="small" :disabled="!taskId" @click="reload">
|
||||
<template #icon><IconRefresh /></template>
|
||||
重新检查
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-space>
|
||||
<span class="exp__hint">{{ checkedAt ? `上次检查:${checkedAt}` : '选择任务后自动检查' }}</span>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-empty v-if="!taskId" description="先选一个任务 —— 导出是按任务进行的" />
|
||||
|
||||
<a-spin v-else :loading="checking">
|
||||
<!--
|
||||
⚠️ 这里是**铁律**的落点:canExport=false 时按钮直接禁用,**不弹警告放行**。
|
||||
清单要具体到"谁 + 哪一项 + 怎么办",否则管理员只能去几百份材料里逐个翻。
|
||||
-->
|
||||
<a-alert v-if="precheck && !precheck.canExport" type="error" class="exp__alert">
|
||||
<template #title>
|
||||
前置检查未通过:{{ precheck.blocking.length }} 项不合格,<strong>无法导出</strong>。
|
||||
这是铁律 —— 不合格的文件不允许进入汇总包。
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-for="(a, i) in quickActions" :key="i" class="exp__action">
|
||||
· {{ a }}
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
<a-alert v-else-if="precheck" type="success" :show-icon="false" class="exp__alert">
|
||||
<template #title>
|
||||
检查通过:<strong>{{ precheck.candidateCount }}</strong> 人、
|
||||
<strong>{{ precheck.fileCount }}</strong> 个文件(原始
|
||||
{{ mb(precheck.rawSizeBytes) }} MB)可以打包。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-alert v-for="(w, i) in precheck?.warnings ?? []" :key="`w${i}`" type="warning"
|
||||
:show-icon="false" class="exp__alert">
|
||||
{{ w }}
|
||||
</a-alert>
|
||||
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="11">
|
||||
<div class="exp__card">
|
||||
<div class="exp__card-head">导出设置</div>
|
||||
<!-- 不引 a-form:这些是"输入即生效"的设置项,没有校验,加一层 form
|
||||
只会多要一个 model 绑定(Arco 的 a-form 要求 model 必填) -->
|
||||
<div class="exp__field">
|
||||
<div class="exp__label">包名(会写进 ZIP 文件名)</div>
|
||||
<a-input v-model="exportName" placeholder="如 202609机电认定-24中技数控1班"
|
||||
:max-length="60" show-word-limit />
|
||||
</div>
|
||||
<div class="exp__field">
|
||||
<div class="exp__label">分卷大小</div>
|
||||
<a-select v-model="splitSizeMb">
|
||||
<a-option :value="0">不分卷(一期只支持这一项)</a-option>
|
||||
</a-select>
|
||||
<div class="exp__hint">分卷还没做:几百人的批次超过 1GB 时会需要它</div>
|
||||
</div>
|
||||
<div class="exp__field">
|
||||
<a-checkbox v-model="desensitize">证件号脱敏导出</a-checkbox>
|
||||
<div class="exp__hint">
|
||||
名册里存的本来就是脱敏值,这个开关留给二期(明文导出需二次确认)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 阻断清单:逐条说清"谁 + 哪一项 + 怎么办" -->
|
||||
<div v-if="precheck && precheck.blocking.length" class="exp__card">
|
||||
<div class="exp__card-head">
|
||||
卡住的地方({{ precheck.blocking.length }})
|
||||
<span class="exp__hint">
|
||||
还没审完 {{ precheck.summary.unapproved }} · 缺材料 {{ precheck.summary.missing }} ·
|
||||
体积超标 {{ precheck.summary.sizeExceeded }} · 名字未规范 {{ precheck.summary.nameInvalid }}
|
||||
</span>
|
||||
</div>
|
||||
<a-table :data="precheck.blocking" :pagination="{ pageSize: 8, size: 'mini' }"
|
||||
size="mini" row-key="title" :bordered="{ cell: true }">
|
||||
<template #columns>
|
||||
<a-table-column title="类型" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="BLOCKING_TYPE[record.type]?.color ?? 'gray'" size="small">
|
||||
{{ BLOCKING_TYPE[record.type]?.text ?? record.type }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="问题">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.title }}</div>
|
||||
<div class="exp__hint">{{ record.action }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="13">
|
||||
<div class="exp__card">
|
||||
<div class="exp__card-head">
|
||||
目录结构预览
|
||||
<span class="exp__hint">
|
||||
{{ tree ? `${tree.personCount} 人 × 各类材料 = ${tree.fileCount} 个文件` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<!--
|
||||
目录树用「半角制表符 + CSS 缩进」,**不用全角空格**:
|
||||
全角空格(U+3000)会被 eslint 的 no-irregular-whitespace 拦下,
|
||||
而靠空白对齐的写法在 `--fix` 之后必然被重排 —— 那样每次改这个文件
|
||||
都要打一次仗。缩进交给 `.exp__tree-*` 的 padding-left。
|
||||
-->
|
||||
<div v-if="tree" class="exp__tree">
|
||||
<div class="exp__tree-root">{{ tree.root }}/</div>
|
||||
<div v-for="c in tree.children" :key="c.path" class="exp__tree-org">
|
||||
<div class="exp__tree-dir">
|
||||
<span class="exp__br">|-- </span>{{ c.name }}/
|
||||
<span class="exp__hint">{{ c.studentCount }} 人</span>
|
||||
</div>
|
||||
<div v-for="m in c.children" :key="m.path" class="exp__tree-mat">
|
||||
<div class="exp__tree-dir2">
|
||||
<span class="exp__br">|-- </span>{{ m.name }}/
|
||||
<span class="exp__hint">{{ m.children.length }} 个</span>
|
||||
</div>
|
||||
<div v-for="f in m.children.slice(0, 2)" :key="f.path" class="exp__tree-file">
|
||||
<span class="exp__br">`-- </span>{{ f.name }}
|
||||
<span class="exp__hint">{{ f.sizeKb }}KB</span>
|
||||
</div>
|
||||
<div v-if="m.children.length > 2" class="exp__tree-file exp__hint">
|
||||
<span class="exp__br">`-- </span>…(其余 {{ m.children.length - 2 }} 个)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exp__tree-file">
|
||||
<span class="exp__br">`-- </span>考生线下报名信息导入模板(汇总).xlsx
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="没有可预览的内容" />
|
||||
<div class="exp__hint exp__tree-note">
|
||||
预览与真正打包**共用同一份构建代码**,所以这里看到的就是解压后的样子
|
||||
(每个材料目录最多列 2 个文件,避免几百人时刷屏)。
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 底部动作条:按钮的可用性就是铁律的落点 -->
|
||||
<div class="exp__footer">
|
||||
<span class="exp__hint">
|
||||
导出是后台任务,完成后在这里下载;产物保留 7 天
|
||||
</span>
|
||||
<a-space>
|
||||
<a-tooltip :disabled="canExport" :content="blockReason">
|
||||
<a-button type="primary" size="small" :disabled="!canExport" :loading="creating"
|
||||
@click="onCreate">
|
||||
<template #icon><IconDownload /></template>
|
||||
开始导出
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="exp__card-head">导出记录</div>
|
||||
<a-table :data="records" :loading="listLoading" row-key="id" size="small"
|
||||
:pagination="pagination" :scroll="{ x: 900 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="包名 / 任务" :width="240" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<div class="exp__name">{{ record.exportName }}</div>
|
||||
<div class="exp__hint">{{ record.taskName || '—' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="EXPORT_STATUS[record.status]?.color ?? 'gray'" size="small">
|
||||
{{ record.statusText || EXPORT_STATUS[record.status]?.text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="进度" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<a-progress :percent="(record.progress ?? 0) / 100" size="small"
|
||||
:status="record.status === 3 ? 'danger' : (record.status === 2 ? 'success' : 'normal')" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="人数 / 包体" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.fileCount ?? '—' }} 人</div>
|
||||
<div class="exp__hint">{{ record.stats?.finalSizeMb ?? '—' }} MB</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" :width="150">
|
||||
<template #cell="{ record }">{{ fmt(record.createTime) }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="160" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space size="mini">
|
||||
<a-link @click="onOpen(record)">详情</a-link>
|
||||
<a-link v-if="record.status === 2 && record.resultFiles?.length"
|
||||
@click="onQuickDownload(record)">
|
||||
下载
|
||||
</a-link>
|
||||
<a-link v-if="record.status === 3" status="warning" @click="onOpen(record)">看原因</a-link>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<ExportDetailDrawer ref="detailRef" @changed="loadRecords" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ExportPrecheck, ExportTaskVO, ExportTree } from '@/apis/xt/export-task'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { BizError } from '@/apis/types'
|
||||
import type { ExportBlocking, ExportPrecheck, ExportTaskVO, ExportTree } from '@/apis/xt/export-task'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NCheckbox,
|
||||
NDataTable,
|
||||
NEmpty,
|
||||
NInput,
|
||||
NRadioButton,
|
||||
NRadioGroup,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NTag,
|
||||
NTooltip,
|
||||
useDialog,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref, useTemplateRef } from 'vue'
|
||||
import {
|
||||
BLOCKING_TYPE,
|
||||
createExport,
|
||||
@@ -250,33 +32,43 @@ import {
|
||||
QUICK_ACTION_TEXT
|
||||
} from '@/apis/xt/export-task'
|
||||
import { getTaskList } from '@/apis/xt/task'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
import ExportDetailDrawer from './ExportDetailDrawer.vue'
|
||||
|
||||
/**
|
||||
* 导出中心(原型 STEP 07,契约 §12.4~§12.6)。
|
||||
* 导出中心(原型 STEP 07,契约 §12.4 ~ §12.6)。
|
||||
*
|
||||
* ## 一屏只做一件事:让"不允许带问题打包"变成按钮禁用
|
||||
*
|
||||
* 所以这一页的**主逻辑就是前置检查**:进页面先检查、改范围再检查、
|
||||
* 所以这一页的**主逻辑就是前置检查**:进页面先检查、改范围再检查,
|
||||
* `canExport=false` 就把「开始导出」禁用并把清单列出来。
|
||||
* 没有"点一下试试看"的分支 —— 也不该有。
|
||||
* **没有"点一下试试看"的分支** —— 服务端在 40035 里也**不带 force**,
|
||||
* 所以前端连"警告后放行"的按钮都不该有。
|
||||
*
|
||||
* ## 目录树预览不是装饰
|
||||
*
|
||||
* 客户拿到包才会发现问题(名字不对、少一个人、体积没压下去),
|
||||
* 而那时已经交出去了。预览把这些提前到"点导出之前",
|
||||
* 并且它与打包**共用同一份构建代码**,所以不会说谎。
|
||||
* 并且它与真正打包**共用同一份构建代码**,所以不会说谎。
|
||||
*/
|
||||
defineOptions({ name: 'Export' })
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const taskLoading = ref(false)
|
||||
const checking = ref(false)
|
||||
const listLoading = ref(false)
|
||||
const creating = ref(false)
|
||||
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
|
||||
/** 正在下载的导出行 id(只用于那一个按钮的 loading) */
|
||||
const downloading = ref<number | null>(null)
|
||||
const taskOptions = ref<Array<{ label: string, value: number }>>([])
|
||||
const taskId = ref<number | undefined>(undefined)
|
||||
const onlyApproved = ref(false)
|
||||
const exportName = ref('')
|
||||
/** 服务端支持分卷(`splitSizeMb`),界面暂时只开放"不分卷" */
|
||||
const splitSizeMb = ref(0)
|
||||
const desensitize = ref(false)
|
||||
const precheck = ref<ExportPrecheck | null>(null)
|
||||
@@ -297,12 +89,11 @@ const quickActions = computed(
|
||||
() => (precheck.value?.quickActions ?? []).map((k) => QUICK_ACTION_TEXT[k] ?? k)
|
||||
)
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
/** `remote` 模式:每页条数改了必须重新取数,否则"分页器说 50、表里 20" */
|
||||
const pagination = computed(() => listPagination({
|
||||
page: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
itemCount: total.value
|
||||
}))
|
||||
|
||||
function fmt(v: string | null) {
|
||||
@@ -313,11 +104,109 @@ function mb(bytes: number | null) {
|
||||
return bytes ? Math.round(bytes / 1024 / 1024 * 10) / 10 : 0
|
||||
}
|
||||
|
||||
const blockingColumns: DataTableColumns<ExportBlocking> = [
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (row) => {
|
||||
const meta = tagOf(BLOCKING_TYPE, row.type, row.type)
|
||||
return h(NTag, { size: 'small', bordered: false, type: meta.type }, { default: () => meta.text })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '问题',
|
||||
key: 'title',
|
||||
render: (row) => h('div', [
|
||||
h('div', row.title),
|
||||
row.action ? h('div', { class: 'exp__hint' }, row.action) : null
|
||||
])
|
||||
}
|
||||
]
|
||||
|
||||
const recordColumns = computed<DataTableColumns<ExportTaskVO>>(() => [
|
||||
{
|
||||
title: '包名 / 任务',
|
||||
key: 'exportName',
|
||||
width: 260,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => h('div', [
|
||||
h('div', { class: 'exp__name' }, row.exportName),
|
||||
h('div', { class: 'exp__hint' }, row.taskName || '—')
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'statusText',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (row) => {
|
||||
const meta = tagOf(EXPORT_STATUS, row.status)
|
||||
return h(NTag, { size: 'small', bordered: false, type: meta.type }, { default: () => row.statusText || meta.text })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
key: 'progress',
|
||||
width: 130,
|
||||
render: (row) => h('div', { class: 'exp__progress' }, [
|
||||
h('div', { class: 'exp__progress-bar' }, [
|
||||
h('div', {
|
||||
class: 'exp__progress-inner',
|
||||
style: `width:${Math.max(0, Math.min(100, row.progress ?? 0))}%;background:${row.status === 3 ? '#d54941' : (row.status === 2 ? '#00a870' : '#0052d9')}`
|
||||
})
|
||||
]),
|
||||
h('span', { class: 'exp__hint' }, `${row.progress ?? 0}%`)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '人数 / 包体',
|
||||
key: 'fileCount',
|
||||
width: 120,
|
||||
render: (row) => h('div', [
|
||||
h('div', `${row.fileCount ?? '—'} 人`),
|
||||
h('div', { class: 'exp__hint' }, `${row.stats?.finalSizeMb ?? '—'} MB`)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'createTime',
|
||||
width: 150,
|
||||
render: (row) => fmt(row.createTime)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
render: (row) => h(NSpace, { size: 12, justify: 'center' }, {
|
||||
default: () => [
|
||||
h(NButton, { text: true, size: 'small', onClick: () => onOpen(row) }, { default: () => '详情' }),
|
||||
row.status === 2 && row.resultFiles?.length
|
||||
? h(NButton, {
|
||||
text: true,
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
// 下载是"取 blob 再触发保存",期间给按钮一个 loading,避免被连点
|
||||
loading: downloading.value === row.id,
|
||||
onClick: () => onQuickDownload(row)
|
||||
}, { default: () => '下载' })
|
||||
: null,
|
||||
row.status === 3
|
||||
? h(NButton, { text: true, size: 'small', type: 'warning', onClick: () => onOpen(row) }, { default: () => '看原因' })
|
||||
: null
|
||||
]
|
||||
})
|
||||
}
|
||||
])
|
||||
|
||||
async function loadTasks() {
|
||||
taskLoading.value = true
|
||||
try {
|
||||
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
|
||||
const page = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (page?.list ?? []).map((t) => ({ label: t.taskName, value: t.id }))
|
||||
} finally {
|
||||
taskLoading.value = false
|
||||
}
|
||||
@@ -335,13 +224,13 @@ async function reload() {
|
||||
precheckExport(taskId.value, scope),
|
||||
previewExportTree(taskId.value, scope)
|
||||
])
|
||||
precheck.value = pre.data ?? null
|
||||
tree.value = tr.data ?? null
|
||||
precheck.value = pre ?? null
|
||||
tree.value = tr ?? null
|
||||
checkedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
if (!exportName.value) {
|
||||
const t = taskOptions.value.find((x) => x.id === taskId.value)
|
||||
const t = taskOptions.value.find((x) => x.value === taskId.value)
|
||||
if (t) {
|
||||
exportName.value = t.taskName.slice(0, 60)
|
||||
exportName.value = t.label.slice(0, 60)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -354,20 +243,20 @@ function onTaskChange() {
|
||||
precheck.value = null
|
||||
tree.value = null
|
||||
query.pageNum = 1
|
||||
reload()
|
||||
loadRecords()
|
||||
void reload()
|
||||
void loadRecords()
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await getExportList({
|
||||
const page = await getExportList({
|
||||
taskId: taskId.value,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
records.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
records.value = page?.list ?? []
|
||||
total.value = page?.total ?? 0
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
@@ -375,12 +264,18 @@ async function loadRecords() {
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
loadRecords()
|
||||
void loadRecords()
|
||||
}
|
||||
|
||||
function onPageSizeChange(size: number) {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
void loadRecords()
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!taskId.value || !exportName.value.trim()) {
|
||||
Message.warning('请填写包名')
|
||||
message.warning('请填写包名')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
@@ -394,20 +289,20 @@ async function onCreate() {
|
||||
format: 'ZIP',
|
||||
generateSummaryExcel: true
|
||||
})
|
||||
Message.success('已开始打包,完成后在这里下载')
|
||||
message.success('已开始打包,完成后在这里下载')
|
||||
await loadRecords()
|
||||
if (res.data?.exportId) {
|
||||
detailRef.value?.open(res.data.exportId)
|
||||
if (res?.exportId) {
|
||||
detailRef.value?.open(res.exportId)
|
||||
}
|
||||
} catch (e) {
|
||||
// 40035:前置检查不过。**不带 force**,所以这里只能把清单摊开
|
||||
const err = e as Error & { bizCode?: number, bizData?: unknown }
|
||||
// 40035:前置检查不过。**不带 force**,所以这里只能把清单摊开,没有"仍然导出"。
|
||||
const err = e as BizError
|
||||
if (err.bizCode === 40035 && err.bizData) {
|
||||
precheck.value = err.bizData as ExportPrecheck
|
||||
Modal.warning({
|
||||
dialog.warning({
|
||||
title: '还不能导出',
|
||||
content: `${err.message}\n\n左侧清单列出了卡住的人与项,处理完再点「重新检查」。`,
|
||||
hideCancel: true
|
||||
positiveText: '知道了'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
@@ -419,12 +314,16 @@ function onOpen(row: ExportTaskVO) {
|
||||
detailRef.value?.open(row.id)
|
||||
}
|
||||
|
||||
/** 下载走 `downloadExportFile`(带 token 取 blob)—— `window.open` 会 401 */
|
||||
async function onQuickDownload(row: ExportTaskVO) {
|
||||
downloading.value = row.id
|
||||
try {
|
||||
await downloadExportFile(row.id, 0)
|
||||
Message.success('已开始下载')
|
||||
message.success('已开始下载')
|
||||
} catch (e) {
|
||||
Message.error((e as Error).message || '下载失败')
|
||||
message.error((e as Error).message || '下载失败')
|
||||
} finally {
|
||||
downloading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,103 +333,367 @@ onMounted(async () => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.exp {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<template>
|
||||
<div class="page-plain">
|
||||
<NCard :bordered="false" title="导出中心">
|
||||
<template #header-extra>
|
||||
<span class="toolbar__hint">
|
||||
交付物:ZIP 包 + 根目录汇总表,目录结构按机构规范生成
|
||||
</span>
|
||||
</template>
|
||||
|
||||
&__task {
|
||||
width: 220px;
|
||||
}
|
||||
<!--
|
||||
工具条:所有控件**统一默认尺寸**,别混 `size="small"` ——
|
||||
28px 的白底小控件贴在白底卡片上几乎看不出是控件(客户反馈过"按钮不明显")。
|
||||
-->
|
||||
<div class="toolbar">
|
||||
<NSpace align="center" :size="8">
|
||||
<NSelect
|
||||
v-model:value="taskId"
|
||||
class="exp__task"
|
||||
placeholder="选择要导出的任务"
|
||||
:loading="taskLoading"
|
||||
:options="taskOptions"
|
||||
@update:value="onTaskChange"
|
||||
/>
|
||||
<NRadioGroup v-model:value="onlyApproved" :disabled="!taskId" @update:value="reload">
|
||||
<NRadioButton :value="false">全部已提交</NRadioButton>
|
||||
<NRadioButton :value="true">仅终审通过</NRadioButton>
|
||||
</NRadioGroup>
|
||||
<NButton :disabled="!taskId" @click="reload">
|
||||
<template #icon>
|
||||
<AppIcon name="refresh" :size="16" />
|
||||
</template>
|
||||
重新检查
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<span class="toolbar__hint">
|
||||
{{ checkedAt ? `上次检查:${checkedAt}` : '选择任务后自动检查' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
<NEmpty v-if="!taskId" description="先选一个任务 —— 导出是按任务进行的" />
|
||||
|
||||
&__action {
|
||||
font-size: 12px;
|
||||
}
|
||||
<NSpin v-else :show="checking">
|
||||
<!--
|
||||
⚠️ 这里是**铁律**的落点:canExport=false 时按钮直接禁用,**不弹警告放行**。
|
||||
清单要具体到"谁 + 哪一项 + 怎么办",否则管理员只能去几百份材料里逐个翻。
|
||||
-->
|
||||
<NAlert v-if="precheck && !precheck.canExport" type="error" class="exp__alert" title="前置检查未通过,无法导出">
|
||||
{{ precheck.blocking.length }} 项不合格。这是铁律 —— 不合格的文件不允许进入汇总包。
|
||||
<div v-for="(a, i) in quickActions" :key="i" class="exp__action">· {{ a }}</div>
|
||||
</NAlert>
|
||||
<NAlert v-else-if="precheck" type="success" :show-icon="false" class="exp__alert">
|
||||
检查通过:<strong>{{ precheck.candidateCount }}</strong> 人、
|
||||
<strong>{{ precheck.fileCount }}</strong> 个文件(原始 {{ mb(precheck.rawSizeBytes) }} MB)可以打包。
|
||||
</NAlert>
|
||||
|
||||
&__card {
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
<NAlert v-for="(w, i) in precheck?.warnings ?? []" :key="`w${i}`" type="warning" :show-icon="false" class="exp__alert">
|
||||
{{ w }}
|
||||
</NAlert>
|
||||
|
||||
&__card-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
<!--
|
||||
左右两栏:左 = 导出设置 + 卡住的地方,右 = 目录结构预览。
|
||||
⚠️ 卡片内部**不再套卡片**:设置项与目录树用浅灰底 + 弱描边的小格子(`.exp__block`),
|
||||
表格直接落在卡片白底上(表头自带浅灰色带,套进灰格子会与表头糊在一起)。
|
||||
-->
|
||||
<div class="exp__row">
|
||||
<div class="exp__col">
|
||||
<div class="exp__sec">
|
||||
<div class="section-title">
|
||||
导出设置
|
||||
</div>
|
||||
<div class="exp__block">
|
||||
<!-- 不套 n-form:这些是"输入即生效"的设置项,没有校验,加一层 form 只是多一层绑定 -->
|
||||
<div class="exp__field">
|
||||
<div class="exp__label">包名(会写进 ZIP 文件名)</div>
|
||||
<NInput v-model:value="exportName" placeholder="如 202609机电认定-24中技数控1班" :maxlength="60" show-count />
|
||||
</div>
|
||||
<div class="exp__field">
|
||||
<div class="exp__label">分卷大小</div>
|
||||
<NSelect v-model:value="splitSizeMb" :options="[{ label: '不分卷', value: 0 }]" />
|
||||
<div class="toolbar__hint exp__field-hint">服务端支持分卷(超过阈值会切成 part1/part2…,每卷都能单独解压),界面暂未开放选择</div>
|
||||
</div>
|
||||
<div class="exp__field">
|
||||
<NCheckbox v-model:checked="desensitize">证件号脱敏导出</NCheckbox>
|
||||
<div class="toolbar__hint exp__field-hint">
|
||||
名册里存的本来就是脱敏值,这个开关暂时不改变导出内容(明文导出需二次确认,未开放)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
&__tree {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
<!-- 阻断清单:逐条说清"谁 + 哪一项 + 怎么办" -->
|
||||
<div v-if="precheck && precheck.blocking.length" class="exp__sec">
|
||||
<div class="section-title">
|
||||
卡住的地方({{ precheck.blocking.length }})
|
||||
<span class="section-title__note">
|
||||
还没审完 {{ precheck.summary.unapproved }} · 缺材料 {{ precheck.summary.missing }} ·
|
||||
体积超标 {{ precheck.summary.sizeExceeded }} · 名字未规范 {{ precheck.summary.nameInvalid }}
|
||||
</span>
|
||||
</div>
|
||||
<NDataTable
|
||||
size="small"
|
||||
:columns="blockingColumns"
|
||||
:data="precheck.blocking"
|
||||
:bordered="true"
|
||||
:row-key="(row: ExportBlocking) => `${row.type}-${row.rosterId}-${row.fileId}-${row.title}`"
|
||||
:pagination="{ pageSize: 8 }"
|
||||
:max-height="240"
|
||||
:scroll-x="520"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
&__tree-root {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
<div class="exp__col">
|
||||
<div class="exp__sec">
|
||||
<div class="section-title">
|
||||
目录结构预览
|
||||
<span class="section-title__note">
|
||||
{{ tree ? `${tree.personCount} 人 × 各类材料 = ${tree.fileCount} 个文件` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="exp__block exp__block--tree">
|
||||
<!--
|
||||
目录树用「半角制表符 + CSS 缩进」,**不用全角空格**:
|
||||
全角空格(U+3000)会被 eslint 的 no-irregular-whitespace 拦下,
|
||||
而靠空白对齐的写法在 `--fix` 之后必然被重排。缩进交给 padding-left。
|
||||
-->
|
||||
<div v-if="tree" class="exp__tree">
|
||||
<div class="exp__tree-root">{{ tree.root }}/</div>
|
||||
<div v-for="c in tree.children" :key="c.path" class="exp__tree-org">
|
||||
<div class="exp__tree-dir">
|
||||
<span class="exp__br">|-- </span>{{ c.name }}/
|
||||
<span class="toolbar__hint">{{ c.studentCount }} 人</span>
|
||||
</div>
|
||||
<div v-for="m in c.children" :key="m.path" class="exp__tree-mat">
|
||||
<div class="exp__tree-dir2">
|
||||
<span class="exp__br">|-- </span>{{ m.name }}/
|
||||
<span class="toolbar__hint">{{ m.children.length }} 个</span>
|
||||
</div>
|
||||
<div v-for="f in m.children.slice(0, 2)" :key="f.path" class="exp__tree-file">
|
||||
<span class="exp__br">`-- </span>{{ f.name }}
|
||||
<span class="toolbar__hint">{{ f.sizeKb }}KB</span>
|
||||
</div>
|
||||
<div v-if="m.children.length > 2" class="exp__tree-file toolbar__hint">
|
||||
<span class="exp__br">`-- </span>…(其余 {{ m.children.length - 2 }} 个)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exp__tree-file">
|
||||
<span class="exp__br">`-- </span>考生线下报名信息导入模板(汇总).xlsx
|
||||
</div>
|
||||
</div>
|
||||
<NEmpty v-else description="没有可预览的内容" />
|
||||
</div>
|
||||
<div class="toolbar__hint exp__tree-note">
|
||||
预览与真正打包<strong>共用同一份构建代码</strong>,所以这里看到的就是解压后的样子
|
||||
(每个材料目录最多列 2 个文件,避免几百人时刷屏)。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
&__tree-dir {
|
||||
padding-left: 0;
|
||||
color: rgb(var(--arcoblue-6));
|
||||
}
|
||||
<!-- 底部动作条:按钮的可用性就是铁律的落点(主操作默认尺寸、type=primary) -->
|
||||
<div class="exp__footer">
|
||||
<span class="toolbar__hint">
|
||||
导出是后台任务,完成后在这里下载;产物保留 7 天
|
||||
</span>
|
||||
<NTooltip :disabled="canExport" trigger="hover">
|
||||
<template #trigger>
|
||||
<span class="exp__tt">
|
||||
<NButton type="primary" :disabled="!canExport" :loading="creating" @click="onCreate">
|
||||
<template #icon>
|
||||
<AppIcon name="download" :size="16" />
|
||||
</template>
|
||||
开始导出
|
||||
</NButton>
|
||||
</span>
|
||||
</template>
|
||||
{{ blockReason }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
</NSpin>
|
||||
</NCard>
|
||||
|
||||
&__tree-dir2 {
|
||||
padding-left: 20px;
|
||||
color: rgb(var(--arcoblue-5));
|
||||
}
|
||||
<!--
|
||||
导出记录:标题交给**卡片头**(浅灰色带),不要再在卡片体里摆一个"像卡片头的 div"——
|
||||
那样看起来就是"外层卡片里又套了一张卡片"。下载说明并进卡片头的灰色小字。
|
||||
-->
|
||||
<NCard :bordered="false" title="导出记录">
|
||||
<template #header-extra>
|
||||
<span class="toolbar__hint">
|
||||
共 {{ total }} 个包 · 下载需要登录态(页面走"带 token 取文件流",直接打开链接会 401)
|
||||
</span>
|
||||
</template>
|
||||
<!--
|
||||
导出记录表:这页是"内容自然增长"的普通页(page-plain),父级没有确定高度,
|
||||
所以纵向用 `:max-height="320"` 而不是 `flex-height`(后者要求父级高度确定)。
|
||||
-->
|
||||
<NDataTable
|
||||
remote
|
||||
size="small"
|
||||
:columns="recordColumns"
|
||||
:data="records"
|
||||
:loading="listLoading"
|
||||
:row-key="(row: ExportTaskVO) => row.id"
|
||||
:pagination="pagination"
|
||||
:max-height="320"
|
||||
:scroll-x="900"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</NCard>
|
||||
|
||||
&__tree-file {
|
||||
padding-left: 40px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
<ExportDetailDrawer ref="detailRef" @changed="loadRecords" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
/** 制表符(├─ └─):缩进靠父级的 padding,这里只管样式 */
|
||||
&__br {
|
||||
color: var(--color-text-3);
|
||||
user-select: none;
|
||||
}
|
||||
<style scoped>
|
||||
/**
|
||||
* ⚠️ `.exp__hint` / `.exp__name` / `.exp__progress*` 都出现在**渲染函数**里(表格列);
|
||||
* 渲染函数生成的元素不带 `data-v-*`,写成普通 scoped 选择器**匹配不到**
|
||||
* (不报错,只是这几处 12px 灰字一直没生效)。所以统一用 `:deep()`。
|
||||
*/
|
||||
:deep(.exp__hint) {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
&__tree-note {
|
||||
margin-top: 8px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.exp__task {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 12px;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid var(--color-border-1);
|
||||
}
|
||||
.exp__alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.exp__action {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
/* 左右两栏:窄屏(<760px 可用宽)自动落成上下两行 */
|
||||
.exp__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.exp__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exp__sec {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片**内部**的小格子:浅灰底 + 弱描边。
|
||||
* 不要在这里再套一个 `NCard` —— 白卡片套白卡片会糊成一片(外层卡片底部那条"灰带"就是这么来的)。
|
||||
*/
|
||||
.exp__block {
|
||||
padding: 14px;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.exp__block--tree {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.exp__tree {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, sfmono-regular, menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.exp__tree-root {
|
||||
font-weight: 600;
|
||||
color: var(--xt-text);
|
||||
}
|
||||
|
||||
.exp__tree-dir {
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
.exp__tree-dir2 {
|
||||
padding-left: 20px;
|
||||
color: #366ef4;
|
||||
}
|
||||
|
||||
.exp__tree-file {
|
||||
padding-left: 40px;
|
||||
color: #4b4b56;
|
||||
}
|
||||
|
||||
/* 制表符(|-- `--):缩进靠父级的 padding,这里只管样式 */
|
||||
.exp__br {
|
||||
color: var(--xt-text-3);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.exp__tree-note {
|
||||
margin-top: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.exp__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid var(--xt-card-border-weak);
|
||||
}
|
||||
|
||||
/* 禁用状态的按钮不触发鼠标事件,用 span 兜住才能显示 tooltip */
|
||||
.exp__tt {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
:deep(.exp__name) {
|
||||
font-weight: 500;
|
||||
color: var(--xt-text);
|
||||
}
|
||||
|
||||
.exp__field:not(:last-child) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.exp__label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--xt-text-2);
|
||||
}
|
||||
|
||||
.exp__field-hint {
|
||||
margin-top: 6px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
:deep(.exp__progress) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:deep(.exp__progress-bar) {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
background: #eef1f5;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
:deep(.exp__progress-inner) {
|
||||
height: 100%;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
<template>
|
||||
<a-modal v-model:visible="visible" :title="mode === 'approve' ? '通过加入申请' : '驳回加入申请'"
|
||||
:width="560" :mask-closable="false" :ok-loading="saving"
|
||||
:ok-text="mode === 'approve' ? '确认通过' : '确认驳回'" @before-ok="onOk" @close="close">
|
||||
<a-alert v-if="row" type="normal" :show-icon="false" class="jd__info">
|
||||
<template #title>
|
||||
<strong>{{ row.realName }}</strong> · {{ row.idCardMask }}
|
||||
<template v-if="row.orgHint"> · 自述班级「{{ row.orgHint }}」</template>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="row.reason" class="jd__muted">申请说明:{{ row.reason }}</div>
|
||||
<div v-if="row.duplicateHint" class="jd__dup">
|
||||
提示:该证件号在「{{ row.duplicateHint.taskName }}」的名册里出现过
|
||||
(登记姓名 {{ row.duplicateHint.realName }})—— 建议核对是不是同一个人。
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- ── 通过 ── -->
|
||||
<template v-if="mode === 'approve'">
|
||||
<a-form layout="vertical" size="small" :model="approveForm">
|
||||
<a-form-item label="加入哪个班级" required>
|
||||
<a-select v-model="approveForm.orgId" placeholder="选择班级">
|
||||
<a-option v-for="c in classOptions" :key="c.id" :value="c.id">
|
||||
{{ c.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<template #extra>
|
||||
<span class="jd__muted">
|
||||
学生的自述班级只作参考 —— 班级写错会让他的材料挂到别的班,
|
||||
将来导出时目录也会分错
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
<a-form-item label="姓名(如与证件不符可修正)">
|
||||
<a-input v-model="approveForm.realName" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-alert type="info" :show-icon="false" class="jd__tip">
|
||||
<template #title>
|
||||
通过后:系统会把他<strong>直接加入名单并绑定</strong>,
|
||||
他返回重新核验即可开始填报,不用再输一遍姓名与证件号。
|
||||
</template>
|
||||
<template #default>
|
||||
<!-- 通知没做:如实说,不假装 -->
|
||||
<span class="jd__muted">
|
||||
⚠️ 通知渠道还没做,<strong>通过之后要人工告知学生</strong>(群里说一声)。
|
||||
</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
</template>
|
||||
|
||||
<!-- ── 驳回 ── -->
|
||||
<template v-else>
|
||||
<a-form layout="vertical" size="small" :model="rejectForm">
|
||||
<a-form-item label="快捷原因">
|
||||
<a-select v-model="rejectForm.reasonCode" placeholder="选择或直接在下面写" allow-clear>
|
||||
<a-option v-for="r in REJECT_REASON_CODES" :key="r.code" :value="r.code">
|
||||
{{ r.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item :label="textLabel" :validate-status="textError ? 'error' : undefined"
|
||||
:help="textError">
|
||||
<a-textarea v-model="rejectForm.reason" :rows="3" :max-length="200" show-word-limit
|
||||
placeholder="如:名单里没有你的报名记录,请联系班主任核对报名信息" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-alert type="warning" :show-icon="false" class="jd__tip">
|
||||
<template #title>
|
||||
这段说明会<strong>原样显示给学生</strong>,所以要说清"哪里不对、该怎么办",
|
||||
不要只写"审核不通过"。
|
||||
</template>
|
||||
</a-alert>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { JoinAdminItem } from '@/apis/xt/join-request'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
approveJoinRequest,
|
||||
JOIN_ERROR_HINT,
|
||||
REJECT_REASON_CODES,
|
||||
rejectJoinRequest
|
||||
} from '@/apis/xt/join-request'
|
||||
import { getOrgTree } from '@/apis/xt/org'
|
||||
|
||||
/**
|
||||
* 加入申请审批弹窗(通过 / 驳回两种模式)。
|
||||
*
|
||||
* ## 为什么通过时要**手选班级**
|
||||
*
|
||||
* 学生的"自述班级"只是他自己打的字(可能写成"数控2班"、"24数控二班"…),
|
||||
* 而班级决定了:他的材料归谁审、导出时落在哪个目录。**让它自动猜**,
|
||||
* 猜错的表现是"材料挂到了别的班",而导出包打开时才会发现。
|
||||
* 服务端在"自述班级匹配到唯一组织"时会自动选上,匹配不上或撞名时必须手选。
|
||||
*
|
||||
* ## 驳回必须填原因
|
||||
*
|
||||
* 原因会原样给学生看 —— 这也是学生唯一能知道"哪里不对"的来源。
|
||||
* 只写"审核不通过"等于让他反复提交。
|
||||
*/
|
||||
defineOptions({ name: 'JoinAuditModal' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'done'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const saving = ref(false)
|
||||
const mode = ref<'approve' | 'reject'>('approve')
|
||||
const row = ref<JoinAdminItem | null>(null)
|
||||
const classOptions = ref<Array<{ id: number, label: string }>>([])
|
||||
const textError = ref('')
|
||||
|
||||
const approveForm = reactive({ orgId: undefined as number | undefined, realName: '' })
|
||||
const rejectForm = reactive({ reasonCode: '', reason: '' })
|
||||
|
||||
const textLabel = computed(() =>
|
||||
rejectForm.reasonCode && rejectForm.reasonCode !== 'OTHER'
|
||||
? '补充说明(可选)'
|
||||
: '驳回原因(必填)')
|
||||
|
||||
async function loadClasses() {
|
||||
if (classOptions.value.length) {
|
||||
return
|
||||
}
|
||||
const res = await getOrgTree()
|
||||
const out: Array<{ id: number, label: string }> = []
|
||||
const walk = (nodes: Array<Record<string, unknown>>) => {
|
||||
for (const n of nodes || []) {
|
||||
if (n.orgType === 3) {
|
||||
out.push({ id: Number(n.id), label: String(n.orgName) })
|
||||
}
|
||||
walk((n.children as Array<Record<string, unknown>>) || [])
|
||||
}
|
||||
}
|
||||
walk((res.data as unknown as Array<Record<string, unknown>>) || [])
|
||||
classOptions.value = out
|
||||
}
|
||||
|
||||
async function openApprove(r: JoinAdminItem) {
|
||||
mode.value = 'approve'
|
||||
row.value = r
|
||||
approveForm.orgId = undefined
|
||||
approveForm.realName = r.realName
|
||||
textError.value = ''
|
||||
visible.value = true
|
||||
try {
|
||||
await loadClasses()
|
||||
// 自述班级能唯一匹配上就预选(服务端也会做同样的匹配,这里只是少一次点击)
|
||||
const hit = classOptions.value.filter((c) => r.orgHint && c.label === r.orgHint)
|
||||
if (hit.length === 1) {
|
||||
approveForm.orgId = hit[0].id
|
||||
}
|
||||
} catch {
|
||||
// 组织树取不到不阻断:他可以不选,服务端会按自述班级再试一次
|
||||
}
|
||||
}
|
||||
|
||||
function openReject(r: JoinAdminItem) {
|
||||
mode.value = 'reject'
|
||||
row.value = r
|
||||
rejectForm.reasonCode = ''
|
||||
rejectForm.reason = ''
|
||||
textError.value = ''
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
if (!row.value) {
|
||||
return false
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (mode.value === 'approve') {
|
||||
if (!approveForm.orgId) {
|
||||
Message.warning('请选择要加入的班级')
|
||||
return false
|
||||
}
|
||||
const res = await approveJoinRequest(row.value.requestId, {
|
||||
orgId: approveForm.orgId,
|
||||
realName: approveForm.realName.trim() || undefined
|
||||
})
|
||||
const d = res.data
|
||||
// 通知没发出去是**事实**,要说出来(不能让人以为学生已经收到通知了)
|
||||
Message.success({
|
||||
content: `已通过并加入名单。${d?.canFillNow ? '该学生重新核验即可填报。' : ''}${
|
||||
d?.notifySent === false ? '(通知未发出,请人工告知学生)' : ''}`,
|
||||
duration: 5000
|
||||
})
|
||||
} else {
|
||||
const needsText = !rejectForm.reasonCode || rejectForm.reasonCode === 'OTHER'
|
||||
if (needsText && !rejectForm.reason.trim()) {
|
||||
textError.value = '请写清驳回原因 —— 学生只能从这句话知道哪里不对'
|
||||
return false
|
||||
}
|
||||
await rejectJoinRequest(row.value.requestId, {
|
||||
reason: rejectForm.reason.trim() || '信息与报名资料不符',
|
||||
reasonCode: rejectForm.reasonCode || undefined
|
||||
})
|
||||
Message.success({
|
||||
content: '已驳回。(通知未发出,请人工告知学生原因)',
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
visible.value = false
|
||||
emit('done')
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as Error & { bizCode?: number }
|
||||
const hint = err.bizCode ? JOIN_ERROR_HINT[err.bizCode] : ''
|
||||
Message.error(hint ? `${err.message}(${hint})` : err.message)
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openApprove, openReject })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.jd {
|
||||
&__info {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__dup {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--orange-6));
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,272 +0,0 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>加入申请</template>
|
||||
<template #extra>
|
||||
<span class="join__hint">
|
||||
门禁没匹配上的人在这里申请加入;通过后他重新核验即可填报
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-alert v-if="alert" type="warning" class="join__alert">
|
||||
<template #title>{{ alert.text }}</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-button v-for="t in statusTabs" :key="String(t.value)"
|
||||
:type="statusFilter === t.value ? 'primary' : 'outline'" size="small"
|
||||
@click="setStatus(t.value)">
|
||||
{{ t.label }}
|
||||
<span v-if="t.count !== undefined" class="join__count">{{ t.count }}</span>
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
<a-space wrap>
|
||||
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
|
||||
class="join__task" :loading="taskLoading" @change="search">
|
||||
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
|
||||
</a-select>
|
||||
<a-input-search v-model="query.keyword" placeholder="搜索姓名" allow-clear
|
||||
class="join__search" @search="search" @clear="search" />
|
||||
<a-button size="small" @click="load">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
⚠️ 这一屏**没有批量通过、没有全选**(契约 §8.7.5 的取舍):
|
||||
放错一个人进名单 = 让一个不属于这批的人拿到认定资格,代价极高。
|
||||
所以只有逐条的通过/驳回,且驳回必须填原因。
|
||||
-->
|
||||
<a-table :data="list" :loading="loading" row-key="requestId" :pagination="pagination"
|
||||
:scroll="{ x: 1000 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="申请人" :width="170">
|
||||
<template #cell="{ record }">
|
||||
<div class="join__name">{{ record.realName }}</div>
|
||||
<div class="join__muted">{{ record.idCardMask }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="手机号" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<span class="join__muted">{{ record.phoneMask || '—' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="自述班级 / 申请说明" :width="220">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.orgHint || '—' }}</div>
|
||||
<div class="join__muted">{{ record.reason || '(未填写说明)' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="任务" :width="170" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.taskName || '—' }}</div>
|
||||
<div v-if="record.duplicateHint" class="join__dup">
|
||||
该证件号在「{{ record.duplicateHint.taskName }}」出现过
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态 / 等待" :width="120" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="JOIN_STATUS[record.status]?.color ?? 'gray'" size="small">
|
||||
{{ record.statusText || JOIN_STATUS[record.status]?.text }}
|
||||
</a-tag>
|
||||
<div class="join__muted">{{ fmtWait(record.waitHours) }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="提交时间" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<span class="join__muted">{{ fmt(record.createTime) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="150" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space v-if="record.status === 0" size="mini">
|
||||
<a-link @click="onApprove(record)">通过</a-link>
|
||||
<a-link status="danger" @click="onReject(record)">驳回</a-link>
|
||||
</a-space>
|
||||
<span v-else class="join__muted">
|
||||
{{ record.rosterId ? `已入册 #${record.rosterId}` : '—' }}
|
||||
</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<JoinAuditModal ref="modalRef" @done="load" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { JoinAdminItem } from '@/apis/xt/join-request'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { getJoinRequests, JOIN_STATUS } from '@/apis/xt/join-request'
|
||||
import { getTaskList } from '@/apis/xt/task'
|
||||
import JoinAuditModal from './JoinAuditModal.vue'
|
||||
|
||||
/**
|
||||
* 加入申请审批(FR-509,原型 STEP 11)。
|
||||
*
|
||||
* ## 为什么没有批量通过
|
||||
*
|
||||
* 契约 §8.7.5 明确取舍:批量**通过**要求管理员先逐条看过,
|
||||
* 而**驳回必须逐条填原因**;且批量通过**不提供"全选"快捷键**。
|
||||
* 理由与"批量通过只带绿色项"同源 —— **身份认定环节的误操作代价极高**:
|
||||
* 放错一个人进名单,等于让一个不属于这批的人拿到认定资格,而撤销只能删名册项。
|
||||
* 所以这一屏干脆不做批量,只留逐条动作。
|
||||
*
|
||||
* ## 两个信息是用来"帮他判断"的
|
||||
*
|
||||
* - `waitHours`:等最久的排最前(列表本来就按提交时间正序)
|
||||
* - `duplicateHint`:这个证件号在**别的任务**的名册里出现过 ——
|
||||
* 这是判断"是不是本校学生"的唯一线索,比对着姓名猜可靠
|
||||
*/
|
||||
defineOptions({ name: 'Join' })
|
||||
|
||||
const loading = ref(false)
|
||||
const taskLoading = ref(false)
|
||||
const list = ref<JoinAdminItem[]>([])
|
||||
const total = ref(0)
|
||||
const counts = reactive({ pending: 0, approved: 0, rejected: 0 })
|
||||
const alert = ref<{ level: number, text: string } | null>(null)
|
||||
const statusFilter = ref<number>(0)
|
||||
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
|
||||
|
||||
const query = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
taskId: undefined as number | undefined
|
||||
})
|
||||
|
||||
const modalRef = useTemplateRef('modalRef')
|
||||
|
||||
const statusTabs = computed(() => [
|
||||
{ label: '待审核', value: 0, count: counts.pending },
|
||||
{ label: '已通过', value: 1, count: counts.approved },
|
||||
{ label: '已驳回', value: 2, count: counts.rejected }
|
||||
])
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
|
||||
function fmt(v: string | null) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function fmtWait(h: number | null) {
|
||||
if (h === null || h === undefined) {
|
||||
return '—'
|
||||
}
|
||||
return h < 24 ? `已等 ${h} 小时` : `已等 ${Math.floor(h / 24)} 天`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getJoinRequests({
|
||||
taskId: query.taskId,
|
||||
status: statusFilter.value,
|
||||
keyword: query.keyword.trim() || undefined,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
const d = res.data
|
||||
list.value = d?.list ?? []
|
||||
total.value = d?.total ?? 0
|
||||
Object.assign(counts, d?.counts ?? { pending: 0, approved: 0, rejected: 0 })
|
||||
alert.value = d?.alert ?? null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
taskLoading.value = true
|
||||
try {
|
||||
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
|
||||
} finally {
|
||||
taskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function setStatus(v: number) {
|
||||
statusFilter.value = v
|
||||
search()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
load()
|
||||
}
|
||||
|
||||
function onApprove(row: JoinAdminItem) {
|
||||
modalRef.value?.openApprove(row)
|
||||
}
|
||||
|
||||
function onReject(row: JoinAdminItem) {
|
||||
modalRef.value?.openReject(row)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.join {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&__task {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
&__search {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__dup {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+356
-259
@@ -1,132 +1,17 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false" class="g-list-card">
|
||||
<template #title>操作日志</template>
|
||||
<template #extra>
|
||||
<span class="log__hint">
|
||||
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
|
||||
class="log__num" :min="1" hide-button @change="search" />
|
||||
<a-select v-model="query.targetType" placeholder="对象类型" allow-clear size="small"
|
||||
class="log__sel" @change="search">
|
||||
<a-option value="ROSTER">名册项</a-option>
|
||||
<a-option value="TASK">任务</a-option>
|
||||
<a-option value="SUBMISSION">填报单</a-option>
|
||||
<a-option value="EXPORT">导出</a-option>
|
||||
<a-option value="USER">账号</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.action" placeholder="动作" allow-clear size="small"
|
||||
class="log__sel2" @change="search">
|
||||
<a-option v-for="a in OPERATION_ACTIONS" :key="a.value" :value="a.value">
|
||||
{{ a.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.module" placeholder="模块" allow-clear size="small"
|
||||
class="log__sel3" @change="search">
|
||||
<a-option value="ROSTER">名册</a-option>
|
||||
<a-option value="TASK">任务</a-option>
|
||||
<a-option value="AUDIT">审核</a-option>
|
||||
<a-option value="EXPORT">导出</a-option>
|
||||
<a-option value="JOIN">加入申请</a-option>
|
||||
</a-select>
|
||||
<!-- 起止日期用两个独立日期框,**不用 range 模式**:
|
||||
Arco 的 range 模式实际值是数组、但类型声明只写了单值,
|
||||
改哪边都要么 as any(掩盖问题)、要么被类型挡住。
|
||||
而且两个框各自能单独清空,核查时更好用 -->
|
||||
<a-date-picker v-model="fromDate" size="small" mode="date"
|
||||
placeholder="开始日期" class="log__date" @change="search" />
|
||||
<a-date-picker v-model="toDate" size="small" mode="date"
|
||||
placeholder="结束日期" class="log__date" @change="search" />
|
||||
<a-button size="small" @click="reset">重置</a-button>
|
||||
<a-button size="small" @click="load">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
`y: '100%'` 不是装饰:它让 Arco 把表头与表体**拆开**
|
||||
(内部 `splitTable`),表体自己滚、表头钉住、分页器留在卡片底部。
|
||||
没有它的话,`.arco-table-content` 的 `overflow-y` 是 hidden(横向滚动条专用),
|
||||
多出来的行会被**直接裁掉**,而且没有任何滚动条能救回来。
|
||||
-->
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||
:scroll="{ x: isPlatform ? 1340 : 1200, y: '100%' }" @page-change="onPageChange"
|
||||
@page-size-change="onPageSizeChange">
|
||||
<template #columns>
|
||||
<!--
|
||||
「租户」列**只给平台运营看**:它看的是全量日志(跨租户),
|
||||
不显示单位就分不清"这条是哪个学校做的";学校管理员永远是本租户,列没有意义。
|
||||
-->
|
||||
<a-table-column v-if="isPlatform" title="租户" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<span class="log__muted">
|
||||
{{ tenantNames[record.tenantId] ?? (record.tenantId != null ? `#${record.tenantId}` : '—') }}
|
||||
</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="时间" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<span class="log__muted">{{ fmt(record.createTime) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作人" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<!--
|
||||
⚠️ 只显示**账号/姓名**,不显示用户 ID(2026-10-09 客户要求)。
|
||||
数字 ID 对看日志的人没有意义 —— 他要知道的是"哪个账号做的",
|
||||
ID 只在排查时要,而那种时候查库比看这一列快。
|
||||
-->
|
||||
<span>{{ record.operatorName || '—' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="动作" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small" :color="isSensitive(record.action) ? 'red' : 'arcoblue'">
|
||||
{{ actionText(record.action) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="对象" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.targetType" class="log__muted">
|
||||
{{ record.targetType }} #{{ record.targetId }}
|
||||
</span>
|
||||
<span v-else class="log__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="变更前 → 变更后" :width="280">
|
||||
<template #cell="{ record }">
|
||||
<div v-if="record.beforeVal || record.afterVal" class="log__diff">
|
||||
<span class="log__before">{{ record.beforeVal || '(空)' }}</span>
|
||||
<span class="log__arrow">→</span>
|
||||
<span class="log__after">{{ record.afterVal || '(空)' }}</span>
|
||||
</div>
|
||||
<span v-else class="log__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="来源" :width="200">
|
||||
<template #cell="{ record }">
|
||||
<div class="log__muted">{{ record.ip || '—' }}</div>
|
||||
<div class="log__ua" :title="record.userAgent || ''">{{ record.userAgent || '' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { OperationLog } from '@/apis/xt/log'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NDatePicker,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
actionText,
|
||||
getOperationLogs,
|
||||
@@ -134,211 +19,423 @@ import {
|
||||
SENSITIVE_ACTIONS
|
||||
} from '@/apis/xt/log'
|
||||
import { getTenantList } from '@/apis/xt/tenant'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
/**
|
||||
* 操作日志(《03-接口设计》§13.4)。
|
||||
* 操作日志。
|
||||
*
|
||||
* ## 为什么把「对象 ID」放在筛选栏最左边
|
||||
*
|
||||
* 因为这是这一屏**唯一不可替代**的查询方式。合规问题问的是
|
||||
* 「这张名册项的证件号被谁从什么改成了什么」,答案只能靠
|
||||
* `target_type + target_id` 定位;而"搜关键字"会把
|
||||
* 「改了它」和「顺带提到它」混在一起 —— 那种匹配撑不起合规结论。
|
||||
* 「这张名册项的证件号被谁从什么改成了什么」,答案只能靠 `target_type + target_id` 定位;
|
||||
* 而"搜关键字"会把「改了它」和「顺带提到它」混在一起 —— 那种匹配撑不起合规结论。
|
||||
*
|
||||
* ## 「变更前 → 变更后」单独一列
|
||||
*
|
||||
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
|
||||
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于每次查看多一次点击,
|
||||
* 而合规核查往往是几十条连着看的。
|
||||
*
|
||||
* ## 范围:平台运营看**全量**,学校管理员看**本租户**
|
||||
*
|
||||
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定(`LogController.logScopeTenantId`)。
|
||||
* ⚠️ 平台运营看到的每条日志属于哪个单位,靠这一列「租户」——它**只对平台显示**。
|
||||
* 以前平台的 tenantId 是 0 而日志挂在各租户下,于是这一屏对平台**永远是空的**,
|
||||
* 且不报任何错(PRD 要求的却是「全量操作日志 ✅」)。
|
||||
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定。⚠️ 平台看到的每条日志属于哪个单位,
|
||||
* 靠「租户」这一列 —— 它**只对平台显示**(学校管理员永远是本租户,列没有意义)。
|
||||
*/
|
||||
defineOptions({ name: 'OperationLogs' })
|
||||
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
const user = useUserStore()
|
||||
|
||||
/** 平台运营:看全量日志,因此要显示「租户」列并把 id 翻成单位名 */
|
||||
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
||||
const isPlatform = computed(() => user.isPlatform)
|
||||
const tenantNames = ref<Record<number, string>>({})
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<OperationLog[]>([])
|
||||
const total = ref(0)
|
||||
const fromDate = ref<string | undefined>(undefined)
|
||||
const toDate = ref<string | undefined>(undefined)
|
||||
|
||||
const fromDate = ref<string | null>(null)
|
||||
const toDate = ref<string | null>(null)
|
||||
|
||||
const query = reactive<{
|
||||
targetId?: number
|
||||
targetType?: string
|
||||
action?: string
|
||||
module?: string
|
||||
from?: string
|
||||
to?: string
|
||||
targetId: number | null
|
||||
targetType: string | null
|
||||
action: string | null
|
||||
module: string | null
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}>({
|
||||
targetId: undefined,
|
||||
targetType: undefined,
|
||||
action: undefined,
|
||||
module: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
targetId: null,
|
||||
targetType: null,
|
||||
action: null,
|
||||
module: null,
|
||||
pageNum: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
const targetTypeOptions = [
|
||||
{ label: '名册项', value: 'ROSTER' },
|
||||
{ label: '任务', value: 'TASK' },
|
||||
{ label: '填报单', value: 'SUBMISSION' },
|
||||
{ label: '导出', value: 'EXPORT' },
|
||||
{ label: '账号', value: 'USER' }
|
||||
]
|
||||
|
||||
function fmt(v?: string) {
|
||||
const actionOptions = OPERATION_ACTIONS.map((a) => ({ label: a.label, value: a.value }))
|
||||
|
||||
/** 加入申请(JOIN)已随需求收敛下线,这里不再列它 */
|
||||
const moduleOptions = [
|
||||
{ label: '名册', value: 'ROSTER' },
|
||||
{ label: '任务', value: 'TASK' },
|
||||
{ label: '审核', value: 'AUDIT' },
|
||||
{ label: '导出', value: 'EXPORT' }
|
||||
]
|
||||
|
||||
function fmt(v?: string): string {
|
||||
return v ? v.replace('T', ' ').slice(0, 19) : '—'
|
||||
}
|
||||
|
||||
function isSensitive(action?: string) {
|
||||
function isSensitive(action?: string): boolean {
|
||||
return !!action && SENSITIVE_ACTIONS.includes(action)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const columns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
const cols: DataTableColumns<OperationLog> = []
|
||||
|
||||
// 「租户」列只给平台运营看
|
||||
if (isPlatform.value) {
|
||||
cols.push({
|
||||
title: '租户',
|
||||
key: 'tenantId',
|
||||
width: 150,
|
||||
render: (row) => h('span', { class: 'log__muted' }, tenantNames.value[row.tenantId ?? 0] ?? (row.tenantId != null ? `#${row.tenantId}` : '—'))
|
||||
})
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
title: '时间',
|
||||
key: 'createTime',
|
||||
width: 160,
|
||||
render: (row) => h('span', { class: 'log__muted' }, fmt(row.createTime))
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
key: 'operatorName',
|
||||
width: 130,
|
||||
// ⚠️ 只显示账号/姓名,不显示用户 ID(客户走查要求):
|
||||
// 数字 ID 对看日志的人没有意义,他要知道的是"哪个账号做的"
|
||||
render: (row) => row.operatorName || '—'
|
||||
},
|
||||
{
|
||||
title: '动作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (row) => h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, type: isSensitive(row.action) ? 'error' : 'info' },
|
||||
{ default: () => actionText(row.action) }
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '对象',
|
||||
key: 'targetType',
|
||||
width: 140,
|
||||
render: (row) => h('span', { class: 'log__muted' }, row.targetType ? `${row.targetType} #${row.targetId}` : '—')
|
||||
},
|
||||
{
|
||||
title: '变更前 → 变更后',
|
||||
key: 'diff',
|
||||
width: 300,
|
||||
render: (row) => {
|
||||
if (!row.beforeVal && !row.afterVal) {
|
||||
return h('span', { class: 'log__muted' }, '—')
|
||||
}
|
||||
return h('div', { class: 'log__diff' }, [
|
||||
h('span', { class: 'log__before' }, row.beforeVal || '(空)'),
|
||||
h('span', { class: 'log__arrow' }, '→'),
|
||||
h('span', { class: 'log__after' }, row.afterVal || '(空)')
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
key: 'ip',
|
||||
width: 200,
|
||||
render: (row) => h('div', null, [
|
||||
h('div', { class: 'log__muted' }, row.ip || '—'),
|
||||
h('div', { class: 'log__ua', title: row.userAgent || '' }, row.userAgent || '')
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
return cols
|
||||
})
|
||||
|
||||
/**
|
||||
* 分页器统一走 `listPagination`(见 `@/composables/pagination`)。
|
||||
*
|
||||
* ⚠️ 远程分页三件事缺一不可:`remote` + 自己控制 page/pageSize +
|
||||
* 同时处理 `@update:page` 与 `@update:page-size`(改每页条数一律回到第 1 页重新取数,
|
||||
* 否则会出现"分页器说 50 条/页、表里还是上一次那 20 条")。
|
||||
*/
|
||||
const pagination = computed(() => listPagination({
|
||||
page: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
itemCount: total.value,
|
||||
pageSizes: [20, 50, 100]
|
||||
}))
|
||||
|
||||
function onPage(page: number): void {
|
||||
query.pageNum = page
|
||||
void load()
|
||||
}
|
||||
|
||||
function onPageSize(size: number): void {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOperationLogs({
|
||||
targetId: query.targetId,
|
||||
targetType: query.targetType,
|
||||
action: query.action,
|
||||
module: query.module,
|
||||
const page = await getOperationLogs({
|
||||
targetId: query.targetId ?? undefined,
|
||||
targetType: query.targetType ?? undefined,
|
||||
action: query.action ?? undefined,
|
||||
module: query.module ?? undefined,
|
||||
// 后端 `to` 会补到当天 23:59:59,所以这里只给日期就够
|
||||
from: fromDate.value || undefined,
|
||||
to: toDate.value || undefined,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
function search(): void {
|
||||
query.pageNum = 1
|
||||
load()
|
||||
void load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.targetId = undefined
|
||||
query.targetType = undefined
|
||||
query.action = undefined
|
||||
query.module = undefined
|
||||
fromDate.value = undefined
|
||||
toDate.value = undefined
|
||||
function reset(): void {
|
||||
query.targetId = null
|
||||
query.targetType = null
|
||||
query.action = null
|
||||
query.module = null
|
||||
fromDate.value = null
|
||||
toDate.value = null
|
||||
search()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
load()
|
||||
}
|
||||
|
||||
/**
|
||||
* 改每页条数。
|
||||
*
|
||||
* ⚠️ **必须处理这个事件**,不能只处理 `page-change`:
|
||||
* Arco 的表格在 `pagination` 非 false 时会**自己再切一次数据**
|
||||
* (`flattenData`:`data.length > pageSize` 就 slice)。
|
||||
* 只在本地改每页条数、不重新取数的话,会出现
|
||||
* 「分页器显示 50 条/页、表里还是上一次那 20 条」这种自相矛盾的画面 ——
|
||||
* 看起来就是"分页器坏了"。所以这里一律回到第 1 页重新取。
|
||||
*/
|
||||
function onPageSizeChange(size: number) {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
|
||||
onMounted(async () => {
|
||||
if (!isPlatform.value) return
|
||||
try {
|
||||
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
|
||||
tenantNames.value = Object.fromEntries((res.data?.list ?? []).map((t) => [t.id, t.tenantName]))
|
||||
} catch {
|
||||
tenantNames.value = {}
|
||||
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
|
||||
if (isPlatform.value) {
|
||||
try {
|
||||
const page = await getTenantList({ pageNum: 1, pageSize: 200 })
|
||||
tenantNames.value = Object.fromEntries((page.list ?? []).map((t) => [t.id, t.tenantName]))
|
||||
} catch {
|
||||
tenantNames.value = {}
|
||||
message.warning('单位名称没取到,租户列先显示编号')
|
||||
}
|
||||
}
|
||||
await load()
|
||||
})
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 卡片/表格的高度链路(分页器为什么必须钉在底部)见 `styles/global.scss` 的 `.g-list-card` */
|
||||
.log {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<template>
|
||||
<NCard class="page-card log" :bordered="false" size="small">
|
||||
<template #header>
|
||||
操作日志
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="log__hint">
|
||||
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
|
||||
</span>
|
||||
</template>
|
||||
|
||||
&__num {
|
||||
width: 110px;
|
||||
}
|
||||
<!--
|
||||
筛选行:控件多,所以**统一默认尺寸**(34px)并允许换行
|
||||
(`.toolbar` 自带 `flex-wrap`)。这里刻意不用 `NSpace`:
|
||||
它自己是一个 flex 项,换行时机由外层挤压决定,行与行之间容易参差不齐;
|
||||
直接用 `flex-wrap: wrap` + `gap`,换行后每一行都从左边对齐。
|
||||
同时把 `.toolbar` 的 `space-between` 改回 `flex-start` ——
|
||||
否则换行后每行会被"两端对齐"抻开,看起来像随机的空档。
|
||||
-->
|
||||
<div class="toolbar log__filters">
|
||||
<!-- 对象 ID 放最左:合规问题只能靠 target_type + target_id 定位 -->
|
||||
<NInputNumber
|
||||
v-model:value="query.targetId"
|
||||
class="log__num"
|
||||
:min="1"
|
||||
clearable
|
||||
placeholder="对象 ID"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.targetType"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="对象类型"
|
||||
:options="targetTypeOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.action"
|
||||
class="log__sel2"
|
||||
clearable
|
||||
placeholder="动作"
|
||||
:options="actionOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.module"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="模块"
|
||||
:options="moduleOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<!-- 起止日期用两个独立日期框,不用 range:两个框各自能单独清空,核查时更好用 -->
|
||||
<NDatePicker
|
||||
v-model:formatted-value="fromDate"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="开始日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="search"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:formatted-value="toDate"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="结束日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="search"
|
||||
/>
|
||||
<NButton @click="reset">
|
||||
重置
|
||||
</NButton>
|
||||
<NButton @click="load">
|
||||
<template #icon>
|
||||
<AppIcon name="refresh" :size="16" />
|
||||
</template>
|
||||
刷新
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
&__sel {
|
||||
width: 120px;
|
||||
}
|
||||
<div class="page-card__body">
|
||||
<NDataTable
|
||||
flex-height
|
||||
style="height: 100%"
|
||||
remote
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="row => row.id"
|
||||
:scroll-x="isPlatform ? 1240 : 1090"
|
||||
:pagination="pagination"
|
||||
size="small"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
&__sel2 {
|
||||
width: 150px;
|
||||
}
|
||||
<style scoped>
|
||||
/**
|
||||
* 页面就是一张卡片(`.page-card` 提供撑满内容区的高度链)。
|
||||
*
|
||||
* ⚠️ 内容区类名是 `.n-card-content`(naive 的 `cB("card-content")`),
|
||||
* **不是** `.n-card__content` —— 后者不匹配任何元素,写错整条规则会静默失效
|
||||
* (症状:表格体高度算成 0,行在 DOM 里却一行都看不见)。
|
||||
* 高度链:卡片内容区 → `.page-card__body` → `n-data-table[flex-height]`,
|
||||
* 中间每层都要 `min-height: 0`(见 styles/index.scss 的 `.page-card` 注释)。
|
||||
*/
|
||||
.log :deep(.n-card-content) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__sel3 {
|
||||
width: 100px;
|
||||
}
|
||||
/* 卡片头右侧的说明文字:长句,窄屏时截断而不是把标题挤掉 */
|
||||
.log__hint {
|
||||
display: block;
|
||||
max-width: 520px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__date {
|
||||
width: 240px;
|
||||
}
|
||||
/* 筛选行:换行后每行左对齐、行距一致(覆盖 .toolbar 的 space-between) */
|
||||
.log__filters {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.log__num {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
&__diff {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log__sel {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
&__before {
|
||||
color: var(--color-text-3);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.log__sel2 {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
margin: 0 6px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.log__date {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
&__after {
|
||||
color: rgb(var(--red-6));
|
||||
font-weight: 500;
|
||||
}
|
||||
.log__muted,
|
||||
:deep(.log__muted) {
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
&__ua {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log__ua,
|
||||
:deep(.log__ua) {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: #b5b5b5;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log__diff,
|
||||
:deep(.log__diff) {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log__before,
|
||||
:deep(.log__before) {
|
||||
color: #8a8a8a;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.log__arrow,
|
||||
:deep(.log__arrow) {
|
||||
color: #b5b5b5;
|
||||
}
|
||||
|
||||
.log__after,
|
||||
:deep(.log__after) {
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
<template>
|
||||
<div class="login-left">
|
||||
<div class="login-left__inner">
|
||||
<header class="login-left__header">
|
||||
<div class="login-left__brand">
|
||||
<div class="login-left__logo">
|
||||
<img src="@/assets/images/logo.svg" alt="填报通" />
|
||||
</div>
|
||||
<div class="login-left__brand-text">
|
||||
<span class="login-left__title">填报通</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="login-left__hero">
|
||||
<h1 class="login-left__heading">
|
||||
报名材料 · 一站收集
|
||||
<span class="login-left__heading-sub">职业技能等级认定材料收集中台</span>
|
||||
</h1>
|
||||
<p class="login-left__desc">
|
||||
学生三分钟填完,老师一键过审 —— 系统按名册自动改名、把扫描件压到 1M 内,并生成可直接上报的汇总包。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="login-left__illustration" aria-hidden="true">
|
||||
<img class="login-left__illustration-img" src="@/assets/images/login-hero.svg"
|
||||
alt="散乱的材料自动归集为规范包" />
|
||||
</div>
|
||||
|
||||
<ul class="login-left__features">
|
||||
<li v-for="item in features" :key="item.title" class="login-left__feature">
|
||||
<span class="login-left__feature-icon">
|
||||
<Icon :icon="item.icon" :width="20" :height="20" />
|
||||
</span>
|
||||
<span class="login-left__feature-text">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<em>{{ item.subtitle }}</em>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
defineOptions({ name: 'LoginLeft' })
|
||||
|
||||
/**
|
||||
* 三条卖点对应产品最硬的三个能力(数字都来自实测):
|
||||
* · 规范文件名由名册生成,不靠学生手打 —— 归属判定靠证件号反查名册
|
||||
* · 扫描件强制压到 1M 内 —— 实测整班 444MB → 22MB,压缩率 95%
|
||||
* · 证件号加密存储 + 名单不下发前端 —— 门禁在服务端完成
|
||||
* 图标名已核对 icon-park-outline 图标集确实存在(写错会渲染成空白且不报错)。
|
||||
*/
|
||||
const features = [
|
||||
{ title: '自动改名', subtitle: '规范名由名册生成', icon: 'icon-park-outline:file-editing' },
|
||||
{ title: '强制压缩', subtitle: '扫描件压到 1M 内', icon: 'icon-park-outline:compression' },
|
||||
{ title: '隐私保护', subtitle: '证件号加密存储', icon: 'icon-park-outline:shield' }
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-left {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
// 两层叠加:
|
||||
// ① 极淡的点阵网格 —— 给大片留白一点"纸张"的质感,同时暗示"表单/表格"
|
||||
// ② 主色浅阶到白的斜向渐变 —— 承接渲染出来的头图
|
||||
// 点阵用主色很低的不透明度,换成别的主题色时自动跟随,不需要改图。
|
||||
background:
|
||||
radial-gradient(circle at 1px 1px, rgb(var(--primary-6), 0.06) 1px, transparent 0) 0 0 / 22px 22px,
|
||||
linear-gradient(165deg,
|
||||
rgb(var(--primary-1)) 0%,
|
||||
var(--color-bg-2) 42%,
|
||||
var(--color-bg-1) 100%);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -120px;
|
||||
right: -80px;
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
content: '';
|
||||
background: radial-gradient(circle, rgb(var(--primary-6), 0.12) 0%, transparent 68%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
bottom: 8%;
|
||||
left: -60px;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
content: '';
|
||||
background: radial-gradient(circle, rgb(var(--primary-6), 0.08) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: clamp(24px, 4vh, 48px) clamp(20px, 4vw, 56px) clamp(20px, 3vh, 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: clamp(20px, 3vh, 36px);
|
||||
}
|
||||
|
||||
&__brand {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 6px;
|
||||
background: var(--color-bg-1);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgb(var(--primary-6), 0.08);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&__brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: clamp(18px, 2.5vw, 26px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-1);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
&__hero {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: clamp(12px, 2vh, 24px);
|
||||
}
|
||||
|
||||
&__heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
font-size: clamp(24px, 3vw, 38px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-1);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
&__heading-sub {
|
||||
font-size: clamp(16px, 2vw, 22px);
|
||||
font-weight: 600;
|
||||
color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
&__desc {
|
||||
max-width: 480px;
|
||||
margin: 0;
|
||||
font-size: clamp(13px, 1.4vw, 15px);
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__illustration {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
margin: clamp(8px, 2vh, 24px) 0;
|
||||
}
|
||||
|
||||
&__illustration-img {
|
||||
width: min(100%, 520px);
|
||||
max-height: min(42vh, 320px);
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 12px 32px rgb(var(--primary-6), 0.1));
|
||||
animation: login-float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&__features {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__feature {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 14px 12px;
|
||||
background: color-mix(in srgb, var(--color-bg-1) 72%, transparent);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgb(var(--primary-6), 0.05);
|
||||
}
|
||||
|
||||
&__feature-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: rgb(var(--primary-6));
|
||||
background: rgb(var(--primary-1));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&__feature-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
em {
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-left__illustration-img {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes login-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <=1200px) {
|
||||
.login-left__features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<div class="login-palette">
|
||||
<div class="login-palette__dots">
|
||||
<div
|
||||
v-for="(color, index) in COLORS"
|
||||
:key="color"
|
||||
class="login-palette__dot"
|
||||
:style="{ 'background': color, '--index': index }"
|
||||
@click="handleClick(color)"
|
||||
/>
|
||||
</div>
|
||||
<div class="login-palette__btn">
|
||||
<icon-palette :size="18" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/hooks'
|
||||
|
||||
defineOptions({ name: 'LoginPalette' })
|
||||
|
||||
const COLORS = ['#165DFF', '#18A058', '#007AFF', '#5856D6', '#536dfe', '#AF52DE', '#f4511e', '#6d4c41']
|
||||
const { setThemeColor } = useTheme()
|
||||
|
||||
const handleClick = (color: string) => {
|
||||
setThemeColor(color)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-palette {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover &__dots {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&:hover &__dot {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
&:hover &__btn :deep(.arco-icon) {
|
||||
color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
&__dots {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 36px 8px 10px;
|
||||
pointer-events: none;
|
||||
border-radius: 100px;
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(10px);
|
||||
transform: translateX(10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgb(0 0 0 / 15%);
|
||||
opacity: 0;
|
||||
transform: translateX(20px) scale(0.8);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-delay: calc(var(--index) * 0.05s);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 8px rgb(0 0 0 / 20%);
|
||||
transform: translateX(0) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,133 +1,41 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<a-row align="stretch" class="login__row">
|
||||
<a-col :xs="0" :sm="0" :md="14" :lg="14" :xl="15" class="login__col login__col--brand">
|
||||
<LoginLeft />
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="24" :md="10" :lg="10" :xl="9" class="login__col">
|
||||
<div class="login__panel">
|
||||
<div class="login__toolbar">
|
||||
<LoginPalette />
|
||||
<GiThemeBtn />
|
||||
</div>
|
||||
|
||||
<div class="login__body">
|
||||
<div class="login__content">
|
||||
<header class="login__mobile-brand">
|
||||
<div class="login__mobile-logo">
|
||||
<img src="@/assets/images/logo.svg" alt="填报通" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="login__mobile-title">填报通</h2>
|
||||
<p class="login__mobile-desc">报名材料收集系统</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h3 class="login__title">欢迎回来</h3>
|
||||
<p class="login__subtitle">请使用管理员分配的账号登录</p>
|
||||
|
||||
<a-form ref="formRef" class="login__form" size="large" :model="form" :rules="rules" layout="vertical"
|
||||
:label-col-props="{ style: { marginBottom: '0' } }" @submit="onSubmit">
|
||||
<a-form-item field="username" label="账号">
|
||||
<a-input v-model="form.username" placeholder="请输入账号" allow-clear autocomplete="username"
|
||||
:disabled="loading">
|
||||
<template #prefix>
|
||||
<icon-user :stroke-width="3" class="login__input-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="password" label="密码">
|
||||
<a-input-password v-model="form.password" placeholder="请输入密码" allow-clear
|
||||
autocomplete="current-password" :disabled="loading" @press-enter="login">
|
||||
<template #prefix>
|
||||
<icon-lock :stroke-width="3" class="login__input-icon" />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 账号在多个租户下重名时才出现(服务端返回 40012 + "多个单位") -->
|
||||
<a-form-item v-if="needTenant" field="tenantCode" label="租户码">
|
||||
<a-input v-model="form.tenantCode" placeholder="如 demo" allow-clear :disabled="loading"
|
||||
@press-enter="login">
|
||||
<template #prefix>
|
||||
<icon-home :stroke-width="3" class="login__input-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
<template #extra>
|
||||
<span class="login__hint">该账号在多个单位下存在,请指明单位</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 连续失败 2 次后服务端才要求(返回 40001) -->
|
||||
<a-form-item v-if="needCaptcha" field="captchaCode" label="图形验证码">
|
||||
<div class="login__captcha">
|
||||
<a-input v-model="form.captchaCode" placeholder="请输入图中字符" allow-clear :max-length="6"
|
||||
:disabled="loading" @press-enter="login">
|
||||
<template #prefix>
|
||||
<icon-safe :stroke-width="3" class="login__input-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
<button class="login__captcha-img" type="button" title="点击刷新" @click="refreshCaptcha">
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="验证码" />
|
||||
<span v-else>加载中</span>
|
||||
</button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 服务端返回的错误统一显示在这里,而不是只用 toast(toast 会消失,用户看不到原因) -->
|
||||
<a-alert v-if="errText" class="login__alert" type="error">
|
||||
{{ errText }}
|
||||
</a-alert>
|
||||
|
||||
<a-form-item hide-label>
|
||||
<a-space direction="vertical" fill class="w-full">
|
||||
<a-button class="login__btn" type="primary" size="large" long html-type="submit" :loading="loading"
|
||||
:disabled="loading">
|
||||
登 录
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="login__footer">
|
||||
<span>忘记密码请联系本单位管理员重置</span>
|
||||
</footer>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInst, FormRules } from 'naive-ui'
|
||||
import { NAlert, NButton, NForm, NFormItem, NInput } from 'naive-ui'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getCaptcha } from '@/apis/xt'
|
||||
import { useLoading } from '@/hooks'
|
||||
import { useTabsStore, useUserStore } from '@/stores'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { registerMenus } from '@/router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { encryptPassword, fetchPublicKey, resetPublicKey } from '@/utils/rsa'
|
||||
import LoginLeft from './LoginLeft.vue'
|
||||
import LoginPalette from './LoginPalette.vue'
|
||||
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
/**
|
||||
* 登录页。
|
||||
*
|
||||
* 三个「按需出现」的机制,都是**服务端提示驱动**的,不靠前端猜:
|
||||
* 1. 图形验证码 —— 连续失败 2 次后服务端开始要求(返回 40001)
|
||||
* 2. 租户码 —— 账号在多个租户下重名时服务端要求(返回 40012 + "多个单位")
|
||||
* 3. 锁定倒计时 —— 连错 5 次后返回 40013
|
||||
* 三个「按需出现」的输入项都是**服务端提示驱动**的,前端不猜:
|
||||
* 1. 图形验证码 —— 连续失败若干次后服务端开始要求(返回 40001)
|
||||
* 2. 租户码 —— 账号在多个单位下同名时服务端拒绝猜测(返回 40012 + "多个单位")
|
||||
* 3. 错误原文 —— 服务端的 msg 直接落在页面上,而不是只弹一个会消失的 toast
|
||||
*
|
||||
* 密码用 RSA 公钥加密后传输,目的不是替代 HTTPS,
|
||||
* 而是防止网关/APM/链路追踪把明文密码记进日志。
|
||||
* 密码走 RSA 加密后传输:目的不是替代 HTTPS,而是**防止明文密码进网关/APM 日志**
|
||||
* (见 `@/utils/rsa`)。
|
||||
*/
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const tabsStore = useTabsStore()
|
||||
const user = useUserStore()
|
||||
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
const loading = ref(false)
|
||||
const errText = ref('')
|
||||
|
||||
/** 服务端要求时才显示 */
|
||||
const needTenant = ref(false)
|
||||
const needCaptcha = ref(false)
|
||||
|
||||
const captchaKey = ref('')
|
||||
const captchaImage = ref('')
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
@@ -136,94 +44,65 @@ const form = reactive({
|
||||
captchaCode: ''
|
||||
})
|
||||
|
||||
const rules: FormInstance['rules'] = {
|
||||
const rules = computed<FormRules>(() => ({
|
||||
username: [
|
||||
{ required: true, message: '请输入账号' },
|
||||
{ minLength: 2, message: '账号至少 2 个字符' }
|
||||
{ required: true, message: '请输入账号', trigger: ['blur', 'input'] },
|
||||
{ min: 2, message: '账号至少 2 个字符', trigger: ['blur'] }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ minLength: 6, message: '密码至少 6 位' }
|
||||
]
|
||||
}
|
||||
{ required: true, message: '请输入密码', trigger: ['blur', 'input'] },
|
||||
{ min: 6, message: '密码至少 6 位', trigger: ['blur'] }
|
||||
],
|
||||
tenantCode: needTenant.value
|
||||
? [{ required: true, message: '请填写租户码(单位编码)', trigger: ['blur', 'input'] }]
|
||||
: [],
|
||||
captchaCode: needCaptcha.value
|
||||
? [{ required: true, message: '请输入图形验证码', trigger: ['blur', 'input'] }]
|
||||
: []
|
||||
}))
|
||||
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const { loading, setLoading } = useLoading()
|
||||
|
||||
const needCaptcha = ref(false)
|
||||
const needTenant = ref(false)
|
||||
const errText = ref('')
|
||||
const captchaKey = ref('')
|
||||
const captchaImage = ref('')
|
||||
|
||||
/** 拉验证码:key 必须与提交时一起回传,服务端校验一次即失效 */
|
||||
async function refreshCaptcha() {
|
||||
/**
|
||||
* 拉验证码。
|
||||
*
|
||||
* key 必须与提交时一起回传,且服务端校验一次即失效 —— 所以每次失败后都要重新拉,
|
||||
* 而不是复用上一次那张图(复用会得到"验证码不正确",而用户填的是他刚看到的那张)。
|
||||
*/
|
||||
async function refreshCaptcha(): Promise<void> {
|
||||
try {
|
||||
const res = await getCaptcha()
|
||||
captchaKey.value = res.data.captchaKey
|
||||
captchaImage.value = res.data.image
|
||||
const vo = await getCaptcha()
|
||||
captchaKey.value = vo.captchaKey
|
||||
captchaImage.value = vo.image
|
||||
form.captchaCode = ''
|
||||
} catch {
|
||||
errText.value = '验证码加载失败,请点击图片重试'
|
||||
}
|
||||
}
|
||||
|
||||
function showCaptcha() {
|
||||
function showCaptcha(): void {
|
||||
if (!needCaptcha.value) {
|
||||
needCaptcha.value = true
|
||||
refreshCaptcha()
|
||||
}
|
||||
void refreshCaptcha()
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return
|
||||
/** 把业务码翻译成「下一步该做什么」,而不是只把服务端那句话显示出来 */
|
||||
function handleFailure(e: unknown): void {
|
||||
const err = e as { message?: string, bizCode?: number }
|
||||
errText.value = err?.message || '登录失败,请重试'
|
||||
|
||||
errText.value = ''
|
||||
setLoading(true)
|
||||
try {
|
||||
const cipher = await encryptPassword(form.password)
|
||||
await userStore.login({
|
||||
username: form.username.trim(),
|
||||
password: cipher,
|
||||
tenantCode: needTenant.value ? (form.tenantCode.trim() || undefined) : undefined,
|
||||
captchaKey: needCaptcha.value ? captchaKey.value : undefined,
|
||||
captchaCode: needCaptcha.value ? (form.captchaCode.trim() || undefined) : undefined
|
||||
})
|
||||
|
||||
tabsStore.reset()
|
||||
const { redirect, ...othersQuery } = router.currentRoute.value.query
|
||||
await router.push({
|
||||
path: (redirect as string) || '/',
|
||||
query: { ...othersQuery }
|
||||
})
|
||||
Message.success('登录成功')
|
||||
} catch (error) {
|
||||
handleFailure(error as Error & { bizCode?: number })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 把错误码翻译成「下一步该做什么」,而不是只显示一句话 */
|
||||
function handleFailure(e: Error & { bizCode?: number }) {
|
||||
errText.value = e.message || '登录失败,请重试'
|
||||
|
||||
switch (e.bizCode) {
|
||||
// 40001:本场景下就是「需要验证码」
|
||||
switch (err?.bizCode) {
|
||||
// 40001:登录场景下就是「验证码不正确或已过期」→ 确保验证码框在,并换一张
|
||||
case 40001:
|
||||
showCaptcha()
|
||||
refreshCaptcha()
|
||||
break
|
||||
// 40012:账号在多个租户下重名时,服务端拒绝猜测并要求指明单位
|
||||
// 40012:普通情况是「账号或密码错误」;只有"多个单位"那一条才需要租户码
|
||||
case 40012:
|
||||
if (e.message.includes('多个单位')) {
|
||||
if ((err.message ?? '').includes('多个单位')) {
|
||||
needTenant.value = true
|
||||
} else {
|
||||
showCaptcha()
|
||||
errText.value = '该账号在多个单位下存在,请填写租户码(单位编码)后重试'
|
||||
}
|
||||
break
|
||||
// 40101:token 失效(这里的场景是登录会话过期)
|
||||
// 40101:登录会话过期,缓存的公钥可能也轮换了,清掉让它下次重新取
|
||||
case 40101:
|
||||
resetPublicKey()
|
||||
break
|
||||
@@ -232,182 +111,390 @@ function handleFailure(e: Error & { bizCode?: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(data: { values: Record<string, unknown>, errors: Record<string, unknown> | undefined }) {
|
||||
if (data.errors) return
|
||||
login()
|
||||
async function onSubmit(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
// 校验失败时 Naive 会把每条错误显示在对应字段下,这里不需要再弹一次
|
||||
return
|
||||
}
|
||||
|
||||
errText.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const cipher = await encryptPassword(form.password)
|
||||
await user.login({
|
||||
username: form.username.trim(),
|
||||
password: cipher,
|
||||
tenantCode: needTenant.value ? (form.tenantCode.trim() || undefined) : undefined,
|
||||
captchaKey: needCaptcha.value ? captchaKey.value : undefined,
|
||||
captchaCode: needCaptcha.value ? (form.captchaCode.trim() || undefined) : undefined
|
||||
})
|
||||
|
||||
// ⚠️ 顺序不能反:先按菜单键注册动态路由,再跳转。
|
||||
// 反过来的话,目标路径此刻还没注册 → 直接落到 404,用户要刷新一次才能进
|
||||
registerMenus(user.menus)
|
||||
|
||||
const redirect = route.query.redirect
|
||||
await router.replace(typeof redirect === 'string' && redirect ? redirect : '/')
|
||||
} catch (e) {
|
||||
handleFailure(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 预取公钥:让点击登录时少一次往返,失败也无所谓(真正加密时会再取)
|
||||
/** 三条卖点对应产品最硬的三个能力,图标名都取自 icon-data.ts(写错会渲染成虚线占位) */
|
||||
const features = [
|
||||
{ icon: 'edit', title: '自动改名', desc: '规范文件名由名册生成,不靠学生手打' },
|
||||
{ icon: 'download', title: '强制压缩', desc: '扫描件统一压到 1M 内,清晰度零损失' },
|
||||
{ icon: 'idcard', title: '隐私保护', desc: '证件号加密存储,名单不下发前端' }
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
fetchPublicKey().catch(() => undefined)
|
||||
// 预取公钥:让点「登录」时少一次往返。失败也无妨 —— 真正加密时会再取一次
|
||||
void fetchPublicKey().catch(() => undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<template>
|
||||
<div class="login">
|
||||
<!-- 左:品牌区(窄屏隐藏,登录框才是必需的) -->
|
||||
<section class="login__brand">
|
||||
<header class="login__brand-head">
|
||||
<img class="login__logo" src="@/assets/images/logo.svg" alt="深学在线">
|
||||
<span class="login__brand-name">深学在线</span>
|
||||
</header>
|
||||
|
||||
<div class="login__hero">
|
||||
<h1 class="login__hero-title">
|
||||
报名材料 · 一站收集
|
||||
<em>职业技能等级认定材料收集中台</em>
|
||||
</h1>
|
||||
<p class="login__hero-desc">
|
||||
学生三分钟填完,老师一键过审 —— 系统按名册自动改名、把扫描件压到 1M 内,
|
||||
并生成可直接上报的汇总包。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="login__hero-img">
|
||||
<img src="@/assets/images/login-hero.svg" alt="散乱的材料自动归集为规范包">
|
||||
</div>
|
||||
|
||||
<ul class="login__features">
|
||||
<li v-for="f in features" :key="f.title" class="login__feature">
|
||||
<span class="login__feature-icon">
|
||||
<AppIcon :name="f.icon" :size="18" />
|
||||
</span>
|
||||
<span class="login__feature-text">
|
||||
<strong>{{ f.title }}</strong>
|
||||
<em>{{ f.desc }}</em>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 右:登录表单 -->
|
||||
<section class="login__panel">
|
||||
<div class="login__form-wrap">
|
||||
<h2 class="login__title">欢迎回来</h2>
|
||||
<p class="login__subtitle">请使用管理员分配的账号登录</p>
|
||||
|
||||
<NForm ref="formRef" :model="form" :rules="rules" label-placement="top" size="large">
|
||||
<NFormItem label="账号" path="username">
|
||||
<NInput
|
||||
v-model:value="form.username"
|
||||
placeholder="请输入账号"
|
||||
clearable
|
||||
:disabled="loading"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="密码" path="password">
|
||||
<NInput
|
||||
v-model:value="form.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="请输入密码"
|
||||
:disabled="loading"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<!-- 账号在多个单位下同名时才出现(服务端 40012 + "多个单位") -->
|
||||
<NFormItem v-if="needTenant" label="租户码" path="tenantCode">
|
||||
<NInput
|
||||
v-model:value="form.tenantCode"
|
||||
placeholder="单位编码,如 demo"
|
||||
clearable
|
||||
:disabled="loading"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="login__hint">该账号在多个单位下存在,请指明是哪一个单位</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<!-- 连续失败后服务端开始要求(40001) -->
|
||||
<NFormItem v-if="needCaptcha" label="图形验证码" path="captchaCode">
|
||||
<div class="login__captcha">
|
||||
<NInput
|
||||
v-model:value="form.captchaCode"
|
||||
placeholder="请输入图中字符"
|
||||
:maxlength="6"
|
||||
:disabled="loading"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
<button class="login__captcha-img" type="button" title="点击刷新" @click="refreshCaptcha">
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="验证码">
|
||||
<span v-else>加载中</span>
|
||||
</button>
|
||||
</div>
|
||||
</NFormItem>
|
||||
|
||||
<!-- 服务端的失败原因留在页面上:toast 会消失,用户回头就看不到为什么失败了 -->
|
||||
<NAlert v-if="errText" class="login__alert" type="error" :show-icon="false">
|
||||
{{ errText }}
|
||||
</NAlert>
|
||||
|
||||
<NButton
|
||||
class="login__btn"
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
@click="onSubmit"
|
||||
>
|
||||
登 录
|
||||
</NButton>
|
||||
</NForm>
|
||||
|
||||
<footer class="login__footer">
|
||||
忘记密码请联系本单位管理员重置
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ── 品牌区:极淡的点阵 + 主色浅阶渐变,给大片留白一点"纸张"质感 ── */
|
||||
.login__brand {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: clamp(24px, 4vh, 48px) clamp(24px, 4vw, 56px);
|
||||
overflow: auto;
|
||||
background:
|
||||
radial-gradient(circle at 1px 1px, rgb(0 82 217 / 6%) 1px, transparent 0) 0 0 / 22px 22px,
|
||||
linear-gradient(165deg, #eaf2ff 0%, #f7f9fc 42%, #fff 100%);
|
||||
}
|
||||
|
||||
.login__brand-head {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login__logo {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.login__brand-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.login__hero {
|
||||
flex: none;
|
||||
margin-top: clamp(20px, 4vh, 40px);
|
||||
}
|
||||
|
||||
.login__hero-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
font-size: clamp(24px, 2.6vw, 36px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.login__hero-title em {
|
||||
font-size: clamp(15px, 1.6vw, 20px);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
.login__hero-desc {
|
||||
max-width: 520px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.login__hero-img {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 150px;
|
||||
margin: clamp(8px, 2vh, 20px) 0;
|
||||
}
|
||||
|
||||
.login__hero-img img {
|
||||
max-width: min(88%, 520px);
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.login__features {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 28px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.login__feature {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login__feature-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: #0052d9;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgb(0 82 217 / 10%);
|
||||
}
|
||||
|
||||
.login__feature-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login__feature-text em {
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
/* ── 表单区 ── */
|
||||
.login__panel {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: min(46%, 520px);
|
||||
padding: 24px clamp(20px, 4vw, 56px);
|
||||
background: #fff;
|
||||
border-left: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.login__form-wrap {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.login__title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login__subtitle {
|
||||
margin: 6px 0 24px;
|
||||
font-size: 13px;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.login__hint {
|
||||
font-size: 12px;
|
||||
color: #f0a020;
|
||||
}
|
||||
|
||||
.login__alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login__captcha {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login__captcha-img {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 116px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: #8a8a8a;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #dbdbdb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.login__captcha-img img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-5);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__row {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.login__btn {
|
||||
height: 42px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
&__col {
|
||||
height: 100%;
|
||||
.login__footer {
|
||||
padding-top: 16px;
|
||||
margin-top: 22px;
|
||||
font-size: 12px;
|
||||
color: #8a8a8a;
|
||||
text-align: center;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
&--brand {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__panel {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
padding: 24px 20px 16px;
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: clamp(16px, 3vw, 30px);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__content {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
&__mobile-brand {
|
||||
/* 窄屏:品牌区让位,登录框独占 */
|
||||
@media (width <= 900px) {
|
||||
.login__brand {
|
||||
display: none;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
&__mobile-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-medium);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__mobile-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__mobile-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
margin: 8px 0 28px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__form {
|
||||
:deep(.arco-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__input-icon {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--warning-6));
|
||||
}
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__captcha {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
.login__panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__captcha-img {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 116px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border-3);
|
||||
border-radius: var(--border-radius-medium);
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 44px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding-top: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
&__mobile-brand {
|
||||
display: flex;
|
||||
}
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,98 +1,17 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>通知记录</template>
|
||||
<template #extra>
|
||||
<span class="notify__hint">
|
||||
「已发出」= 站内信已写进他的消息中心;微信推送要看学生有没有授权
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
⚠️ 这一行是刻意留的:一期的推送渠道只有微信订阅消息(一次性授权),
|
||||
拿不到授权时消息**只落站内信**。不写清这一点,
|
||||
运营会把「额度不足跳过」当成系统故障去查通道。
|
||||
-->
|
||||
<a-alert v-if="rate" type="normal" class="notify__rate">
|
||||
<template #title>
|
||||
送达率 <b>{{ rate }}</b>
|
||||
—— 分母含「额度不足跳过」。
|
||||
跳过**不是失败**:内容已经进了学生的消息中心,只是没有推送。
|
||||
要提升这个数字,该做的是引导学生授权,而不是查通道。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
|
||||
class="notify__task" :loading="taskLoading" @change="search">
|
||||
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.bizType" placeholder="全部类型" allow-clear size="small"
|
||||
class="notify__biz" @change="search">
|
||||
<a-option v-for="b in BIZ_TYPES" :key="b.value" :value="b.value">{{ b.label }}</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.status" placeholder="全部状态" allow-clear size="small"
|
||||
class="notify__status" @change="search">
|
||||
<a-option v-for="(v, k) in NOTIFY_STATUS" :key="k" :value="Number(k)">{{ v.text }}</a-option>
|
||||
</a-select>
|
||||
<a-button size="small" @click="load">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||
:scroll="{ x: 1100 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="时间" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<span class="notify__muted">{{ fmt(record.sendTime) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="收件人" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.receiverName || '—' }}</div>
|
||||
<div class="notify__muted">ID {{ record.receiverId }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="类型" :width="110">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small">{{ bizTypeText(record.bizType) }}</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="内容" :width="300">
|
||||
<template #cell="{ record }">
|
||||
<div class="notify__content">{{ record.content || '—' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="渠道" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<span class="notify__muted">{{ channelText(record.channel) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small" :color="notifyStatus(record.status).color">
|
||||
{{ record.statusText || notifyStatus(record.status).text }}
|
||||
</a-tag>
|
||||
<!-- 失败原因要露出来:不然「发送失败」这四个字没法排查 -->
|
||||
<div v-if="record.failReason" class="notify__fail" :title="record.failReason">
|
||||
{{ record.failReason }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { NotifyRecord } from '@/apis/xt/notify'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTag
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
BIZ_TYPES,
|
||||
bizTypeText,
|
||||
@@ -102,21 +21,23 @@ import {
|
||||
notifyStatus
|
||||
} from '@/apis/xt/notify'
|
||||
import { getTaskList } from '@/apis/xt/task'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { tagType } from '@/utils/tag'
|
||||
|
||||
/**
|
||||
* 通知记录(《03-接口设计》§13.3)。
|
||||
*
|
||||
* ## 这一屏存在的意义:把「通知到底发出去没有」变成可回答的
|
||||
*
|
||||
* 在此之前,驳回与审批只能回 `notifySent=false` 并让老师口头告知。
|
||||
* 现在每次通知都会写一条发送记录,所以运营能看到:
|
||||
* · **已送达**:站内信写进去了;
|
||||
* · **额度不足跳过**(3):内容进了站内信,但没推送 —— **这不是故障**,
|
||||
* 每次通知都会写一条发送记录,所以运营能看到三种结果:
|
||||
* · **已送达**(1):站内信写进去了;
|
||||
* · **额度不足跳过**(3):内容进了站内信,但**没有推送** —— 这**不是故障**,
|
||||
* 该做的是引导学生授权;
|
||||
* · **发送失败**(2):真的出了问题(微信接口异常等),要查通道。
|
||||
*
|
||||
* 把 2 和 3 混在一起显示成"失败",会让运营一直去查通道故障,
|
||||
* 而真正的原因(学生没授权)永远没人管。
|
||||
* 而真正的原因(学生没授权)永远没人管 —— 所以标签颜色与文案都分开。
|
||||
*/
|
||||
defineOptions({ name: 'NotifyRecords' })
|
||||
|
||||
@@ -125,7 +46,24 @@ const taskLoading = ref(false)
|
||||
const list = ref<NotifyRecord[]>([])
|
||||
const total = ref(0)
|
||||
const rate = ref('')
|
||||
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
|
||||
const taskOptions = ref<Array<{ label: string, value: number }>>([])
|
||||
|
||||
/**
|
||||
* `n-card` 的内容区要变成"能撑满的 flex 列"(列表页高度链的中间一环)。
|
||||
*
|
||||
* ⚠️ 曾经用 `:deep(.n-card__content)` 写这条,**但那是个不存在的类名**:
|
||||
* naive-ui 2.45.3 的内容区是 `.n-card-content`(`Card.mjs` 里 `cB("card-content")`)。
|
||||
* 匹配不到 → 内容区还是 `display: block` → `.page-card__body` 的 `flex: 1` 无处可长
|
||||
* → 表格只有 81px 高、分页器浮在卡片中间(不报错,只是"看起来没贴底")。
|
||||
* 所以这里用 `content-style` 直接写在元素上,不依赖类名。
|
||||
*/
|
||||
const contentStyle: CSSProperties = {
|
||||
flex: '1 1 auto',
|
||||
minHeight: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
|
||||
const query = reactive<{
|
||||
taskId?: number
|
||||
@@ -141,31 +79,98 @@ const query = reactive<{
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
/**
|
||||
* 送达率的分母含「额度不足跳过」。
|
||||
*
|
||||
* ⚠️ 这句解释必须留在界面上:不写清,运营看到 80% 就会去查通道,
|
||||
* 而实际该做的是引导学生授权(一次性订阅消息,发几次耗几次)。
|
||||
*/
|
||||
const rateText = computed(() => rate.value || '—')
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
Object.keys(NOTIFY_STATUS)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map((k) => ({ label: NOTIFY_STATUS[k].text, value: k }))
|
||||
)
|
||||
|
||||
/** `remote` 模式:每页条数改了必须重新取数,否则"分页器说 50、表里 20" */
|
||||
const pagination = computed(() => listPagination({
|
||||
page: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
itemCount: total.value,
|
||||
pageSizes: [20, 50, 100]
|
||||
}))
|
||||
|
||||
function fmt(v?: string) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<NotifyRecord>>(() => [
|
||||
{
|
||||
title: '时间',
|
||||
key: 'sendTime',
|
||||
width: 150,
|
||||
render: (row) => h('span', { class: 'notify__muted' }, fmt(row.sendTime))
|
||||
},
|
||||
{
|
||||
title: '收件人',
|
||||
key: 'receiverName',
|
||||
width: 140,
|
||||
render: (row) => h('div', [
|
||||
h('div', row.receiverName || '—'),
|
||||
h('div', { class: 'notify__muted' }, `ID ${row.receiverId ?? '—'}`)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'bizType',
|
||||
width: 110,
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false }, { default: () => bizTypeText(row.bizType) })
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
key: 'content',
|
||||
width: 300,
|
||||
// 关键词只用于本页过滤(接口没有内容搜索参数),所以不做服务端查询
|
||||
render: (row) => h('div', { class: 'notify__content' }, row.content || '—')
|
||||
},
|
||||
{
|
||||
title: '渠道',
|
||||
key: 'channel',
|
||||
width: 130,
|
||||
render: (row) => h('span', { class: 'notify__muted' }, channelText(row.channel))
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 170,
|
||||
render: (row) => {
|
||||
const meta = notifyStatus(row.status)
|
||||
return h('div', [
|
||||
h(NTag, { size: 'small', bordered: false, type: tagType(meta.color) }, { default: () => row.statusText || meta.text }),
|
||||
// 失败原因要露出来:不然「发送失败」这四个字没法排查
|
||||
row.failReason
|
||||
? h('div', { class: 'notify__fail', title: row.failReason }, row.failReason)
|
||||
: null
|
||||
])
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getNotifyRecords({
|
||||
const page = await getNotifyRecords({
|
||||
taskId: query.taskId,
|
||||
bizType: query.bizType,
|
||||
status: query.status,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
list.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
rate.value = res.data?.deliveryRate ?? ''
|
||||
list.value = page?.list ?? []
|
||||
total.value = page?.total ?? 0
|
||||
rate.value = page?.deliveryRate ?? ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -174,8 +179,8 @@ async function load() {
|
||||
async function loadTasks() {
|
||||
taskLoading.value = true
|
||||
try {
|
||||
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
|
||||
const page = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (page?.list ?? []).map((t) => ({ label: t.taskName, value: t.id }))
|
||||
} finally {
|
||||
taskLoading.value = false
|
||||
}
|
||||
@@ -191,54 +196,144 @@ function onPageChange(page: number) {
|
||||
load()
|
||||
}
|
||||
|
||||
load()
|
||||
loadTasks()
|
||||
function onPageSizeChange(size: number) {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notify {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<template>
|
||||
<NCard class="page-card notify" :bordered="false" title="通知记录" :content-style="contentStyle">
|
||||
<template #header-extra>
|
||||
<span class="toolbar__hint">
|
||||
「已送达」= 站内信已写进他的消息中心;微信推送要看学生有没有授权
|
||||
</span>
|
||||
</template>
|
||||
|
||||
&__rate {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
<!--
|
||||
⚠️ 这一段是刻意留的:推送渠道只有微信订阅消息(一次性授权),
|
||||
拿不到授权时消息**只落站内信**。不写清这一点,
|
||||
运营会把「额度不足跳过」当成系统故障去查通道。
|
||||
-->
|
||||
<NAlert type="info" class="notify__rate">
|
||||
<template #header>送达率 {{ rateText }}</template>
|
||||
分母含「额度不足跳过」。跳过<strong>不是失败</strong>:内容已经进了学生的消息中心,
|
||||
只是没有推送。要提升这个数字,该做的是引导学生授权,而不是查通道。
|
||||
</NAlert>
|
||||
|
||||
&__task {
|
||||
width: 200px;
|
||||
}
|
||||
<!--
|
||||
工具条:筛选控件**统一默认尺寸**(同一行不要大小混用)。
|
||||
之前全是 `size="small"`,28px 的白底控件贴在白底表格上几乎看不出边界。
|
||||
-->
|
||||
<div class="toolbar">
|
||||
<NSpace align="center" :size="8">
|
||||
<NSelect
|
||||
v-model:value="query.taskId"
|
||||
class="notify__task"
|
||||
clearable
|
||||
placeholder="全部任务"
|
||||
:loading="taskLoading"
|
||||
:options="taskOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.bizType"
|
||||
class="notify__biz"
|
||||
clearable
|
||||
placeholder="全部类型"
|
||||
:options="BIZ_TYPES"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.status"
|
||||
class="notify__status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
:options="statusOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NButton @click="load">
|
||||
<template #icon>
|
||||
<AppIcon name="refresh" :size="16" />
|
||||
</template>
|
||||
刷新
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
&__biz {
|
||||
width: 140px;
|
||||
}
|
||||
<div class="page-card__body notify__body">
|
||||
<!--
|
||||
尺寸口径:横向 `:scroll-x="1000"`(6 列合计),纵向 `flex-height` 撑满 `.page-card__body`
|
||||
—— Naive **没有** Arco 的 `:scroll="{ x, y }"`,照抄会被静默忽略(见 styles/index.scss)。
|
||||
-->
|
||||
<NDataTable
|
||||
remote
|
||||
flex-height
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="(row: NotifyRecord) => row.id"
|
||||
:pagination="pagination"
|
||||
:scroll-x="1000"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
&__status {
|
||||
width: 180px;
|
||||
}
|
||||
<style scoped>
|
||||
/**
|
||||
* 高度链的中间一环由 `contentStyle` 写在卡片内容元素上(原因见 script 里那段说明)。
|
||||
* 这里只保证"卡片内容区里的固定区块不被挤压",剩下的空间全给表格。
|
||||
*/
|
||||
.notify__rate {
|
||||
flex: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.notify__task {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.notify__biz {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
&__fail {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--red-6));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notify__status {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
/* ⚠️ 下面这些类都出现在**渲染函数**里(表格列)。渲染函数生成的元素不带 `data-v-*`,
|
||||
写成 `.notify__muted` 这种普通 scoped 选择器**匹配不到**(不报错,只是样式全丢),
|
||||
所以统一用 `:deep()`。 */
|
||||
:deep(.notify__muted) {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
:deep(.notify__content) {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
:deep(.notify__fail) {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: #d54941;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,60 +1,21 @@
|
||||
<template>
|
||||
<a-modal v-model:visible="visible" :title="title" :width="480" :mask-closable="false" @ok="confirm" @cancel="close">
|
||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
|
||||
<a-form-item v-if="!isEdit" label="上级组织">
|
||||
<a-input :model-value="parentName" disabled />
|
||||
<template #extra>
|
||||
<span class="org-form__hint">
|
||||
新增后不支持直接拖拽调整层级,请在建的时候就选对上级。
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="orgName" label="组织名称">
|
||||
<a-input v-model="form.orgName" placeholder="如 24中技数控1班" :max-length="128" allow-clear />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="orgType" label="组织类型">
|
||||
<a-select v-model="form.orgType" placeholder="请选择">
|
||||
<a-option v-for="(name, code) in ORG_TYPE_NAMES" :key="code" :value="Number(code)">
|
||||
{{ name }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<template #extra>
|
||||
<span class="org-form__hint">
|
||||
类型必须比上级更低一级:学校 > 院系/专业 > 班级。
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="orgCode" label="组织编码">
|
||||
<a-input v-model="form.orgCode" placeholder="班号或简称,租户内唯一" :max-length="64" allow-clear />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="sortNo" label="排序号">
|
||||
<a-input-number v-model="form.sortNo" :min="0" :max="9999" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import type { OrgTreeVO, OrgUpsertReq } from '@/apis/xt/org'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInst, FormRules } from 'naive-ui'
|
||||
import type { OrgTreeVO } from '@/apis/xt/org'
|
||||
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, NSpace, useMessage } from 'naive-ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import * as orgApi from '@/apis/xt/org'
|
||||
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
|
||||
|
||||
/**
|
||||
* 组织新增 / 编辑弹窗。
|
||||
*
|
||||
* 三条服务端规则在界面上提前提示,减少"提交了才知道不行":
|
||||
* 1. 组织编码租户内唯一
|
||||
* 2. 组织类型必须比上级更低一级
|
||||
* 3. 修改时不提供改上级(避免误操作把整棵子树搬走)
|
||||
* 三条服务端规则在界面上提前说明,减少"提交了才知道不行":
|
||||
* 1. 组织编码租户内唯一;
|
||||
* 2. 组织类型必须比上级低一级(学校 > 院系/专业 > 班级);
|
||||
* 3. **修改时不提供改上级** —— 改层级会连带搬走整棵子树,风险高,不开放。
|
||||
*
|
||||
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:它决定这个组织建到哪所学校下
|
||||
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。学校管理员传 null,行为与以前一致。
|
||||
* `tenantId` 由父页面传入,**只有平台运营会传值**:它决定这个组织建到哪所学校下。
|
||||
* 学校管理员的租户由 token 决定,传了也会被服务端忽略。
|
||||
*/
|
||||
defineOptions({ name: 'OrgFormModal' })
|
||||
|
||||
@@ -65,56 +26,78 @@ const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
|
||||
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 parentId = ref<number | null>(null)
|
||||
const parentName = ref('(顶级)')
|
||||
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
|
||||
const isEdit = computed(() => !!detailId.value)
|
||||
const title = computed(() => (isEdit.value ? '修改组织' : '新增组织'))
|
||||
|
||||
const getInitForm = (): OrgUpsertReq => ({
|
||||
parentId: null,
|
||||
orgName: '',
|
||||
orgType: 1,
|
||||
orgCode: '',
|
||||
sortNo: 0
|
||||
})
|
||||
const typeOptions = Object.entries(ORG_TYPE_NAMES).map(([code, name]) => ({
|
||||
label: name,
|
||||
value: Number(code)
|
||||
}))
|
||||
|
||||
const form = reactive<OrgUpsertReq>(getInitForm())
|
||||
|
||||
const rules: FormInstance['rules'] = {
|
||||
orgName: [{ required: true, message: '请填写组织名称' }],
|
||||
orgType: [{ required: true, message: '请选择组织类型' }],
|
||||
orgCode: [{ required: true, message: '请填写组织编码' }]
|
||||
/**
|
||||
* 表单模型。
|
||||
*
|
||||
* `sortNo` 用 `number | null` 而不是可选:`n-input-number` 被清空时回传 `null`,
|
||||
* 声明成 `number | undefined` 会在 v-model 上产生类型打架(运行期没事,但 vue-tsc 会红)。
|
||||
* 提交时再收敛成接口要的形状。
|
||||
*/
|
||||
interface OrgForm {
|
||||
parentId: number | null
|
||||
orgName: string
|
||||
orgType: number
|
||||
orgCode: string
|
||||
sortNo: number | null
|
||||
}
|
||||
|
||||
/** 由父节点推断下一级类型,减少用户选错 */
|
||||
function emptyForm(): OrgForm {
|
||||
return { parentId: null, orgName: '', orgType: 1, orgCode: '', sortNo: 0 }
|
||||
}
|
||||
|
||||
// 顺序要紧:下面的 computed 读的是 form,所以 form 必须先建好
|
||||
// (接口请求体在提交时手写组装:接口的 `sortNo?: number` 与表单的 `number | null` 不是一回事)
|
||||
const form = reactive<OrgForm>(emptyForm())
|
||||
|
||||
const rules: FormRules = {
|
||||
orgName: [{ required: true, message: '请填写组织名称', trigger: ['blur', 'input'] }],
|
||||
orgType: [{ required: true, message: '请选择组织类型', trigger: ['change'] }],
|
||||
orgCode: [{ required: true, message: '请填写组织编码', trigger: ['blur', 'input'] }]
|
||||
}
|
||||
|
||||
/** 由上级推断下一级类型,减少选错 */
|
||||
function nextTypeOf(parent: OrgTreeVO | null): number {
|
||||
if (!parent) return 1
|
||||
if (!parent) {
|
||||
return 1
|
||||
}
|
||||
return Math.min((parent.orgType ?? 1) + 1, 4)
|
||||
}
|
||||
|
||||
/** 新增 */
|
||||
function add(parent: OrgTreeVO | null) {
|
||||
/** 新增(parent 为 null 表示建顶级组织) */
|
||||
function add(parent: OrgTreeVO | null): void {
|
||||
detailId.value = null
|
||||
parentId.value = parent?.id ?? null
|
||||
parentName.value = parent?.orgName ?? '(顶级)'
|
||||
Object.assign(form, getInitForm())
|
||||
Object.assign(form, emptyForm())
|
||||
form.parentId = parent?.id ?? null
|
||||
form.orgType = nextTypeOf(parent)
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
/** 编辑 */
|
||||
function edit(row: OrgTreeVO) {
|
||||
function edit(row: OrgTreeVO): void {
|
||||
detailId.value = row.id
|
||||
parentId.value = null
|
||||
parentName.value = '(不修改)'
|
||||
Object.assign(form, getInitForm())
|
||||
Object.assign(form, emptyForm())
|
||||
form.orgName = row.orgName
|
||||
form.orgType = row.orgType
|
||||
form.orgCode = row.orgCode ?? ''
|
||||
@@ -122,34 +105,33 @@ function edit(row: OrgTreeVO) {
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return
|
||||
async function confirm(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
// 修改时不传 parentId:改层级由服务端重算子树路径,风险高,这里不开放
|
||||
await orgApi.updateOrg(detailId.value!, {
|
||||
if (isEdit.value && detailId.value) {
|
||||
// 修改时不传 parentId:改层级由服务端重算子树路径,这里不开放
|
||||
await orgApi.updateOrg(detailId.value, {
|
||||
orgName: form.orgName.trim(),
|
||||
orgType: form.orgType,
|
||||
orgCode: form.orgCode.trim(),
|
||||
sortNo: form.sortNo ?? 0
|
||||
}, props.tenantId)
|
||||
Message.success('已保存')
|
||||
message.success('已保存')
|
||||
} else {
|
||||
await orgApi.addOrg({
|
||||
parentId: form.parentId ?? null,
|
||||
parentId: parentId.value,
|
||||
orgName: form.orgName.trim(),
|
||||
orgType: form.orgType,
|
||||
orgCode: form.orgCode.trim(),
|
||||
sortNo: form.sortNo ?? 0
|
||||
}, props.tenantId)
|
||||
Message.success('已新增')
|
||||
message.success('已新增')
|
||||
}
|
||||
visible.value = false
|
||||
emit('save-success')
|
||||
@@ -161,10 +143,64 @@ async function confirm() {
|
||||
defineExpose({ add, edit })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
:title="title"
|
||||
style="width: 480px"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<NFormItem v-if="!isEdit" label="上级组织">
|
||||
<NInput :value="parentName" disabled />
|
||||
<template #feedback>
|
||||
<span class="org-form__hint">新增后不支持调整层级,请在建的时候就选对上级</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="组织名称" path="orgName">
|
||||
<NInput v-model:value="form.orgName" placeholder="如 24中技数控1班" :maxlength="128" clearable />
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="组织类型" path="orgType">
|
||||
<NSelect v-model:value="form.orgType" :options="typeOptions" placeholder="请选择" />
|
||||
<template #feedback>
|
||||
<span class="org-form__hint">类型必须比上级更低一级:学校 > 院系/专业 > 班级</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="组织编码" path="orgCode">
|
||||
<NInput v-model:value="form.orgCode" placeholder="班号或简称,租户内唯一" :maxlength="64" clearable />
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="排序号">
|
||||
<NInputNumber v-model:value="form.sortNo" :min="0" :max="9999" class="org-form__num" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="visible = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="submitting" @click="confirm">
|
||||
{{ isEdit ? '保存' : '新增' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 表单里的说明文字统一用"第三级灰",与页面/其它弹窗一致(原来是各写一个 #8a8a8a) */
|
||||
.org-form__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text-3);
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/* 排序号:占满弹窗的一列宽度(`n-input-number` 默认是内容宽,在一排里会缩成一小截) */
|
||||
.org-form__num {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
+490
-261
@@ -1,215 +1,169 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<!-- 左:组织树 -->
|
||||
<template #left>
|
||||
<a-card :bordered="false" class="org__tree-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<IconFolder />
|
||||
<span>组织架构</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<span class="org__hint">{{ totalNodes }} 个节点</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
平台运营必须先选学校:组织树本来就是租户内的东西,而平台的租户过滤是关掉的。
|
||||
不选就请求,拿到的是"所有租户的组织混在一棵树里"—— 只有一所学校有数据时,
|
||||
它和学校管理员看到的完全一样,看不出错。
|
||||
-->
|
||||
<GiTenantPicker
|
||||
v-if="isPlatform"
|
||||
v-model="actingStore.tenantId"
|
||||
class="org__tenant"
|
||||
placeholder="选择学校(平台运营)"
|
||||
/>
|
||||
|
||||
<a-input-search v-model="keyword" placeholder="搜索名称或编码" allow-clear class="org__search" />
|
||||
|
||||
<!--
|
||||
⚠️ Arco 的 `a-empty` 里一旦给了默认插槽,`description` 就**不再渲染**
|
||||
(它的实现是 `<slot>{{ description }}</slot>`)—— 所以文案要写在插槽里,
|
||||
否则页面上只剩半句话。
|
||||
-->
|
||||
<a-empty v-if="needPickTenant">
|
||||
<div class="org__empty-title">请先选择学校</div>
|
||||
<span class="org__hint">组织架构按学校隔离,选定学校后即可查看与维护</span>
|
||||
</a-empty>
|
||||
|
||||
<a-spin v-else-if="loading" class="org__loading" />
|
||||
|
||||
<a-empty v-else-if="!tree.length">
|
||||
<div class="org__empty-title">还没有组织</div>
|
||||
<a-button v-if="canManage" type="primary" size="small" @click="onAdd(null)">
|
||||
新增顶级组织
|
||||
</a-button>
|
||||
</a-empty>
|
||||
|
||||
<a-tree v-else v-model:selected-keys="selectedKeys" :data="treeData" :field-names="{ key: 'id', title: 'orgName', children: 'children' }"
|
||||
block-node :default-expand-all="true" @select="onSelect">
|
||||
<template #title="node">
|
||||
<span class="org__node">
|
||||
<span class="org__node-name">{{ node.orgName }}</span>
|
||||
<a-tag size="small" class="org__node-type">{{ ORG_TYPE_NAMES[node.orgType] ?? '—' }}</a-tag>
|
||||
<span v-if="node.studentCount" class="org__node-count">{{ node.studentCount }} 人</span>
|
||||
</span>
|
||||
</template>
|
||||
</a-tree>
|
||||
|
||||
<template #footer>
|
||||
<a-button v-if="canManage && !needPickTenant" long size="small" @click="onAdd(null)">
|
||||
<template #icon><IconPlus /></template>
|
||||
新增顶级组织
|
||||
</a-button>
|
||||
</template>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<!-- 右:详情 -->
|
||||
<a-card v-if="!selected" :bordered="false">
|
||||
<a-empty description="从左侧选择一个组织" />
|
||||
</a-card>
|
||||
|
||||
<template v-else>
|
||||
<a-card :bordered="false">
|
||||
<template #title>{{ selected.orgName }}</template>
|
||||
<template #extra>
|
||||
<a-tag>{{ ORG_TYPE_NAMES[selected.orgType] ?? '未知' }}</a-tag>
|
||||
</template>
|
||||
|
||||
<a-descriptions :column="2" size="large">
|
||||
<a-descriptions-item label="组织编码">{{ selected.orgCode ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="负责人">{{ selected.leaderName ?? '未指定' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="排序号">{{ selected.sortNo }}</a-descriptions-item>
|
||||
<a-descriptions-item label="学生数">{{ selected.studentCount ?? 0 }} 人</a-descriptions-item>
|
||||
<a-descriptions-item label="下级组织">{{ (selected.children ?? []).length }} 个</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="selected.status === 1 ? 'green' : 'gray'">
|
||||
{{ selected.status === 1 ? '正常' : '停用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-space v-if="canManage" class="org__acts">
|
||||
<a-button type="primary" size="small" @click="onAdd(selected)">
|
||||
<template #icon><IconPlus /></template>
|
||||
新增下级
|
||||
</a-button>
|
||||
<a-button size="small" @click="onEdit(selected)">
|
||||
<template #icon><IconEdit /></template>
|
||||
修改
|
||||
</a-button>
|
||||
<a-popconfirm content="删除后不可恢复,确认删除?" type="warning" @ok="onDelete(selected)">
|
||||
<a-button size="small" status="danger">
|
||||
<template #icon><IconDelete /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="(selected.children ?? []).length" :bordered="false">
|
||||
<template #title>下级组织</template>
|
||||
<template #extra>
|
||||
<span class="org__hint">{{ (selected.children ?? []).length }} 个</span>
|
||||
</template>
|
||||
<a-table :data="selected.children" :pagination="false" row-key="id" size="small">
|
||||
<template #columns>
|
||||
<a-table-column title="名称" data-index="orgName" />
|
||||
<a-table-column title="类型" :width="110">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small">{{ ORG_TYPE_NAMES[record.orgType] ?? '—' }}</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="编码" data-index="orgCode" :width="130" />
|
||||
<a-table-column title="学生数" data-index="studentCount" :width="90" />
|
||||
<a-table-column title="操作" :width="80">
|
||||
<template #cell="{ record }">
|
||||
<a-link @click="onSelect([record.id], { node: record })">查看</a-link>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<OrgFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns, TreeOption } from 'naive-ui'
|
||||
import type { VNodeChild } from 'vue'
|
||||
import type { OrgTreeVO } from '@/apis/xt/org'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NEmpty,
|
||||
NInput,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NTag,
|
||||
NTree,
|
||||
useDialog,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import * as orgApi from '@/apis/xt/org'
|
||||
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
|
||||
import { useActingTenantStore, useUserStore } from '@/stores'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useActingTenantStore } from '@/stores/acting-tenant'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import OrgFormModal from './OrgFormModal.vue'
|
||||
|
||||
/**
|
||||
* 组织架构。
|
||||
*
|
||||
* 形态:左树右详情,是管理后台最常规的主从布局。
|
||||
* 服务端的约束(编码唯一、类型层级、有下级不可删)在界面上提前提示,
|
||||
* 但不做前端拦截 —— 判定权威在服务端,前端只负责减少无效提交。
|
||||
* 形态:左树右详情(管理后台最常规的主从布局)。
|
||||
* 服务端的约束(编码唯一、类型必须比上级低一级、有下级或有账号不可删)在界面上提前提示,
|
||||
* 但**不做前端拦截** —— 判定权威在服务端,前端只负责减少无效提交。
|
||||
*
|
||||
* ⚠️ 平台运营必须先择校(入口在顶栏):组织树本来就是租户内的东西,而平台身份下
|
||||
* 服务端关掉了租户过滤 —— 不选就请求,拿到的是所有学校的组织混在一棵树里,
|
||||
* 而只有一所学校有数据时它与学校管理员看到的完全一样,看不出错。
|
||||
*/
|
||||
defineOptions({ name: 'Org' })
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
/**
|
||||
* 平台运营的「当前学校」。
|
||||
*
|
||||
* 服务端对平台关掉了租户过滤,所以平台**必须先指明看哪所学校**才能拿到租户内的数据
|
||||
* (见 `@/apis/xt/acting-tenant`)。选择存在 `useActingTenantStore` 里,
|
||||
* 与「账号管理」共用 —— 切页面不用重选,也不会出现两个页面各看一所学校。
|
||||
* 学校管理员的 `isPlatform` 为 false,这个值恒为 undefined —— 不传头,行为与以前一致。
|
||||
*/
|
||||
const actingStore = useActingTenantStore()
|
||||
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
||||
const needPickTenant = computed(() => isPlatform.value && !actingStore.tenantId)
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const user = useUserStore()
|
||||
const acting = useActingTenantStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref<OrgTreeVO[]>([])
|
||||
const selected = ref<OrgTreeVO | null>(null)
|
||||
const selectedKeys = ref<(string | number)[]>([])
|
||||
const selectedKeys = ref<Array<string | number>>([])
|
||||
const keyword = ref('')
|
||||
const formModalRef = useTemplateRef('formModalRef')
|
||||
|
||||
const canManage = computed(() => userStore.permissions.includes('org:manage'))
|
||||
// 弹窗暴露的方法在这里显式声明:比 `InstanceType<typeof X>` 更直白,
|
||||
// 也不依赖 vue-tsc 对 defineExpose 的推导细节
|
||||
const formModalRef = ref<{ add: (parent: OrgTreeVO | null) => void, edit: (row: OrgTreeVO) => void } | null>(null)
|
||||
|
||||
const isPlatform = computed(() => user.isPlatform)
|
||||
/** 平台没选学校时**不发**租户内数据的请求,界面显示一句指路 */
|
||||
const needPickTenant = computed(() => isPlatform.value && !acting.tenantId)
|
||||
const canManage = computed(() => user.has('org:manage'))
|
||||
const totalNodes = computed(() => countNodes(tree.value))
|
||||
|
||||
function countNodes(nodes: OrgTreeVO[]): number {
|
||||
return nodes.reduce((s, n) => s + 1 + countNodes(n.children ?? []), 0)
|
||||
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
|
||||
}
|
||||
|
||||
/** 按关键字过滤(命中则保留其祖先链) */
|
||||
const treeData = computed(() => {
|
||||
const k = keyword.value.trim().toLowerCase()
|
||||
if (!k) return tree.value
|
||||
const walk = (nodes: OrgTreeVO[]): OrgTreeVO[] =>
|
||||
nodes
|
||||
.map((n) => {
|
||||
const children = walk(n.children ?? [])
|
||||
const hit = `${n.orgName} ${n.orgCode ?? ''}`.toLowerCase().includes(k)
|
||||
return hit || children.length ? { ...n, children } : null
|
||||
})
|
||||
.filter(Boolean) as OrgTreeVO[]
|
||||
return walk(tree.value)
|
||||
})
|
||||
|
||||
function findById(nodes: OrgTreeVO[], id: number): OrgTreeVO | null {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n
|
||||
if (n.id === id) {
|
||||
return n
|
||||
}
|
||||
const hit = findById(n.children ?? [], id)
|
||||
if (hit) return hit
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function load() {
|
||||
// 平台没选学校时不发请求(服务端会返回空树,徒增一次往返与一次困惑)
|
||||
/**
|
||||
* 关键字过滤:命中的节点**连同它的祖先链**一起保留。
|
||||
* 只留命中节点的话,树会变成一堆没有上下文的孤立节点,反而找不到东西。
|
||||
*/
|
||||
const treeData = computed<OrgTreeVO[]>(() => {
|
||||
const k = keyword.value.trim().toLowerCase()
|
||||
if (!k) {
|
||||
return tree.value
|
||||
}
|
||||
const walk = (nodes: OrgTreeVO[]): OrgTreeVO[] => nodes
|
||||
.map((n) => {
|
||||
const children = walk(n.children ?? [])
|
||||
const hit = `${n.orgName} ${n.orgCode ?? ''}`.toLowerCase().includes(k)
|
||||
return hit || children.length ? { ...n, children } : null
|
||||
})
|
||||
.filter(Boolean) as OrgTreeVO[]
|
||||
return walk(tree.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* `n-tree` 要的是 `TreeOption`(`{ key, label, children }`),**不能直接喂 `OrgTreeVO[]`**:
|
||||
* TreeOption 带索引签名,而 OrgTreeVO 没有 —— 直接传会报 TS2322。
|
||||
*
|
||||
* 这里映射一层而不是 `as unknown as` 硬转:硬转会把"字段名写错"(比如写成 `orgname`)
|
||||
* 变成一个**运行期空白的树**,而编译期一声不吭。映射写错至少会被类型检查拦住。
|
||||
* `raw` 留着回查原始节点(类型标签、学生数都要用它)。
|
||||
*/
|
||||
interface OrgTreeOption extends TreeOption {
|
||||
key: number
|
||||
label: string
|
||||
children?: OrgTreeOption[]
|
||||
raw: OrgTreeVO
|
||||
}
|
||||
|
||||
function toTreeOptions(nodes: OrgTreeVO[]): OrgTreeOption[] {
|
||||
return (nodes ?? []).map((n) => ({
|
||||
key: n.id,
|
||||
label: n.orgName,
|
||||
children: n.children?.length ? toTreeOptions(n.children) : undefined,
|
||||
raw: n
|
||||
}))
|
||||
}
|
||||
|
||||
const treeOptions = computed<OrgTreeOption[]>(() => toTreeOptions(treeData.value))
|
||||
|
||||
/** 树节点右侧:类型标签 + 学生数(这两个是选班级时唯一要看的) */
|
||||
function renderSuffix(info: { option: TreeOption }): VNodeChild {
|
||||
const node = (info.option as OrgTreeOption).raw
|
||||
const parts: VNodeChild[] = []
|
||||
if (node?.orgType) {
|
||||
parts.push(h(NTag, { size: 'tiny', bordered: false }, { default: () => ORG_TYPE_NAMES[node.orgType] ?? '—' }))
|
||||
}
|
||||
if (node?.studentCount) {
|
||||
parts.push(h('span', { class: 'org__count' }, `${node.studentCount} 人`))
|
||||
}
|
||||
return h(NSpace, { size: 6, align: 'center', wrap: false }, { default: () => parts })
|
||||
}
|
||||
|
||||
/**
|
||||
* 下级组织的小表。
|
||||
* ⚠️ 列宽收紧过一档:卡片内容区只有 ~490px(1258 宽的窗口下),
|
||||
* 原来 类型110/编码130/学生数90/操作80 = 410,名称列被挤到 80px,
|
||||
* 「智能制造系」直接变成「智能制…」—— 名称才是这张表要看的那一列。
|
||||
*/
|
||||
const childColumns: DataTableColumns<OrgTreeVO> = [
|
||||
{ title: '名称', key: 'orgName', minWidth: 130, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '类型',
|
||||
key: 'orgType',
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false }, { default: () => ORG_TYPE_NAMES[row.orgType] ?? '—' })
|
||||
},
|
||||
{ title: '编码', key: 'orgCode', width: 116 },
|
||||
{ title: '学生数', key: 'studentCount', width: 76 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 68,
|
||||
render: (row) => h(
|
||||
NButton,
|
||||
{ text: true, type: 'primary', size: 'small', onClick: () => onSelect([row.id]) },
|
||||
{ default: () => '查看' }
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (needPickTenant.value) {
|
||||
tree.value = []
|
||||
selected.value = null
|
||||
@@ -218,9 +172,9 @@ async function load() {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await orgApi.getOrgTree({ withCount: true, tenantId: actingStore.tenantId })
|
||||
tree.value = res.data ?? []
|
||||
// 刷新后保持选中
|
||||
const nodes = await orgApi.getOrgTree({ withCount: true, tenantId: acting.tenantId })
|
||||
tree.value = nodes ?? []
|
||||
// 刷新后保持选中(并把选中项换成新树里的那个对象:旧对象里没有最新的 children)
|
||||
if (selected.value) {
|
||||
selected.value = findById(tree.value, selected.value.id)
|
||||
}
|
||||
@@ -229,111 +183,386 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
// 换学校 = 换一整棵树:不清空的话,上一所学校的节点会留在页面上,
|
||||
// 而"点进去看到的是别人学校的组织"这种事没有任何报错会提醒你
|
||||
watch(() => actingStore.tenantId, () => {
|
||||
// 换学校 = 换一整棵树。不清空的话,上一所学校的节点会留在页面上,
|
||||
// 而"点进去看到的是别人学校的组织"这件事没有任何报错会提醒你
|
||||
watch(() => acting.tenantId, () => {
|
||||
selected.value = null
|
||||
selectedKeys.value = []
|
||||
keyword.value = ''
|
||||
load()
|
||||
void load()
|
||||
})
|
||||
|
||||
/**
|
||||
* 树节点选中。
|
||||
*
|
||||
* 第二个参数的类型要写成「宽进」的 `{ node?: unknown }` 而不是 `{ node: OrgTreeVO }`:
|
||||
* Arco 的 `@select` 回调给的是 `{ selected?, selectedNodes, node?, e? }`,
|
||||
* 把参数声明得比它窄,函数就不可赋值给它,vue-tsc 会在模板上报类型错(运行时其实是好的)。
|
||||
* 这里收窄的动作放在函数体里做。
|
||||
*/
|
||||
function onSelect(keys: (string | number)[], data?: { node?: unknown }) {
|
||||
function onSelect(keys: Array<string | number>): void {
|
||||
const id = Number(keys[0] ?? '')
|
||||
if (!id) {
|
||||
selected.value = null
|
||||
return
|
||||
}
|
||||
const node = (data?.node ?? null) as OrgTreeVO | null
|
||||
selected.value = node?.id === id ? node : findById(tree.value, id)
|
||||
selectedKeys.value = id ? [id] : []
|
||||
selected.value = id ? findById(tree.value, id) : null
|
||||
}
|
||||
|
||||
function onAdd(parent: OrgTreeVO | null) {
|
||||
function onAdd(parent: OrgTreeVO | null): void {
|
||||
formModalRef.value?.add(parent)
|
||||
}
|
||||
|
||||
function onEdit(row: OrgTreeVO) {
|
||||
function onEdit(row: OrgTreeVO): void {
|
||||
formModalRef.value?.edit(row)
|
||||
}
|
||||
|
||||
async function onDelete(row: OrgTreeVO) {
|
||||
await orgApi.deleteOrg(row.id, actingStore.tenantId)
|
||||
Message.success('已删除')
|
||||
if (selected.value?.id === row.id) {
|
||||
selected.value = null
|
||||
selectedKeys.value = []
|
||||
}
|
||||
await load()
|
||||
function onDelete(row: OrgTreeVO): void {
|
||||
dialog.warning({
|
||||
title: `确认删除「${row.orgName}」?`,
|
||||
content: '删除后不可恢复。若该组织下面还有下级组织或账号,服务端会拒绝并列出占用清单。',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await orgApi.deleteOrg(row.id, acting.tenantId)
|
||||
message.success('已删除')
|
||||
if (selected.value?.id === row.id) {
|
||||
selected.value = null
|
||||
selectedKeys.value = []
|
||||
}
|
||||
await load()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.org {
|
||||
&__tree-card {
|
||||
height: 100%;
|
||||
}
|
||||
<template>
|
||||
<div class="md">
|
||||
<!--
|
||||
左:组织树 —— **一张独立卡片**。
|
||||
外观(白底 / 描边 / 阴影 / 圆角 / 卡片头灰条)由全局 `.n-card.n-card` 给,页面不再自己画;
|
||||
`page-card` 负责"撑满内容区高度",这样下面的树滚动条才有确定的可用高度。
|
||||
-->
|
||||
<!--
|
||||
⚠️ 不要再写 `:content-style="{ padding: '12px' }"`:内联样式会**盖掉全局的
|
||||
`.n-card.n-card > .n-card-content`**,于是左栏比右栏紧一圈(12 vs 16/18),
|
||||
而且它只解决左右、不管"有卡片头时 naive 不给上内边距"这件事(见 styles/index.scss)。
|
||||
-->
|
||||
<NCard class="md__side page-card" size="small">
|
||||
<template #header>
|
||||
<span class="md__side-head">
|
||||
<AppIcon name="org-group" :size="15" />
|
||||
组织架构
|
||||
</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="md__hint">{{ totalNodes }} 个节点</span>
|
||||
</template>
|
||||
|
||||
&__search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
<div class="md__side-body">
|
||||
<NInput
|
||||
v-model:value="keyword"
|
||||
clearable
|
||||
placeholder="搜索名称或编码"
|
||||
:disabled="needPickTenant"
|
||||
/>
|
||||
|
||||
&__tenant {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
<NEmpty
|
||||
v-if="needPickTenant"
|
||||
class="md__empty"
|
||||
description="组织按学校隔离:请先在右上角选择学校"
|
||||
/>
|
||||
<NSpin v-else :show="loading" class="md__spin">
|
||||
<!-- 只有树自己滚:搜索框与下面的按钮留在原地,不用滚上去才能点 -->
|
||||
<div class="md__tree">
|
||||
<NEmpty v-if="!tree.length" class="md__empty" description="还没有组织">
|
||||
<template #extra>
|
||||
<NButton v-if="canManage" type="primary" @click="onAdd(null)">
|
||||
新增顶级组织
|
||||
</NButton>
|
||||
</template>
|
||||
</NEmpty>
|
||||
<NTree
|
||||
v-else
|
||||
block-line
|
||||
selectable
|
||||
default-expand-all
|
||||
:indent="16"
|
||||
:data="treeOptions"
|
||||
:selected-keys="selectedKeys"
|
||||
:render-suffix="renderSuffix"
|
||||
@update:selected-keys="onSelect"
|
||||
/>
|
||||
</div>
|
||||
</NSpin>
|
||||
|
||||
&__loading {
|
||||
display: block;
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
<!-- 树为空时上面那块空态里已经有同一个按钮,这里不再重复放一个 -->
|
||||
<NButton
|
||||
v-if="canManage && !needPickTenant && tree.length"
|
||||
type="primary"
|
||||
block
|
||||
@click="onAdd(null)"
|
||||
>
|
||||
<template #icon>
|
||||
<AppIcon name="plus" :size="14" />
|
||||
</template>
|
||||
新增顶级组织
|
||||
</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<!--
|
||||
右:所选组织的详情 —— 同一套卡片语言。
|
||||
内容比卡片高时在**卡片内部**滚动(见 `.md__detail` 的 overflow 说明),
|
||||
这样左树的高度不会被右栏内容顶走。
|
||||
-->
|
||||
<NCard class="md__main page-card" size="small">
|
||||
<div class="md__detail page-card__body">
|
||||
<NEmpty v-if="!selected" class="md__detail-empty" description="从左侧选择一个组织" />
|
||||
|
||||
&__empty-title {
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
<template v-else>
|
||||
<div class="md__detail-head">
|
||||
<span class="md__detail-name">{{ selected.orgName }}</span>
|
||||
<NTag size="small" :bordered="false">{{ ORG_TYPE_NAMES[selected.orgType] ?? '未知' }}</NTag>
|
||||
</div>
|
||||
|
||||
&__node {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
<NDescriptions :column="2" label-placement="left" size="small" bordered>
|
||||
<NDescriptionsItem label="组织编码">{{ selected.orgCode ?? '—' }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="负责人">{{ selected.leaderName ?? '未指定' }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="排序号">{{ selected.sortNo }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="学生数">{{ selected.studentCount ?? 0 }} 人</NDescriptionsItem>
|
||||
<NDescriptionsItem label="下级组织">{{ (selected.children ?? []).length }} 个</NDescriptionsItem>
|
||||
<NDescriptionsItem label="状态">
|
||||
<NTag size="small" :type="selected.status === 1 ? 'success' : 'default'" :bordered="false">
|
||||
{{ selected.status === 1 ? '正常' : '停用' }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
&__node-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
<NSpace v-if="canManage" :size="8">
|
||||
<NButton type="primary" @click="onAdd(selected)">
|
||||
<template #icon>
|
||||
<AppIcon name="plus" :size="14" />
|
||||
</template>
|
||||
新增下级
|
||||
</NButton>
|
||||
<NButton @click="onEdit(selected)">
|
||||
<template #icon>
|
||||
<AppIcon name="edit" :size="14" />
|
||||
</template>
|
||||
修改
|
||||
</NButton>
|
||||
<NPopconfirm @positive-click="onDelete(selected)">
|
||||
<template #trigger>
|
||||
<NButton type="error" ghost>
|
||||
<template #icon>
|
||||
<AppIcon name="delete" :size="14" />
|
||||
</template>
|
||||
删除
|
||||
</NButton>
|
||||
</template>
|
||||
删除后不可恢复,确认删除?
|
||||
</NPopconfirm>
|
||||
</NSpace>
|
||||
|
||||
&__node-type {
|
||||
flex: none;
|
||||
transform: scale(0.88);
|
||||
}
|
||||
<!--
|
||||
下级组织:**不再另开一张卡片**(卡里套卡会糊成一片),
|
||||
用 `.section-title` 把它与上面的详情切开
|
||||
-->
|
||||
<div v-if="(selected.children ?? []).length" class="md__block">
|
||||
<div class="section-title">
|
||||
下级组织
|
||||
<span class="section-title__note">{{ (selected.children ?? []).length }} 个</span>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="childColumns"
|
||||
:data="selected.children ?? []"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:max-height="260"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
&__node-count {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<OrgFormModal ref="formModalRef" :tenant-id="acting.tenantId" @save-success="load" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
&__acts {
|
||||
padding-top: 14px;
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
<style scoped>
|
||||
/**
|
||||
* 主从两栏:左树 / 右详情**各是一张卡片**。
|
||||
*
|
||||
* ⚠️ 两栏都带全局的 `page-card`,高度由它按 `--xt-header-h` / `--xt-content-pad` 算出来,
|
||||
* 这里不再写 `calc(100vh - 56px - 28px)` —— 上一版写死 56/28,而内容区内边距是 16px(=32),
|
||||
* 整块比可视区高 4px,页面底部凭空多出一条滚动条。
|
||||
*/
|
||||
.md {
|
||||
display: flex;
|
||||
gap: var(--xt-gap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左栏:`flex: none` + 一个**基准宽度**(220~260 之间,树的层级多了会挤成两行,不能更窄)。
|
||||
* 窄屏收窄一档而不是让它自由伸缩:右表有 `min-width: 0` 能缩,树被压扁反而读不了。
|
||||
*/
|
||||
.md__side {
|
||||
flex: none;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
/* 与「账号管理」同一档,切页面时左栏宽度不跳 */
|
||||
@media (max-width: 1100px) {
|
||||
.md__side {
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 左栏内容:搜索框固定,树占满剩余高度并**自己滚**(卡片高度不跟着节点数长) */
|
||||
.md__side-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* NSpin 外面套了 container / content 两层 div,且默认都不是 flex ——
|
||||
* 不把 flex 链透下去,`.md__tree` 的 `flex: 1` 就没有可分配的父高度,树的滚动条不会出现
|
||||
* (卡片被内容顶高、整页跟着滚,正是上一版的症状)。
|
||||
*/
|
||||
.md__spin {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.md__spin :deep(.n-spin-content) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.md__tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 树的字号比正文小一档:左栏只有 220~260px,14px 下"班级名 + 类型标签"必然折成两行 */
|
||||
.md__tree :deep(.n-tree) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 原生滚动条在浅色卡片上是一道深色竖杠,收窄 + 调浅(Chromium 专属伪元素,不影响其它浏览器) */
|
||||
.md__tree::-webkit-scrollbar,
|
||||
.md__main .md__detail::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.md__tree::-webkit-scrollbar-thumb,
|
||||
.md__main .md__detail::-webkit-scrollbar-thumb {
|
||||
background: #d8dde5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.md__tree::-webkit-scrollbar-track,
|
||||
.md__main .md__detail::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 树的「轻选中」。
|
||||
*
|
||||
* naive 默认把选中态画成**整行实心色块**(`--n-node-color-active` 铺满 `.n-tree-node`,
|
||||
* `block-line` 下连缩进区一起染),在一张白卡片里比右边表格的行选中还重。
|
||||
* 这里改成:极浅蓝底 + 主色文字 + 左侧 3px 竖条。
|
||||
*
|
||||
* ⚠️ 必须覆盖 naive 的内部类:组件样式由 css-render 在**运行时**注入 <head>,
|
||||
* 与本 SFC 的样式先后顺序不保证 —— 所以 ① 显式写它的 CSS 变量(正常情况生效)、
|
||||
* ② 把类名重复一遍提到 (0,4,0) 权重兜底(naive 自己是 `.n-tree--block-line .n-tree-node--selected`),
|
||||
* 与 styles/index.scss 用 `.n-card.n-card` 是同一个道理。
|
||||
* `.n-tree-node` 自带 `position: relative`,竖条直接用绝对定位的 `::before`。
|
||||
*/
|
||||
.md__tree :deep(.n-tree-node.n-tree-node--selected) {
|
||||
--n-node-color-active: #eef4ff;
|
||||
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.md__tree :deep(.n-tree-node.n-tree-node--selected)::before {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
content: '';
|
||||
background: #0052d9;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.md__tree :deep(.n-tree-node.n-tree-node--selected .n-tree-node-content__text) {
|
||||
font-weight: 500;
|
||||
color: #0052d9;
|
||||
}
|
||||
|
||||
.md__empty {
|
||||
padding: 28px 0;
|
||||
}
|
||||
|
||||
.md__side-head {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.md__hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 右栏:卡片内容区(全局 `.page-card > .n-card-content` 已把 flex 链铺好)。
|
||||
* 详情比卡片高时**在卡片内部**滚 —— 用两段选择器提高权重,避免与全局 `.page-card__body`
|
||||
* 的 `overflow: hidden` 比"谁注入得晚"(那种顺序是不保证的)。
|
||||
*/
|
||||
.md__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.md__main .md__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--xt-gap);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 没选节点时把空态放在卡片正中间;`margin: auto` 在 flex 列里即"水平+垂直都居中" */
|
||||
.md__detail-empty {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
/* 组织名 + 类型标签:卡片内部的浅灰小格子,而不是再套一层卡片 */
|
||||
.md__detail-head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.md__detail-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--xt-text);
|
||||
}
|
||||
|
||||
.md__block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.org__count) {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const { params, query } = route
|
||||
const { path } = params
|
||||
|
||||
router.replace({ path: `/${path}`, query })
|
||||
</script>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInst, FormRules } from 'naive-ui'
|
||||
import type { RoleVO } from '@/apis/xt/role'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NSelect,
|
||||
NSpace,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { createRole, ROLE_USER_TYPE_NAMES, updateRole } from '@/apis/xt/role'
|
||||
import { useActingTenantStore } from '@/stores/acting-tenant'
|
||||
|
||||
/**
|
||||
* 自定义角色的新增 / 编辑弹窗。
|
||||
*
|
||||
* ## 为什么"新建"要选一个模板角色
|
||||
*
|
||||
* 服务端对新建角色的校验是 **至少一项权限 + 一个菜单**(`@NotEmpty`)——
|
||||
* 一个新角色如果什么都不给,它的账号登录后什么都看不到,那本来也没有意义。
|
||||
* 而内置角色的权限矩阵在界面上**看得见、改不了**,本来就该当"参考模板"用
|
||||
* (这也是内置角色只读的设计理由之一)。
|
||||
*
|
||||
* 所以这里的做法是:选一个模板角色 → 复制它的权限码与菜单 → 建好之后
|
||||
* 到右侧的授权矩阵里增删。好处是新建这一步只需要填 4 个字段,
|
||||
* 而授权仍然只有**一个**编辑入口(`RoleGrantEditor`),不会出现两套勾选逻辑。
|
||||
*
|
||||
* ## 角色码建后不可改
|
||||
*
|
||||
* 它会进 JWT 的 `roles`、出现在日志与排查对话里。改了它,历史日志里的角色码
|
||||
* 就成了一个查不到的东西。所以编辑时禁用(后端接口里也没有这个字段)。
|
||||
*/
|
||||
defineOptions({ name: 'RoleFormModal' })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 角色列表:模板下拉的候选(内置 + 已存在的自定义角色) */
|
||||
roles?: RoleVO[]
|
||||
}>(), {
|
||||
roles: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success', id?: number): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const acting = useActingTenantStore()
|
||||
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
|
||||
const isEdit = computed(() => !!detailId.value)
|
||||
const title = computed(() => (isEdit.value ? '修改角色' : '新建角色'))
|
||||
|
||||
const current = computed(() => props.roles.find((r) => r.id === detailId.value) ?? null)
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
roleCode: '',
|
||||
roleName: '',
|
||||
userType: 3,
|
||||
remark: '',
|
||||
/** 新建时的模板角色码:复制它的权限与菜单 */
|
||||
templateCode: 'HEAD_TEACHER'
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const userTypeOptions = Object.entries(ROLE_USER_TYPE_NAMES).map(([code, name]) => ({
|
||||
label: name,
|
||||
value: Number(code)
|
||||
}))
|
||||
|
||||
/** 模板候选:内置角色优先(它们才是"官方口径"的模板) */
|
||||
const templateOptions = computed(() => props.roles
|
||||
.filter((r) => r.id !== detailId.value)
|
||||
.map((r) => ({
|
||||
label: `${r.roleName}(${r.roleType === 1 ? '内置' : '自定义'}|${r.permCodes?.length ?? 0} 项权限)`,
|
||||
value: r.roleCode
|
||||
})))
|
||||
|
||||
/**
|
||||
* ⚠️ `userType` 的 `type: 'number'` **不能省**。
|
||||
*
|
||||
* 只有 `required` 时,async-validator 会把 `rule.type` 默认成 `'string'`
|
||||
* (`Schema.validate` 里 `rule.type = this.getType(rule)`),随后按
|
||||
* `typeof value !== rule.type` 报错;而 `userType` 是数字(1/2/3)——
|
||||
* 结果是**「创建」永远失败**,且错误信息被 `NFormItem` 的 `#feedback` 插槽挡住
|
||||
* (naive 的渲染是 `children || feedback`),界面上只剩一个没有说明的红框。
|
||||
*/
|
||||
const rules: FormRules = {
|
||||
roleCode: [
|
||||
{ required: true, message: '请填写角色码', trigger: ['blur', 'input'] },
|
||||
{
|
||||
pattern: /^[A-Z][A-Z0-9_]{1,63}$/,
|
||||
message: '只能是大写字母、数字与下划线,且以字母开头',
|
||||
trigger: ['blur', 'input']
|
||||
}
|
||||
],
|
||||
roleName: [{ required: true, message: '请填写角色名称', trigger: ['blur', 'input'] }],
|
||||
userType: [{ type: 'number', required: true, message: '请选择用户类型', trigger: ['change'] }]
|
||||
}
|
||||
|
||||
function add(): void {
|
||||
detailId.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
// 默认挑一个内置角色当模板:让"新建"这一步不用理解每一条权限码
|
||||
const preferred = props.roles.find((r) => r.roleCode === 'HEAD_TEACHER' && r.roleType === 1)
|
||||
if (preferred) {
|
||||
form.templateCode = preferred.roleCode
|
||||
form.userType = preferred.userType
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function edit(row: RoleVO): void {
|
||||
detailId.value = row.id
|
||||
Object.assign(form, emptyForm())
|
||||
form.roleCode = row.roleCode
|
||||
form.roleName = row.roleName
|
||||
form.userType = row.userType
|
||||
form.remark = row.remark ?? ''
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function confirm(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value && detailId.value) {
|
||||
await updateRole(detailId.value, {
|
||||
roleName: form.roleName.trim(),
|
||||
userType: form.userType,
|
||||
remark: form.remark.trim() || undefined
|
||||
})
|
||||
message.success('已保存')
|
||||
visible.value = false
|
||||
emit('save-success', detailId.value)
|
||||
return
|
||||
}
|
||||
|
||||
const tpl = props.roles.find((r) => r.roleCode === form.templateCode)
|
||||
if (!tpl) {
|
||||
message.warning('请选择作为模板的角色(新角色需要至少一项权限与一个菜单)')
|
||||
return
|
||||
}
|
||||
|
||||
const id = await createRole({
|
||||
roleCode: form.roleCode.trim().toUpperCase(),
|
||||
roleName: form.roleName.trim(),
|
||||
userType: form.userType,
|
||||
remark: form.remark.trim() || undefined,
|
||||
permCodes: [...(tpl.permCodes ?? [])],
|
||||
menus: [...(tpl.menus ?? [])]
|
||||
})
|
||||
message.success('已创建。下面可以在授权矩阵里增删权限与菜单')
|
||||
visible.value = false
|
||||
emit('save-success', id)
|
||||
} catch {
|
||||
// 提示已由 http 层给出(如角色码重复、平台级权限 40301、userType 与角色不符)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ add, edit })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
:title="title"
|
||||
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<NFormItem label="角色码" path="roleCode">
|
||||
<NInput
|
||||
v-model:value="form.roleCode"
|
||||
:disabled="isEdit"
|
||||
placeholder="如 CLASS_AUDITOR(大写字母 + 下划线)"
|
||||
/>
|
||||
<template #feedback>
|
||||
<span class="rf__hint">
|
||||
{{ isEdit
|
||||
? '角色码建后不可改:它已经进了 JWT 与历史日志,改了那些记录就查不到了'
|
||||
: '租户内唯一;它会出现在日志与排查对话里,所以用大写字母 + 下划线' }}
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="角色名称" path="roleName">
|
||||
<NInput v-model:value="form.roleName" placeholder="如 年级审核员" :maxlength="64" clearable />
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="用户类型" path="userType">
|
||||
<NSelect v-model:value="form.userType" :options="userTypeOptions" />
|
||||
<template #feedback>
|
||||
<span class="rf__hint">
|
||||
账号的用户类型由角色推导;{{ isEdit && (current?.userCount ?? 0) > 0
|
||||
? '该角色还有账号在用,服务端不允许改这一项'
|
||||
: '所以它必须选对' }}
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="!isEdit" label="以哪个角色为模板">
|
||||
<NSelect v-model:value="form.templateCode" :options="templateOptions" />
|
||||
<template #feedback>
|
||||
<span class="rf__hint">
|
||||
复制它的权限码与菜单;建好后在右侧授权矩阵里增删。新角色至少要有一项权限与一个菜单
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="备注">
|
||||
<NInput
|
||||
v-model:value="form.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:maxlength="255"
|
||||
placeholder="这个角色是给谁用的(选填)"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<NAlert type="info" :show-icon="false" class="rf__note">
|
||||
保存授权后<strong>立即生效</strong>,不需要重新登录。
|
||||
<template v-if="acting.tenantId">当前操作的是所选学校的角色。</template>
|
||||
</NAlert>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="visible = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="submitting" @click="confirm">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rf__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/* 表单与结论条之间留出间隔:不隔开的话,提示会像最后一个表单项的说明 */
|
||||
.rf__note {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import type { PermGroupVO, PermVO, RoleVO } from '@/apis/xt/role'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCheckbox,
|
||||
NCheckboxGroup,
|
||||
NSpace,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { setRoleMenus, setRolePermissions } from '@/apis/xt/role'
|
||||
import { DASHBOARD, GROUPS, XT_MENUS } from '@/config/xt-routes'
|
||||
|
||||
/**
|
||||
* 授权矩阵(权限码 + 可见菜单)。
|
||||
*
|
||||
* ## 四条口径(决定了这个组件长什么样)
|
||||
*
|
||||
* 1. **内置角色只读**:`role.editable === false` 时整块不可勾选。它的权限由后端代码
|
||||
* (`BuiltinRole`)决定 —— 一次误操作就能把管理员锁在系统外,而这类错误不可逆。
|
||||
* 所以内置角色在这里的作用是"参考模板":照着它新建一个自定义角色。
|
||||
* 2. **改完立即生效**:服务端每次写授权都会清掉该角色的缓存,**不需要重新登录**。
|
||||
* 这一点必须写在按钮旁边 —— 不写的话,用户会习惯性地"重新登录一次看看生效没有"。
|
||||
* 3. **只提交改动过的那一项**:权限与菜单是两个接口,只把脏的那一项发出去,
|
||||
* 避免"改菜单顺带把权限也重写一遍"这种放大操作。
|
||||
* 4. **平台级能力要挡住**:`platformOnly` 的权限码与「租户管理」菜单在学校租户里
|
||||
* 发不出去(服务端 40301)。界面把它们禁用,而不是让人点完收到一个错误。
|
||||
*/
|
||||
defineOptions({ name: 'RoleGrantEditor' })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
role: RoleVO
|
||||
permGroups: PermGroupVO[]
|
||||
/** 当前是否在**平台租户**下操作(决定平台级能力可否勾选) */
|
||||
platformTenant?: boolean
|
||||
}>(), {
|
||||
platformTenant: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const perms = ref<string[]>([])
|
||||
const menus = ref<string[]>([])
|
||||
const savingPerm = ref(false)
|
||||
const savingMenu = ref(false)
|
||||
|
||||
/** 角色切换(或保存后父组件刷新)时,把本地选择对齐到服务端那份 */
|
||||
function resetFromRole(): void {
|
||||
perms.value = [...(props.role.permCodes ?? [])]
|
||||
menus.value = [...(props.role.menus ?? [])]
|
||||
}
|
||||
|
||||
watch(() => props.role, resetFromRole, { immediate: true, deep: false })
|
||||
|
||||
const isReadonly = computed(() => props.role.editable === false)
|
||||
|
||||
/** 比较两个字符串集合是否等价(顺序无关)—— 用它判"这一项是否改过" */
|
||||
function sameSet(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
const set = new Set(b)
|
||||
return a.every((x) => set.has(x))
|
||||
}
|
||||
|
||||
const permDirty = computed(() => !sameSet(perms.value, props.role.permCodes ?? []))
|
||||
const menuDirty = computed(() => !sameSet(menus.value, props.role.menus ?? []))
|
||||
const dirty = computed(() => permDirty.value || menuDirty.value)
|
||||
|
||||
/** 至少一项权限 + 一个菜单:与后端 RoleCreateReq 的校验同口径,避免"点了必然 40001" */
|
||||
const canSave = computed(() => perms.value.length > 0 && menus.value.length > 0)
|
||||
|
||||
function isPlatformOnly(p: PermVO): boolean {
|
||||
return p.platformOnly === true
|
||||
}
|
||||
|
||||
function permDisabled(p: PermVO): boolean {
|
||||
return isReadonly.value || (isPlatformOnly(p) && !props.platformTenant)
|
||||
}
|
||||
|
||||
function platformText(p: PermVO): string {
|
||||
if (p.platform === 'MP') {
|
||||
return '仅小程序'
|
||||
}
|
||||
if (p.platform === 'BOTH') {
|
||||
return '两端'
|
||||
}
|
||||
return '仅 PC'
|
||||
}
|
||||
|
||||
/** 菜单候选:工作台 + 业务菜单;平台专用菜单(租户管理)只在平台租户里出现 */
|
||||
const menuGroups = computed(() => {
|
||||
const platformOnlyKeys = new Set(['tenant'])
|
||||
const business = XT_MENUS.filter((m) => props.platformTenant || !platformOnlyKeys.has(m.key))
|
||||
const groups = GROUPS
|
||||
.map((g) => ({ title: g.title, items: business.filter((m) => m.group === g.id) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
return [
|
||||
{ title: '通用', items: [DASHBOARD] },
|
||||
...groups
|
||||
]
|
||||
})
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!canSave.value) {
|
||||
message.warning('至少保留一项权限与一个菜单 —— 否则这个角色的人登录后什么都看不到')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 只提交改动过的那一项
|
||||
if (permDirty.value) {
|
||||
savingPerm.value = true
|
||||
await setRolePermissions(props.role.id, perms.value)
|
||||
savingPerm.value = false
|
||||
}
|
||||
if (menuDirty.value) {
|
||||
savingMenu.value = true
|
||||
await setRoleMenus(props.role.id, menus.value)
|
||||
savingMenu.value = false
|
||||
}
|
||||
message.success('已保存,立即生效(不必重新登录)')
|
||||
emit('saved')
|
||||
} catch {
|
||||
// 提示已由 http 层给出(如平台级能力 40301、自锁保护)
|
||||
} finally {
|
||||
savingPerm.value = false
|
||||
savingMenu.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grant">
|
||||
<NAlert v-if="isReadonly" type="default" :show-icon="false" class="grant__note">
|
||||
内置角色的权限由<strong>代码决定,不可修改</strong>。
|
||||
要调整授权范围,请「新建」一个自定义角色,照着这份矩阵勾选。
|
||||
</NAlert>
|
||||
|
||||
<div class="grant__section">
|
||||
<div class="grant__head">
|
||||
<div class="section-title">
|
||||
权限码
|
||||
<span class="section-title__note">决定"能做什么"(按钮与接口校验)</span>
|
||||
</div>
|
||||
<NTag v-if="permDirty" size="small" type="warning" :bordered="false">
|
||||
有改动
|
||||
</NTag>
|
||||
</div>
|
||||
|
||||
<div v-for="g in permGroups" :key="g.group" class="grant__group">
|
||||
<div class="grant__group-title">
|
||||
{{ g.group }}
|
||||
</div>
|
||||
<NCheckboxGroup v-model:value="perms" :disabled="isReadonly">
|
||||
<div class="grant__items">
|
||||
<NCheckbox
|
||||
v-for="p in g.items"
|
||||
:key="p.code"
|
||||
:value="p.code"
|
||||
:disabled="permDisabled(p)"
|
||||
>
|
||||
<span class="grant__item-label">{{ p.label }}</span>
|
||||
<span class="grant__code">{{ p.code }}</span>
|
||||
<NTag size="tiny" :bordered="false" class="grant__tag">
|
||||
{{ platformText(p) }}
|
||||
</NTag>
|
||||
<NTag v-if="isPlatformOnly(p)" size="tiny" type="error" :bordered="false" class="grant__tag">
|
||||
平台专用{{ platformTenant ? '' : '(不可授予)' }}
|
||||
</NTag>
|
||||
</NCheckbox>
|
||||
</div>
|
||||
</NCheckboxGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grant__section">
|
||||
<div class="grant__head">
|
||||
<div class="section-title">
|
||||
可见菜单
|
||||
<span class="section-title__note">
|
||||
只决定"左侧看得见哪些入口",<strong>不代表有权限操作</strong>
|
||||
</span>
|
||||
</div>
|
||||
<NTag v-if="menuDirty" size="small" type="warning" :bordered="false">
|
||||
有改动
|
||||
</NTag>
|
||||
</div>
|
||||
|
||||
<div v-for="g in menuGroups" :key="g.title" class="grant__group">
|
||||
<div class="grant__group-title">
|
||||
{{ g.title }}
|
||||
</div>
|
||||
<NCheckboxGroup v-model:value="menus" :disabled="isReadonly">
|
||||
<div class="grant__items">
|
||||
<NCheckbox v-for="m in g.items" :key="m.key" :value="m.key" :label="m.title" />
|
||||
</div>
|
||||
</NCheckboxGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isReadonly" class="grant__foot">
|
||||
<NSpace :size="10" align="center">
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="savingPerm || savingMenu"
|
||||
:disabled="!dirty || !canSave"
|
||||
@click="save"
|
||||
>
|
||||
保存授权
|
||||
</NButton>
|
||||
<NButton :disabled="!dirty" @click="resetFromRole">
|
||||
撤销改动
|
||||
</NButton>
|
||||
<span class="grant__hint">保存后<strong>立即生效</strong>,不必重新登录</span>
|
||||
</NSpace>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grant__note {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 两块之间用 20px 间隔 + 一条 1px 分隔线(分隔线在 .grant__head 上) */
|
||||
.grant__section + .grant__section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.grant__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--xt-card-border-weak);
|
||||
}
|
||||
|
||||
/* 全局 .section-title 自带 12px 下外边距,这里由 .grant__head 统一控制间距 */
|
||||
.grant__head .section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grant__hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.grant__group {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.grant__group-title {
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.grant__items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 4px 12px;
|
||||
}
|
||||
|
||||
.grant__item-label {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.grant__code {
|
||||
margin-right: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.grant__tag {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.grant__foot {
|
||||
padding-top: 14px;
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid var(--xt-card-border-weak);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
import type { PermGroupVO, RoleVO } from '@/apis/xt/role'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NEmpty,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NTag,
|
||||
useDialog,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
DATA_SCOPE_NAMES,
|
||||
deleteRole,
|
||||
getPermissions,
|
||||
getRoles,
|
||||
setRoleStatus
|
||||
} from '@/apis/xt/role'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useActingTenantStore } from '@/stores/acting-tenant'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import RoleFormModal from './RoleFormModal.vue'
|
||||
import RoleGrantEditor from './RoleGrantEditor.vue'
|
||||
|
||||
/**
|
||||
* 角色与授权。
|
||||
*
|
||||
* ## 界面上的核心分叉:内置角色 vs 自定义角色
|
||||
*
|
||||
* 内置角色(`roleType === 1`)**看得见、改不了**:它的权限由后端代码(`BuiltinRole`)决定。
|
||||
* 所以它的作用只有一个 —— 当"参考模板":照着它新建一个自定义角色,
|
||||
* 而不是试图去改它然后收到 40001。
|
||||
*
|
||||
* ## 改完立即生效
|
||||
*
|
||||
* 服务端每次写授权都会清掉该角色的缓存,所以**不需要重新登录**:
|
||||
* 保存后刷新页面就能看到按钮与菜单的变化。这一点写在按钮旁边 ——
|
||||
* 不写的话,用户会习惯性地"重新登录一次看看生效没有"。
|
||||
*
|
||||
* ## 数据范围为什么只展示
|
||||
*
|
||||
* 它目前由"拿到了哪些权限码 + 任务里被指派了哪些班"共同决定(散在审核 / 催报 / 代传 / 看板各处)。
|
||||
* 做成可配需要先把那些判据收口(单独一批)。让界面出现一个"改了不生效"的开关,
|
||||
* 比没有这个开关更糟 —— 所以这里只显示它现在是什么。
|
||||
*
|
||||
* ⚠️ 平台运营要先择校(顶栏):角色是租户内的东西,而平台的租户过滤是关掉的。
|
||||
*/
|
||||
defineOptions({ name: 'Role' })
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const user = useUserStore()
|
||||
const acting = useActingTenantStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const roles = ref<RoleVO[]>([])
|
||||
const current = ref<RoleVO | null>(null)
|
||||
const permGroups = ref<PermGroupVO[]>([])
|
||||
|
||||
const formModalRef = ref<{
|
||||
add: () => void
|
||||
edit: (row: RoleVO) => void
|
||||
} | null>(null)
|
||||
|
||||
const isPlatform = computed(() => user.isPlatform)
|
||||
const needPickTenant = computed(() => isPlatform.value && !acting.tenantId)
|
||||
const canManage = computed(() => user.has('role:manage'))
|
||||
/** 授权矩阵只在**平台租户**下允许勾平台级能力;代管某所学校时不允许 */
|
||||
const platformTenant = computed(() => isPlatform.value && !acting.tenantId)
|
||||
|
||||
async function loadRoles(): Promise<void> {
|
||||
if (needPickTenant.value) {
|
||||
roles.value = []
|
||||
current.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
roles.value = await getRoles(acting.tenantId) ?? []
|
||||
// 刷新后保持选中(并把选中项换成新数组里的对象)
|
||||
if (current.value) {
|
||||
current.value = roles.value.find((r) => r.id === current.value?.id) ?? null
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPermissions(): Promise<void> {
|
||||
// 权限码目录要 role:manage;没有它就不发这个请求(403 会先弹一个提示,catch 拦不住)
|
||||
if (!canManage.value) {
|
||||
permGroups.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
permGroups.value = await getPermissions() ?? []
|
||||
} catch {
|
||||
permGroups.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => acting.tenantId, () => {
|
||||
current.value = null
|
||||
void loadRoles()
|
||||
void loadPermissions()
|
||||
})
|
||||
|
||||
function pick(r: RoleVO): void {
|
||||
current.value = r
|
||||
}
|
||||
|
||||
async function onCreated(id?: number): Promise<void> {
|
||||
await loadRoles()
|
||||
if (id) {
|
||||
current.value = roles.value.find((r) => r.id === id) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function onToggleStatus(row: RoleVO, status: number): void {
|
||||
const action = status === 1 ? '启用' : '停用'
|
||||
dialog.warning({
|
||||
title: `确认${action}「${row.roleName}」?`,
|
||||
content: status === 1
|
||||
? '启用后,持有该角色的账号立刻恢复它带来的权限。'
|
||||
: '停用后该角色立刻不再给权限(不必等 token 过期);挂在它下面的账号会失去这些权限。',
|
||||
positiveText: action,
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await setRoleStatus(row.id, status)
|
||||
message.success(`已${action}`)
|
||||
await loadRoles()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function onDelete(row: RoleVO): Promise<void> {
|
||||
try {
|
||||
await deleteRole(row.id)
|
||||
message.success('已删除')
|
||||
if (current.value?.id === row.id) {
|
||||
current.value = null
|
||||
}
|
||||
await loadRoles()
|
||||
} catch {
|
||||
// 40045(还有账号在用)等提示已由 http 层给出
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadRoles()
|
||||
void loadPermissions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="md">
|
||||
<!-- 左:角色列表(独立卡片,与右栏之间留 16px) -->
|
||||
<NCard class="md__side" :bordered="false" size="small">
|
||||
<template #header>
|
||||
<div class="md__side-head">
|
||||
<AppIcon name="role" :size="15" />
|
||||
<span>角色({{ roles.length }})</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NButton
|
||||
v-if="canManage"
|
||||
type="primary"
|
||||
:disabled="needPickTenant"
|
||||
@click="formModalRef?.add()"
|
||||
>
|
||||
新建
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<NEmpty
|
||||
v-if="needPickTenant"
|
||||
class="md__empty"
|
||||
description="角色按学校隔离:请先在右上角选择学校"
|
||||
/>
|
||||
<NSpin v-else :show="loading" class="md__list-wrap">
|
||||
<NEmpty v-if="!roles.length" class="md__empty" description="还没有角色" />
|
||||
<div
|
||||
v-for="r in roles"
|
||||
:key="r.id"
|
||||
class="md__row"
|
||||
:class="{ 'md__row--on': current?.id === r.id }"
|
||||
@click="pick(r)"
|
||||
>
|
||||
<div class="md__row-main">
|
||||
<span class="md__row-name">{{ r.roleName }}</span>
|
||||
<NSpace :size="4" :wrap="true">
|
||||
<NTag v-if="r.roleType === 1" size="small" type="info" :bordered="false">
|
||||
内置
|
||||
</NTag>
|
||||
<NTag v-if="r.status === 0" size="small" :bordered="false">
|
||||
已停用
|
||||
</NTag>
|
||||
<NTag v-if="r.assignable === false" size="small" type="warning" :bordered="false">
|
||||
不可分配
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</div>
|
||||
<div class="md__hint">
|
||||
{{ r.roleCode }} · {{ r.userCount ?? 0 }} 个账号
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</NCard>
|
||||
|
||||
<!-- 右:角色详情 + 授权矩阵(两张独立卡片,同上留 16px) -->
|
||||
<section class="md__main">
|
||||
<NCard v-if="needPickTenant" class="md__blank" :bordered="false" size="small">
|
||||
<NEmpty description="请先在右上角选择学校" />
|
||||
</NCard>
|
||||
<NCard v-else-if="!current" class="md__blank" :bordered="false" size="small">
|
||||
<NEmpty description="从左边选一个角色" />
|
||||
</NCard>
|
||||
|
||||
<template v-else>
|
||||
<NCard class="role__card" :bordered="false" size="small">
|
||||
<template #header>
|
||||
<div class="role__head">
|
||||
<span class="role__name">{{ current.roleName }}</span>
|
||||
<span class="role__code">{{ current.roleCode }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NSpace :size="8" align="center">
|
||||
<NTag size="small" :bordered="false" :type="current.roleType === 1 ? 'info' : 'success'">
|
||||
{{ current.roleType === 1 ? '内置(只读)' : '自定义' }}
|
||||
</NTag>
|
||||
<NTag size="small" :bordered="false" :type="current.status === 1 ? 'success' : 'default'">
|
||||
{{ current.status === 1 ? '启用' : '已停用' }}
|
||||
</NTag>
|
||||
<template v-if="current.editable">
|
||||
<NButton @click="formModalRef?.edit(current)">
|
||||
编辑
|
||||
</NButton>
|
||||
<NButton
|
||||
:type="current.status === 1 ? 'warning' : 'primary'"
|
||||
@click="onToggleStatus(current, current.status === 1 ? 0 : 1)"
|
||||
>
|
||||
{{ current.status === 1 ? '停用' : '启用' }}
|
||||
</NButton>
|
||||
<NPopconfirm @positive-click="onDelete(current)">
|
||||
<template #trigger>
|
||||
<NButton type="error" ghost>
|
||||
删除
|
||||
</NButton>
|
||||
</template>
|
||||
删除这个角色?{{ (current.userCount ?? 0) > 0
|
||||
? `还有 ${current.userCount} 个账号在用它 —— 服务端会拒绝(40045),请先给他们换角色`
|
||||
: '删除后不可恢复' }}
|
||||
</NPopconfirm>
|
||||
</template>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<div class="role__meta">
|
||||
<div class="role__meta-row">
|
||||
<span class="role__meta-label">数据范围</span>
|
||||
<NTag size="small" :bordered="false">
|
||||
{{ DATA_SCOPE_NAMES[current.dataScope] ?? '—' }}
|
||||
</NTag>
|
||||
</div>
|
||||
<div class="role__hint">
|
||||
本期只展示、不可配:它由"拿到了哪些权限码 + 任务里被指派了哪些班"共同决定;
|
||||
做成可配要先把散在审核 / 催报 / 代传 / 看板的判据收口
|
||||
</div>
|
||||
<div v-if="current.remark" class="role__hint role__remark">
|
||||
备注:{{ current.remark }}
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<NCard class="role__card" :bordered="false" size="small">
|
||||
<template #header>
|
||||
授权
|
||||
</template>
|
||||
<RoleGrantEditor
|
||||
v-if="current.roleType !== 1 || permGroups.length"
|
||||
:role="current"
|
||||
:perm-groups="permGroups"
|
||||
:platform-tenant="platformTenant"
|
||||
@saved="loadRoles"
|
||||
/>
|
||||
<NAlert v-else type="warning" :show-icon="false">
|
||||
权限码目录没取到(需要「角色与授权」权限)。内置角色的权限由代码决定,
|
||||
这里看不到也不影响它的实际权限。
|
||||
</NAlert>
|
||||
</NCard>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<RoleFormModal ref="formModalRef" :roles="roles" @save-success="onCreated" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/**
|
||||
* 主从版面:左右各一张独立卡片,间隔 16px(`--xt-gap`)。
|
||||
*
|
||||
* ⚠️ 高度算式与全局 `.page-card` **同源**(`--xt-header-h` / `--xt-content-pad`):
|
||||
* 上一版写死 `calc(100vh - 56px - 28px)`,比内容区实际高度多 4px,
|
||||
* 整页会多出一条无意义的纵向滚动条。
|
||||
*/
|
||||
.md {
|
||||
display: flex;
|
||||
gap: var(--xt-gap);
|
||||
height: calc(100vh - var(--xt-header-h) - var(--xt-content-pad) * 2);
|
||||
}
|
||||
|
||||
/* 左栏:卡片自己就是 flex 列(Naive 的 .n-card 已是 column),宽度固定、不参与伸缩 */
|
||||
.md__side {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
width: 240px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片内容区变成"能撑满的 flex 列",列表才有确定高度、自己滚(高度链的第一环)。
|
||||
*
|
||||
* ⚠️ 类名是 `.n-card-content`(naive 的 `cB("card-content")`),**不是** `.n-card__content`:
|
||||
* 后者不匹配任何元素,写错了整条规则静默失效(列表会变成不滚动的长块)。
|
||||
*/
|
||||
.md__side :deep(.n-card-content) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.md__side-head {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.md__hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.md__list-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.md__empty {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.md__row {
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.md__row:hover {
|
||||
background: var(--xt-band-bg);
|
||||
}
|
||||
|
||||
.md__row--on {
|
||||
background: #eef4ff;
|
||||
border-color: #cfe0ff;
|
||||
}
|
||||
|
||||
/* 名字与状态标签同行;左栏收窄(≤1100px)时标签整块落到下一行,而不是把名字挤断 */
|
||||
.md__row-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.md__row-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 右栏:两张卡片自然堆叠、超高由自己滚 */
|
||||
.md__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--xt-gap);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 卡片不参与压缩:否则右栏内容一多,两张卡会被压扁而不是滚动 */
|
||||
.role__card {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 空态也占一张卡片:右侧留一块灰底会让人以为"页面没加载完" */
|
||||
.md__blank {
|
||||
flex: 1;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.md__blank :deep(.n-card-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.role__head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.role__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role__code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.role__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.role__meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.role__meta-label {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.role__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--xt-text-2);
|
||||
}
|
||||
|
||||
.role__remark {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ≤1100px:左栏收窄一档,别把右栏挤到撑破容器 */
|
||||
@media (max-width: 1100px) {
|
||||
.md__side {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,236 +1,112 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>租户</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<span class="tenant__hint">共 {{ total }} 个单位</span>
|
||||
<a-button type="primary" size="small" @click="onAdd">
|
||||
<template #icon><IconPlus /></template>
|
||||
开通租户
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-button v-for="c in statusChips" :key="String(c.value)"
|
||||
:type="statusFilter === c.value ? 'primary' : 'outline'" size="small" @click="setStatus(c.value)">
|
||||
{{ c.label }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-input-search v-model="query.keyword" placeholder="搜索名称或编码" allow-clear class="tenant__search"
|
||||
@search="search" @clear="search" />
|
||||
</div>
|
||||
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="单位名称" data-index="tenantName">
|
||||
<template #cell="{ record }">
|
||||
<div class="tenant__name">{{ record.tenantName }}</div>
|
||||
<div class="tenant__id">#{{ record.id }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="编码" data-index="tenantCode" :width="130" />
|
||||
<a-table-column title="类型" :width="110">
|
||||
<template #cell="{ record }">
|
||||
{{ TENANT_TYPE_NAMES[record.tenantType] ?? '—' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="联系人" data-index="contactName" :width="120" />
|
||||
<a-table-column title="到期" :width="120">
|
||||
<template #cell="{ record }">
|
||||
{{ (record.expireTime ?? '').slice(0, 10) || '—' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" :width="100">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="TENANT_STATUS[record.status]?.color ?? 'gray'">
|
||||
{{ TENANT_STATUS[record.status]?.text ?? '未知' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 开通租户:成功态与表单态共用一个弹窗,弱化"关掉就看不到密码了"的风险 -->
|
||||
<a-modal v-model:visible="visible" :title="created ? '租户已开通' : '开通租户'" :width="620"
|
||||
:mask-closable="false" :closable="!created" :footer="false">
|
||||
<!-- 成功态 -->
|
||||
<template v-if="created">
|
||||
<a-alert type="success" :show-icon="false">
|
||||
<template #title>
|
||||
<strong>「{{ createdName }}」已开通。</strong>
|
||||
系统已自动创建 6 个内置角色,以及首个管理员账号。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="tenant__pwd-box">
|
||||
<div class="tenant__pwd-label">管理员初始密码</div>
|
||||
<div class="tenant__pwd-row">
|
||||
<code class="tenant__pwd">{{ created.adminInitialPassword }}</code>
|
||||
<a-button type="primary" size="small" @click="copyPassword">
|
||||
<template #icon><IconCopy /></template>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<a-alert type="warning" :show-icon="false" class="tenant__pwd-warn">
|
||||
<strong>这个密码只显示这一次。</strong>
|
||||
关闭窗口后就查不到了 —— 请现在复制并发给管理员,由其首次登录后自行修改。
|
||||
</a-alert>
|
||||
</div>
|
||||
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="租户 ID">{{ created.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="小程序码参数">{{ created.miniProgramScene }}</a-descriptions-item>
|
||||
<a-descriptions-item label="管理员账号">{{ form.adminUsername }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="tenant__footer">
|
||||
<a-button type="primary" @click="closeCreated">我已记下密码</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 表单态 -->
|
||||
<template v-else>
|
||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="tenantName" label="单位名称">
|
||||
<a-input v-model="form.tenantName" placeholder="如 江西机电职业技术学院" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="tenantCode" label="租户编码">
|
||||
<a-input v-model="form.tenantCode" placeholder="如 jxjd" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="tenantType" label="类型">
|
||||
<a-select v-model="form.tenantType">
|
||||
<a-option v-for="(name, code) in TENANT_TYPE_NAMES" :key="code" :value="Number(code)">
|
||||
{{ name }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="expireTime" label="到期时间">
|
||||
<a-date-picker v-model="form.expireTime" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="contactName" label="联系人">
|
||||
<a-input v-model="form.contactName" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="contactPhone" label="联系电话">
|
||||
<a-input v-model="form.contactPhone" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider orientation="left">首个管理员</a-divider>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item field="adminUsername" label="登录账号">
|
||||
<a-input v-model="form.adminUsername" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="adminRealName" label="姓名">
|
||||
<a-input v-model="form.adminRealName" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="adminPhone" label="手机号">
|
||||
<a-input v-model="form.adminPhone" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-alert type="info" :show-icon="false">
|
||||
<template #title>
|
||||
提交后会一次性完成:<strong>建租户</strong> → <strong>发放 6 个内置角色</strong> →
|
||||
<strong>创建首个管理员</strong>。初始密码在下一步显示,<strong>只显示一次</strong>。
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-form>
|
||||
|
||||
<div class="tenant__footer">
|
||||
<a-space>
|
||||
<a-button @click="visible = false">取消</a-button>
|
||||
<a-button type="primary" :loading="submitting" @click="onSubmit">确认开通</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
|
||||
import type { TenantCreateResult, TenantVO } from '@/apis/xt/tenant'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconCopy, IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NDatePicker,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import * as tenantApi from '@/apis/xt/tenant'
|
||||
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { fmtDate } from '@/utils/format'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
|
||||
/**
|
||||
* 租户管理(仅平台运营)。
|
||||
*
|
||||
* 两个容易做错的地方:
|
||||
* 1. **初始密码只返回一次** —— 成功后不能弹个 toast 就完事,
|
||||
* 必须停下来让用户当场记下/复制,并明确告知"关掉就再也看不到"。
|
||||
* 2. **租户列表是跨租户的**(sys_tenant 没有 tenant_id),
|
||||
* 所以权限把关全在服务端;学校管理员调它会拿到 40301,前端也据此隐藏入口。
|
||||
* 1. **初始密码只返回一次** —— 成功后不能弹个 toast 就完事,必须停下来让用户当场
|
||||
* 复制下来,并明确告知"关掉就再也看不到"(库里存的是 BCrypt 哈希,谁也还原不出来)。
|
||||
* 2. **租户列表是跨租户的**(`sys_tenant` 没有 `tenant_id`),权限把关全在服务端;
|
||||
* 学校管理员调它会拿到 40301,左侧菜单也不会给他这一项。
|
||||
*
|
||||
* ⚠️ 列表**没有操作列**:不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)。
|
||||
*/
|
||||
defineOptions({ name: 'Tenant' })
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const list = ref<TenantVO[]>([])
|
||||
const total = ref(0)
|
||||
const statusFilter = ref<number | undefined>(undefined)
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, keyword: '' })
|
||||
const keyword = ref('')
|
||||
|
||||
const query = reactive({ pageNum: 1, pageSize: 10 })
|
||||
|
||||
const visible = ref(false)
|
||||
const created = ref<TenantCreateResult | null>(null)
|
||||
const createdName = ref('')
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
|
||||
const getInitForm = () => ({
|
||||
tenantName: '',
|
||||
tenantCode: '',
|
||||
tenantType: 1,
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
expireTime: '',
|
||||
adminUsername: 'admin',
|
||||
adminRealName: '',
|
||||
adminPhone: ''
|
||||
})
|
||||
|
||||
const form = reactive(getInitForm())
|
||||
|
||||
const rules: FormInstance['rules'] = {
|
||||
tenantName: [{ required: true, message: '请填写单位名称' }],
|
||||
tenantCode: [
|
||||
{ required: true, message: '请填写租户编码' },
|
||||
{ match: /^[a-z][a-z0-9_-]{1,31}$/, message: '小写字母开头,后接小写字母、数字、下划线或短横线' }
|
||||
],
|
||||
tenantType: [{ required: true, message: '请选择类型' }],
|
||||
expireTime: [{ required: true, message: '请选择到期时间' }],
|
||||
contactName: [{ required: true, message: '请填写联系人' }],
|
||||
contactPhone: [{ required: true, message: '请填写联系电话' }],
|
||||
adminUsername: [{ required: true, message: '请填写管理员账号' }],
|
||||
adminRealName: [{ required: true, message: '请填写管理员姓名' }],
|
||||
adminPhone: [{ required: true, message: '请填写管理员手机号' }]
|
||||
function emptyForm() {
|
||||
return {
|
||||
tenantName: '',
|
||||
tenantCode: '',
|
||||
tenantType: 1,
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
// 用 null 而不是 '':n-date-picker 清空时会回传 null,
|
||||
// 声明成 string 会在 v-model 上产生类型打架(运行期没事,但 vue-tsc 会红)
|
||||
expireTime: null as string | null,
|
||||
adminUsername: 'admin',
|
||||
adminRealName: '',
|
||||
adminPhone: ''
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
/**
|
||||
* ⚠️ 数字字段的规则必须显式写 `type: 'number'`。
|
||||
*
|
||||
* 只有 `required` 时,async-validator 会把 `rule.type` 默认成 `'string'`
|
||||
* (`Schema.validate` 里 `rule.type = this.getType(rule)`),接着按
|
||||
* `typeof value !== rule.type` 判错;而 `tenantType` 是数字(1/2/3)——
|
||||
* 结果是「确认开通」永远提交不出去,且错误信息会被 `#feedback` 插槽挡掉,
|
||||
* 界面上只剩一个红框、没有任何说明。
|
||||
*/
|
||||
const rules: FormRules = {
|
||||
tenantName: [{ required: true, message: '请填写单位名称', trigger: ['blur', 'input'] }],
|
||||
tenantCode: [
|
||||
{ required: true, message: '请填写租户编码', trigger: ['blur', 'input'] },
|
||||
{
|
||||
pattern: /^[a-z][a-z0-9_-]{1,31}$/,
|
||||
message: '小写字母开头,后接小写字母、数字、下划线或短横线',
|
||||
trigger: ['blur', 'input']
|
||||
}
|
||||
],
|
||||
tenantType: [{ type: 'number', required: true, message: '请选择类型', trigger: ['change'] }],
|
||||
expireTime: [{ required: true, message: '请选择到期时间', trigger: ['change'] }],
|
||||
contactName: [{ required: true, message: '请填写联系人', trigger: ['blur', 'input'] }],
|
||||
contactPhone: [{ required: true, message: '请填写联系电话', trigger: ['blur', 'input'] }],
|
||||
adminUsername: [{ required: true, message: '请填写管理员账号', trigger: ['blur', 'input'] }],
|
||||
adminRealName: [{ required: true, message: '请填写管理员姓名', trigger: ['blur', 'input'] }],
|
||||
adminPhone: [{ required: true, message: '请填写管理员手机号', trigger: ['blur', 'input'] }]
|
||||
}
|
||||
|
||||
const typeOptions = Object.entries(TENANT_TYPE_NAMES).map(([code, name]) => ({
|
||||
label: name,
|
||||
value: Number(code)
|
||||
}))
|
||||
|
||||
/** 状态筛选用「按钮组」而不是下拉:平台运营看的就是这几个状态,一眼能选完 */
|
||||
const statusChips = computed(() => [
|
||||
{ label: `全部 ${total.value}`, value: undefined },
|
||||
{ label: '正常', value: 1 },
|
||||
@@ -238,85 +114,139 @@ const statusChips = computed(() => [
|
||||
{ label: '已停用', value: 0 }
|
||||
])
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
const columns: DataTableColumns<TenantVO> = [
|
||||
{
|
||||
title: '单位名称',
|
||||
key: 'tenantName',
|
||||
render: (row) => h('div', null, [
|
||||
h('div', null, row.tenantName),
|
||||
h('div', { class: 'tenant__id' }, `#${row.id}`)
|
||||
])
|
||||
},
|
||||
{ title: '编码', key: 'tenantCode', width: 140 },
|
||||
{
|
||||
title: '类型',
|
||||
key: 'tenantType',
|
||||
width: 110,
|
||||
render: (row) => TENANT_TYPE_NAMES[row.tenantType] ?? '—'
|
||||
},
|
||||
{ title: '联系人', key: 'contactName', width: 120, render: (row) => row.contactName || '—' },
|
||||
{
|
||||
title: '到期',
|
||||
key: 'expireTime',
|
||||
width: 120,
|
||||
render: (row) => fmtDate(row.expireTime)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const s = tagOf(TENANT_STATUS, row.status)
|
||||
return h(NTag, { size: 'small', bordered: false, type: s.type }, { default: () => s.text })
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 分页器统一走 `listPagination`(见 `@/composables/pagination`)——
|
||||
* 手写 `prefix` 的类型最容易写错(naive 给的 `itemCount` 是 `number | undefined`)。
|
||||
* 事件在模板上处理:`@update:page` 与 `@update:page-size` 两个都要管。
|
||||
*/
|
||||
const pagination = computed(() => listPagination({
|
||||
page: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true
|
||||
itemCount: total.value,
|
||||
pageSizes: [10, 20, 50]
|
||||
}))
|
||||
|
||||
async function load() {
|
||||
function onPage(page: number): void {
|
||||
query.pageNum = page
|
||||
void load()
|
||||
}
|
||||
|
||||
function onPageSize(size: number): void {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await tenantApi.getTenantList({
|
||||
const page = await tenantApi.getTenantList({
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keyword: query.keyword.trim() || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: statusFilter.value
|
||||
})
|
||||
list.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
list.value = page.list ?? []
|
||||
total.value = page.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
function search(): void {
|
||||
query.pageNum = 1
|
||||
load()
|
||||
void load()
|
||||
}
|
||||
|
||||
function setStatus(v: number | undefined) {
|
||||
function setStatus(v: number | undefined): void {
|
||||
statusFilter.value = v
|
||||
search()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
load()
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
Object.assign(form, getInitForm())
|
||||
function onAdd(): void {
|
||||
Object.assign(form, emptyForm())
|
||||
created.value = null
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return
|
||||
async function onSubmit(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await tenantApi.createTenant({
|
||||
...form,
|
||||
tenantName: form.tenantName.trim(),
|
||||
tenantCode: form.tenantCode.trim().toLowerCase(),
|
||||
tenantType: form.tenantType,
|
||||
contactName: form.contactName.trim(),
|
||||
contactPhone: form.contactPhone.trim(),
|
||||
expireTime: form.expireTime ?? '',
|
||||
adminUsername: form.adminUsername.trim(),
|
||||
adminRealName: form.adminRealName.trim(),
|
||||
adminPhone: form.adminPhone.trim()
|
||||
})
|
||||
createdName.value = form.tenantName.trim()
|
||||
created.value = res.data
|
||||
created.value = res
|
||||
await load()
|
||||
} catch {
|
||||
// 提示已由 http 层给出(如租户编码重复)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPassword() {
|
||||
if (!created.value) return
|
||||
async function copyPassword(): Promise<void> {
|
||||
const pwd = created.value?.adminInitialPassword
|
||||
if (!pwd) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(created.value.adminInitialPassword)
|
||||
Message.success('已复制到剪贴板')
|
||||
await navigator.clipboard.writeText(pwd)
|
||||
message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
Message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||||
message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||||
}
|
||||
}
|
||||
|
||||
function closeCreated() {
|
||||
function closeCreated(): void {
|
||||
created.value = null
|
||||
visible.value = false
|
||||
}
|
||||
@@ -324,71 +254,281 @@ function closeCreated() {
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tenant {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<template>
|
||||
<NCard class="page-card tenant" :bordered="false" size="small">
|
||||
<template #header>
|
||||
租户
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="tenant__hint">共 {{ total }} 个单位</span>
|
||||
</template>
|
||||
|
||||
&__search {
|
||||
width: 220px;
|
||||
margin-left: auto;
|
||||
}
|
||||
<!--
|
||||
工具条:主操作在左、筛选在右(与收集任务页同一口径)。
|
||||
同一行里所有控件统一**默认尺寸** —— 全是 28px 的小控件挤在 52px 的一行里,
|
||||
白底小按钮贴在白底卡片上几乎看不出是按钮。
|
||||
-->
|
||||
<div class="toolbar">
|
||||
<NSpace :size="8">
|
||||
<NButton type="primary" @click="onAdd">
|
||||
<template #icon>
|
||||
<AppIcon name="plus" :size="16" />
|
||||
</template>
|
||||
开通租户
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
<NSpace :size="8" align="center">
|
||||
<NButton
|
||||
v-for="c in statusChips"
|
||||
:key="String(c.value)"
|
||||
:type="statusFilter === c.value ? 'primary' : 'default'"
|
||||
@click="setStatus(c.value)"
|
||||
>
|
||||
{{ c.label }}
|
||||
</NButton>
|
||||
<NInput
|
||||
v-model:value="keyword"
|
||||
clearable
|
||||
placeholder="搜索名称或编码"
|
||||
class="tenant__search"
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
&__id {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<div class="page-card__body">
|
||||
<!--
|
||||
⚠️ Naive 的表格**没有** `scroll` prop(那是 Arco 的 API):
|
||||
横向用 `:scroll-x`(数值),纵向靠 `flex-height` + 高度链。
|
||||
-->
|
||||
<NDataTable
|
||||
flex-height
|
||||
style="height: 100%"
|
||||
remote
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="row => row.id"
|
||||
:scroll-x="800"
|
||||
:pagination="pagination"
|
||||
size="small"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
&__pwd-box {
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: var(--border-radius-large);
|
||||
}
|
||||
<!-- 开通租户:成功态与表单态共用一个弹窗,弱化"关掉就看不到密码了"的风险 -->
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
:title="created ? '租户已开通' : '开通租户'"
|
||||
:style="{ width: created ? 'min(560px, calc(100vw - 32px))' : 'min(620px, calc(100vw - 32px))' }"
|
||||
:mask-closable="false"
|
||||
:closable="!created"
|
||||
>
|
||||
<!-- 成功态:初始密码只显示这一次 -->
|
||||
<template v-if="created">
|
||||
<NAlert type="success" :show-icon="false">
|
||||
<strong>「{{ createdName }}」已开通。</strong>
|
||||
系统已自动创建内置角色,以及首个管理员账号。
|
||||
</NAlert>
|
||||
|
||||
&__pwd-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<div class="tenant__pwd-box">
|
||||
<div class="tenant__pwd-label">
|
||||
管理员初始密码
|
||||
</div>
|
||||
<div class="tenant__pwd-row">
|
||||
<code class="tenant__pwd">{{ created.adminInitialPassword }}</code>
|
||||
<NButton type="primary" size="small" @click="copyPassword">
|
||||
复制
|
||||
</NButton>
|
||||
</div>
|
||||
<NAlert type="warning" :show-icon="false" class="tenant__pwd-warn">
|
||||
<strong>这个密码只显示这一次。</strong>
|
||||
关闭窗口后就查不到了 —— 请现在复制并发给管理员,由其首次登录后自行修改。
|
||||
</NAlert>
|
||||
</div>
|
||||
|
||||
&__pwd-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
<NDescriptions :column="1" size="small" label-placement="left">
|
||||
<NDescriptionsItem label="租户 ID">{{ created.id }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="小程序码参数">{{ created.miniProgramScene }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="管理员账号">{{ form.adminUsername }}</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
&__pwd {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
user-select: all;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border-3);
|
||||
border-radius: var(--border-radius-medium);
|
||||
}
|
||||
<div class="tenant__footer">
|
||||
<NButton type="primary" @click="closeCreated">
|
||||
我已记下密码
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
&__pwd-warn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
<!-- 表单态 -->
|
||||
<template v-else>
|
||||
<NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<div class="tenant__grid">
|
||||
<NFormItem label="单位名称" path="tenantName">
|
||||
<NInput v-model:value="form.tenantName" placeholder="如 江西机电职业技术学院" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem label="租户编码" path="tenantCode">
|
||||
<NInput v-model:value="form.tenantCode" placeholder="如 jxjd(小写字母开头)" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem label="类型" path="tenantType">
|
||||
<NSelect v-model:value="form.tenantType" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="到期时间" path="expireTime">
|
||||
<NDatePicker
|
||||
v-model:formatted-value="form.expireTime"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
class="tenant__full"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="联系人" path="contactName">
|
||||
<NInput v-model:value="form.contactName" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem label="联系电话" path="contactPhone">
|
||||
<NInput v-model:value="form.contactPhone" clearable />
|
||||
</NFormItem>
|
||||
</div>
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
}
|
||||
<div class="tenant__sub">
|
||||
<div class="section-title">
|
||||
首个管理员
|
||||
</div>
|
||||
<div class="tenant__grid tenant__grid--3">
|
||||
<NFormItem label="登录账号" path="adminUsername">
|
||||
<NInput v-model:value="form.adminUsername" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem label="姓名" path="adminRealName">
|
||||
<NInput v-model:value="form.adminRealName" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem label="手机号" path="adminPhone">
|
||||
<NInput v-model:value="form.adminPhone" clearable />
|
||||
</NFormItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NAlert type="info" :show-icon="false">
|
||||
提交后会一次性完成:<strong>建租户</strong> → <strong>发放内置角色</strong> →
|
||||
<strong>创建首个管理员</strong>。初始密码在下一步显示,<strong>只显示一次</strong>。
|
||||
</NAlert>
|
||||
</NForm>
|
||||
|
||||
<div class="tenant__footer">
|
||||
<NSpace justify="end">
|
||||
<NButton @click="visible = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="submitting" @click="onSubmit">
|
||||
确认开通
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/**
|
||||
* 页面就是一张卡片(`.page-card` 提供撑满内容区的高度链)。
|
||||
*
|
||||
* ⚠️ Naive 的 `.n-card` 本身已是 `display:flex; flex-direction:column`,
|
||||
* 所以只要把**内容区**也变成"能撑满的 flex 列",下面的表格才拿得到确定高度,
|
||||
* `flex-height` 才有意义 —— 少了这一条,分页器会被顶出卡片底部
|
||||
* ("DOM 里有、屏幕上看不到")。
|
||||
*/
|
||||
.tenant :deep(.n-card-content) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tenant__hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tenant__search {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.tenant__id,
|
||||
:deep(.tenant__id) {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tenant__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.tenant__grid--3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tenant__full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「首个管理员」子块。
|
||||
*
|
||||
* ⚠️ 这里**刻意不用 `NCard`**:弹窗本身已经是卡片,卡里再套一张卡会出现
|
||||
* 两层"浮起 + 浅灰头",比不分组还乱。改用浅灰底 + 弱描边的小格子
|
||||
* (与工作台的四格看板同一套写法)。
|
||||
*/
|
||||
.tenant__sub {
|
||||
padding: 14px 16px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tenant__pwd-box {
|
||||
padding: 12px 14px;
|
||||
margin: 14px 0;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tenant__pwd-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tenant__pwd-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tenant__pwd {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
font-size: 15px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.06em;
|
||||
background: #fff;
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tenant__pwd-warn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.tenant__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,159 +1,46 @@
|
||||
<template>
|
||||
<a-modal v-model:visible="visible" :title="created ? '账号已创建' : title" :width="560"
|
||||
:mask-closable="false" :closable="!created" :footer="false">
|
||||
<!-- ── 成功态:初始密码只显示这一次 ──────────────────── -->
|
||||
<template v-if="created">
|
||||
<a-alert type="success" :show-icon="false">
|
||||
<template #title>
|
||||
<strong>「{{ created.realName }}」的账号已创建。</strong>
|
||||
请把下面的初始密码发给本人。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="user-form__pwd-box">
|
||||
<div class="user-form__pwd-label">初始密码</div>
|
||||
<div class="user-form__pwd-row">
|
||||
<code class="user-form__pwd">{{ created.initialPassword }}</code>
|
||||
<a-button type="primary" size="small" @click="copyPassword">
|
||||
<template #icon><IconCopy /></template>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<a-alert type="warning" :show-icon="false" class="user-form__pwd-warn">
|
||||
<strong>这个密码只显示这一次。</strong>
|
||||
关闭窗口后就查不到了 —— 库里存的是不可逆的哈希,忘了只能重新重置。
|
||||
</a-alert>
|
||||
</div>
|
||||
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="登录账号">{{ created.username }}</a-descriptions-item>
|
||||
<a-descriptions-item label="姓名">{{ created.realName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="角色">{{ created.roleNames.join('、') || '—' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="user-form__footer">
|
||||
<a-button type="primary" @click="closeCreated">我已记下密码</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── 表单态 ──────────────────────────────────────── -->
|
||||
<template v-else>
|
||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="username" label="登录账号">
|
||||
<a-input v-model="form.username" :disabled="isEdit" placeholder="如 zhangsan" allow-clear />
|
||||
<template #extra>
|
||||
<span v-if="isEdit" class="user-form__hint">
|
||||
登录名不可修改:改了会让历史通知、导出包里的账号全对不上。
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="realName" label="姓名">
|
||||
<a-input v-model="form.realName" placeholder="如 张三" :max-length="64" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12">
|
||||
<a-form-item field="phone" label="手机号">
|
||||
<a-input v-model="form.phone" placeholder="11 位手机号" :max-length="11" allow-clear />
|
||||
<template #extra>
|
||||
<span class="user-form__hint">加密存储,列表只显示脱敏值。</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="orgId" label="所属组织">
|
||||
<a-tree-select v-model="form.orgId" :data="orgTree" :field-names="ORG_FIELD_NAMES"
|
||||
placeholder="不选表示不归属任何组织" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-form-item label="用户类型">
|
||||
<a-space>
|
||||
<a-tag v-if="derivedUserType" color="arcoblue">
|
||||
{{ USER_TYPE_NAMES[derivedUserType] ?? derivedUserType }}
|
||||
</a-tag>
|
||||
<span v-else-if="conflictTypes.length > 1" class="user-form__hint">
|
||||
所选角色分属不同类别({{ conflictTypes.join('、') }}),请只选同一类的角色
|
||||
</span>
|
||||
<span v-else class="user-form__hint">选择角色后自动确定</span>
|
||||
</a-space>
|
||||
<template #extra>
|
||||
<span class="user-form__hint">
|
||||
由所选角色自动决定,不需要手选 —— 这样不会出现「学生拿着学校管理员权限」这类账号。
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-form-item field="roleCodes" label="角色">
|
||||
<a-checkbox-group v-model="form.roleCodes">
|
||||
<a-checkbox v-for="r in roleOptions" :key="r.value" :value="r.value">
|
||||
{{ r.label }}
|
||||
</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
<template #extra>
|
||||
<span class="user-form__hint">
|
||||
可多选,权限取并集。内置角色的权限由系统决定,不能在这里改。
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col v-if="isEdit" :span="24">
|
||||
<a-form-item field="status" label="状态">
|
||||
<a-switch v-model="form.status" :checked-value="1" :unchecked-value="0" />
|
||||
<span class="user-form__hint user-form__hint--inline">
|
||||
{{ form.status === 1 ? '正常,可登录' : '停用,不能登录(数据保留)' }}
|
||||
</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<div class="user-form__footer">
|
||||
<a-space>
|
||||
<a-button @click="close">取消</a-button>
|
||||
<a-button type="primary" :loading="submitting" @click="confirm">
|
||||
{{ isEdit ? '保存' : '创建账号' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import type { FormInst, FormRules, TreeSelectOption } from 'naive-ui'
|
||||
import type { OrgTreeVO } from '@/apis/xt/org'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import type { RoleOption } from '@/apis/xt/role'
|
||||
import type { UserVO } from '@/apis/xt/user'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCheckbox,
|
||||
NCheckboxGroup,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
NTag,
|
||||
NTreeSelect,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { getOrgTree } from '@/apis/xt/org'
|
||||
import { deriveUserType, getRoleOptions, ROLE_USER_TYPE_NAMES } from '@/apis/xt/role'
|
||||
import * as userApi from '@/apis/xt/user'
|
||||
import { deriveUserType, PLATFORM_ROLE_OPTIONS, ROLE_TO_USER_TYPE, SCHOOL_ROLE_OPTIONS, USER_TYPE_NAMES } from '@/apis/xt/user'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
/**
|
||||
* 账号新增 / 编辑弹窗。
|
||||
*
|
||||
* 三处值得留意的设计:
|
||||
* 1. **初始密码只显示一次** —— 创建成功后不弹 toast 就完事,而是把弹窗切成成功态,
|
||||
* 四处值得留意的设计:
|
||||
* 1. **初始密码只显示一次** —— 创建成功后不弹个 toast 就完事,而是把弹窗切成成功态,
|
||||
* 停下来让用户当场抄下来。库里是 BCrypt 哈希,关了窗口谁也还原不出来。
|
||||
* 2. **角色选项按租户过滤** —— 平台运营角色只对平台租户显示。学校管理员
|
||||
* 拿到它会直接越权到平台级(能开通租户、看全平台租户列表),
|
||||
* 所以前端不显示、后端也会用 40301 拒绝。
|
||||
* 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定,
|
||||
* 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,
|
||||
* 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。
|
||||
* 2. **角色选项按租户过滤**(`getRoleOptions` 只给"启用且可发放"的)——
|
||||
* 平台运营角色在学校租户里发不出去(服务端 40301),所以前端不显示它,
|
||||
* 而不是让人选完再收到一个错误。
|
||||
* 3. **用户类型不让用户选,从角色推导** —— 二者一对一绑定,让用户分别选就会产生
|
||||
* "学生 + 学校管理员"这种自相矛盾的账号,而服务端还会因此拒掉整个提交。
|
||||
* 4. **编辑时手机号必须重填** —— 列表接口只返回脱敏值(`138****8888`),
|
||||
* 拿它回填会把脱敏串写进库,比"让用户重填一次"糟糕得多。
|
||||
*
|
||||
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"这个账号建到哪所学校下"
|
||||
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定角色的可选范围:
|
||||
* 给某所学校建账号时只能发**学校那套角色**,发 PLATFORM_ADMIN 会被服务端拒(40301)。
|
||||
* `tenantId` 由父页面传入,**只有平台运营会传值**:它既是"账号建到哪所学校",
|
||||
* 也决定角色候选的范围(给某校建账号时只能发学校那套角色)。
|
||||
*/
|
||||
defineOptions({ name: 'UserFormModal' })
|
||||
|
||||
@@ -165,172 +52,175 @@ const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
const orgTree = ref<OrgTreeVO[]>([])
|
||||
const roleOptions = ref<RoleOption[]>([])
|
||||
const created = ref<{ username: string, realName: string, initialPassword: string, roleNames: string[] } | null>(null)
|
||||
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
|
||||
const isEdit = computed(() => !!detailId.value)
|
||||
const title = computed(() => (isEdit.value ? '修改账号' : '新增账号'))
|
||||
|
||||
/** 平台租户下才提供平台运营角色 */
|
||||
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
||||
function emptyForm() {
|
||||
return {
|
||||
username: '',
|
||||
realName: '',
|
||||
phone: '',
|
||||
orgId: null as number | null,
|
||||
roleCodes: [] as string[],
|
||||
status: 1
|
||||
}
|
||||
}
|
||||
|
||||
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const getInitForm = () => ({
|
||||
username: '',
|
||||
realName: '',
|
||||
phone: '',
|
||||
orgId: undefined as number | undefined,
|
||||
roleCodes: [] as string[],
|
||||
status: 1
|
||||
})
|
||||
/** 所选角色推导出的用户类型;跨类(如班主任 + 学校管理员)时为 null,页面据此拦住提交 */
|
||||
const derivedUserType = computed(() => deriveUserType(form.roleCodes, roleOptions.value))
|
||||
|
||||
// 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前
|
||||
const form = reactive(getInitForm())
|
||||
|
||||
/**
|
||||
* 可选角色。
|
||||
*
|
||||
* ⚠️ 判据不只是"我是不是平台租户",还要看**有没有代管学校**:
|
||||
* · 平台运营 + 没选学校 → 建的是平台自己的账号 → 只能发 PLATFORM_ADMIN;
|
||||
* · 平台运营 + 选了学校 → 建的是**那所学校**的账号 → 只能发学校那套角色
|
||||
* (发 PLATFORM_ADMIN 会被服务端按租户白名单拒掉,报 40301);
|
||||
* · 学校管理员 → 学校那套。
|
||||
*/
|
||||
const roleOptions = computed(() =>
|
||||
isPlatformTenant.value && !props.tenantId ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS
|
||||
)
|
||||
|
||||
/**
|
||||
* 从所选角色推导用户类型。
|
||||
*
|
||||
* 用户类型不让用户手选:它与角色是**一对一绑定**的(见 ROLE_TO_USER_TYPE),
|
||||
* 让用户分别选就会出现"学生 + 学校管理员"这种自相矛盾的账号,
|
||||
* 而服务端还会因此拒掉整个提交。改成推导之后这类组合在界面上根本产生不了。
|
||||
*/
|
||||
const derivedUserType = computed(() => deriveUserType(form.roleCodes))
|
||||
|
||||
/** 所选角色跨越了多个用户类型时的类别名(用于提示为什么不能提交) */
|
||||
/** 所选角色跨越了多个用户类型时的类别名,用来解释"为什么不能提交" */
|
||||
const conflictTypes = computed(() => {
|
||||
const names = new Set<string>()
|
||||
for (const c of form.roleCodes) {
|
||||
const t = ROLE_TO_USER_TYPE[c]
|
||||
if (t) names.add(USER_TYPE_NAMES[t] ?? String(t))
|
||||
for (const code of form.roleCodes) {
|
||||
const t = roleOptions.value.find((o) => o.value === code)?.userType
|
||||
if (t) {
|
||||
names.add(ROLE_USER_TYPE_NAMES[t] ?? String(t))
|
||||
}
|
||||
}
|
||||
return [...names]
|
||||
})
|
||||
|
||||
const rules: FormInstance['rules'] = {
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请填写登录账号' },
|
||||
{ required: true, message: '请填写登录账号', trigger: ['blur', 'input'] },
|
||||
{
|
||||
match: /^[A-Z][\w.-]{1,63}$/i,
|
||||
message: '只能以字母开头,可含字母、数字、下划线、点或短横线'
|
||||
pattern: /^[A-Z]\w{1,63}$/i,
|
||||
message: '只能以字母开头,可含字母、数字、下划线',
|
||||
trigger: ['blur', 'input']
|
||||
}
|
||||
],
|
||||
realName: [{ required: true, message: '请填写姓名' }],
|
||||
realName: [{ required: true, message: '请填写姓名', trigger: ['blur', 'input'] }],
|
||||
phone: [
|
||||
{ required: true, message: '请填写手机号' },
|
||||
{ match: /^1[3-9]\d{9}$/, message: '手机号格式不正确' }
|
||||
{ required: true, message: '请填写手机号', trigger: ['blur', 'input'] },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: ['blur', 'input'] }
|
||||
],
|
||||
roleCodes: [{ required: true, message: '请至少选择一个角色' }]
|
||||
roleCodes: [{
|
||||
required: true,
|
||||
type: 'array',
|
||||
message: '请至少选择一个角色',
|
||||
trigger: ['change', 'blur']
|
||||
}]
|
||||
}
|
||||
|
||||
async function loadOrgTree() {
|
||||
if (orgTree.value.length) return
|
||||
const res = await getOrgTree({ tenantId: props.tenantId })
|
||||
orgTree.value = res.data ?? []
|
||||
const orgOptions = computed<TreeSelectOption[]>(() => toTreeSelect(orgTree.value))
|
||||
|
||||
function toTreeSelect(nodes: OrgTreeVO[]): TreeSelectOption[] {
|
||||
return (nodes ?? []).map((n) => ({
|
||||
key: n.id,
|
||||
label: n.orgName,
|
||||
children: n.children?.length ? toTreeSelect(n.children) : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
// 换了学校就把缓存的组织树丢掉:留着上一所学校的树,用户会在"组织"下拉里
|
||||
// 看到别人学校的班级,而选中它提交会得到一个指向不存在组织的 orgId
|
||||
watch(() => props.tenantId, () => {
|
||||
orgTree.value = []
|
||||
})
|
||||
|
||||
/** 新增 */
|
||||
async function add() {
|
||||
detailId.value = null
|
||||
created.value = null
|
||||
Object.assign(form, getInitForm())
|
||||
visible.value = true
|
||||
await loadOrgTree()
|
||||
}
|
||||
|
||||
/** 编辑 */
|
||||
async function edit(row: userApi.UserVO) {
|
||||
detailId.value = row.id
|
||||
created.value = null
|
||||
Object.assign(form, getInitForm())
|
||||
form.username = row.username ?? ''
|
||||
form.realName = row.realName
|
||||
// 手机号只回脱敏值,编辑时留空表示"不改手机号"(后端会把空的手机号当作不修改)
|
||||
form.phone = ''
|
||||
form.orgId = row.orgId ?? undefined
|
||||
// 用户类型不在这里赋值:它由 roleCodes 推导,见 derivedUserType
|
||||
form.roleCodes = [...row.roleCodes]
|
||||
form.status = row.status ?? 1
|
||||
visible.value = true
|
||||
await loadOrgTree()
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function closeCreated() {
|
||||
created.value = null
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function copyPassword() {
|
||||
const pwd = created.value?.initialPassword
|
||||
if (!pwd) return
|
||||
async function loadOrgTree(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(pwd)
|
||||
Message.success('已复制到剪贴板')
|
||||
orgTree.value = await getOrgTree({ tenantId: props.tenantId }) ?? []
|
||||
} catch {
|
||||
Message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||||
orgTree.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return
|
||||
async function loadRoles(): Promise<void> {
|
||||
try {
|
||||
roleOptions.value = await getRoleOptions(props.tenantId)
|
||||
} catch {
|
||||
roleOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 换了学校就把缓存的组织树与角色候选丢掉:留着上一所学校的树,
|
||||
// 用户会在「组织」下拉里看到别人学校的班级,而选中它提交会得到指向不存在组织的 orgId
|
||||
watch(() => props.tenantId, () => {
|
||||
orgTree.value = []
|
||||
roleOptions.value = []
|
||||
})
|
||||
|
||||
async function add(): Promise<void> {
|
||||
detailId.value = null
|
||||
created.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
visible.value = true
|
||||
await Promise.all([loadOrgTree(), loadRoles()])
|
||||
}
|
||||
|
||||
async function edit(row: UserVO): Promise<void> {
|
||||
detailId.value = row.id
|
||||
created.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
form.username = row.username ?? ''
|
||||
form.realName = row.realName
|
||||
// 手机号留空,强制重填(见文件头第 4 条)
|
||||
form.phone = ''
|
||||
form.orgId = row.orgId ?? null
|
||||
form.roleCodes = [...(row.roleCodes ?? [])]
|
||||
form.status = row.status ?? 1
|
||||
visible.value = true
|
||||
await Promise.all([loadOrgTree(), loadRoles()])
|
||||
}
|
||||
|
||||
async function copyPassword(): Promise<void> {
|
||||
const pwd = created.value?.initialPassword
|
||||
if (!pwd) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(pwd)
|
||||
message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||||
}
|
||||
}
|
||||
|
||||
function closeCreated(): void {
|
||||
created.value = null
|
||||
visible.value = false
|
||||
emit('save-success')
|
||||
}
|
||||
|
||||
async function confirm(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const phone = form.phone.trim()
|
||||
if (!phone) {
|
||||
// 编辑态也要求重填手机号:列表接口按 §1.7 只返回脱敏值(138****8888),
|
||||
// 拿它当明文回填会把脱敏串写进库里,比"让用户重填一次"糟糕得多。
|
||||
Message.warning('请填写手机号(列表只返回脱敏值,无法回填原号码,需重填)')
|
||||
message.warning('请填写手机号(列表只返回脱敏值,无法回填原号码,需重填)')
|
||||
return
|
||||
}
|
||||
|
||||
const userType = derivedUserType.value
|
||||
if (userType === null) {
|
||||
Message.warning('请选择角色(且需属于同一类),用户类型由它自动确定')
|
||||
message.warning('请选择角色,且所选角色需属于同一类(用户类型由它自动确定)')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await userApi.updateUser(detailId.value!, {
|
||||
if (isEdit.value && detailId.value) {
|
||||
await userApi.updateUser(detailId.value, {
|
||||
realName: form.realName.trim(),
|
||||
phone,
|
||||
orgId: form.orgId ?? null,
|
||||
orgId: form.orgId,
|
||||
roleCodes: form.roleCodes,
|
||||
userType,
|
||||
status: form.status
|
||||
}, props.tenantId)
|
||||
Message.success('已保存')
|
||||
message.success('已保存')
|
||||
visible.value = false
|
||||
emit('save-success')
|
||||
return
|
||||
@@ -340,23 +230,19 @@ async function confirm() {
|
||||
username: form.username.trim(),
|
||||
realName: form.realName.trim(),
|
||||
phone,
|
||||
orgId: form.orgId ?? null,
|
||||
orgId: form.orgId,
|
||||
roleCodes: form.roleCodes,
|
||||
userType
|
||||
}, props.tenantId)
|
||||
const data = res.data
|
||||
if (!data) {
|
||||
Message.error('创建失败,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
created.value = {
|
||||
username: form.username.trim(),
|
||||
realName: form.realName.trim(),
|
||||
initialPassword: data.initialPassword,
|
||||
roleNames: form.roleCodes.map(
|
||||
(c) => roleOptions.value.find((r) => r.value === c)?.label ?? c
|
||||
)
|
||||
initialPassword: res.initialPassword,
|
||||
roleNames: form.roleCodes.map((c) => roleOptions.value.find((r) => r.value === c)?.label ?? c)
|
||||
}
|
||||
} catch {
|
||||
// 提示已由 http 层给出(如"用户类型与所选角色不符")
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -365,52 +251,200 @@ async function confirm() {
|
||||
defineExpose({ add, edit })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-form {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text-3);
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
:title="created ? '账号已创建' : title"
|
||||
:style="{ width: created ? '480px' : '600px' }"
|
||||
:mask-closable="false"
|
||||
:closable="!created"
|
||||
>
|
||||
<!-- ── 成功态:初始密码只显示这一次 ──────────────────── -->
|
||||
<template v-if="created">
|
||||
<NAlert type="success" :show-icon="false">
|
||||
<strong>「{{ created.realName }}」的账号已创建。</strong>
|
||||
请把下面的初始密码发给本人。
|
||||
</NAlert>
|
||||
|
||||
&--inline {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
<div class="uf__pwd-box">
|
||||
<div class="uf__pwd-label">
|
||||
初始密码
|
||||
</div>
|
||||
<div class="uf__pwd-row">
|
||||
<code class="uf__pwd">{{ created.initialPassword }}</code>
|
||||
<NButton type="primary" @click="copyPassword">
|
||||
复制
|
||||
</NButton>
|
||||
</div>
|
||||
<NAlert type="warning" :show-icon="false" class="uf__pwd-warn">
|
||||
<strong>这个密码只显示这一次。</strong>
|
||||
关闭窗口后就查不到了 —— 库里存的是不可逆的哈希,忘了只能重新重置。
|
||||
</NAlert>
|
||||
</div>
|
||||
|
||||
&__pwd-box {
|
||||
margin: 16px 0;
|
||||
}
|
||||
<NDescriptions :column="1" size="small" label-placement="left">
|
||||
<NDescriptionsItem label="登录账号">{{ created.username }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="姓名">{{ created.realName }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="角色">{{ created.roleNames.join('、') || '—' }}</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
&__pwd-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<div class="uf__footer">
|
||||
<NButton type="primary" @click="closeCreated">
|
||||
我已记下密码
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
&__pwd-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
<!-- ── 表单态 ──────────────────────────────────────── -->
|
||||
<template v-else>
|
||||
<NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<div class="uf__grid">
|
||||
<NFormItem label="登录账号" path="username">
|
||||
<NInput v-model:value="form.username" :disabled="isEdit" placeholder="如 zhangsan" clearable />
|
||||
<template v-if="isEdit" #feedback>
|
||||
<span class="uf__hint">登录名不可修改:改了会让历史记录与导出包里的账号对不上</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
&__pwd {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 18px;
|
||||
letter-spacing: 2px;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
<NFormItem label="姓名" path="realName">
|
||||
<NInput v-model:value="form.realName" placeholder="如 张三" :maxlength="64" clearable />
|
||||
</NFormItem>
|
||||
|
||||
&__pwd-warn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
<NFormItem label="手机号" path="phone">
|
||||
<NInput v-model:value="form.phone" placeholder="11 位手机号" :maxlength="11" clearable />
|
||||
<template #feedback>
|
||||
<span class="uf__hint">
|
||||
{{ isEdit ? '列表只返回脱敏值,这里必须重填一次(加密存储)' : '加密存储,列表只显示脱敏值' }}
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
<NFormItem label="所属组织">
|
||||
<NTreeSelect
|
||||
v-model:value="form.orgId"
|
||||
:options="orgOptions"
|
||||
placeholder="不选表示不归属任何组织"
|
||||
clearable
|
||||
class="uf__full"
|
||||
/>
|
||||
</NFormItem>
|
||||
</div>
|
||||
|
||||
<NFormItem label="用户类型">
|
||||
<NSpace align="center" :size="8">
|
||||
<NTag v-if="derivedUserType" type="info" :bordered="false" size="small">
|
||||
{{ ROLE_USER_TYPE_NAMES[derivedUserType] ?? derivedUserType }}
|
||||
</NTag>
|
||||
<span v-else-if="conflictTypes.length > 1" class="uf__hint">
|
||||
所选角色分属不同类别({{ conflictTypes.join('、') }}),请只选同一类的角色
|
||||
</span>
|
||||
<span v-else class="uf__hint">选择角色后自动确定</span>
|
||||
</NSpace>
|
||||
<template #feedback>
|
||||
<span class="uf__hint">
|
||||
由所选角色自动决定,不需要手选 —— 这样不会出现「学生拿着学校管理员权限」这类账号
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="角色" path="roleCodes">
|
||||
<NCheckboxGroup v-model:value="form.roleCodes">
|
||||
<NSpace :size="14" :wrap="true">
|
||||
<NCheckbox v-for="r in roleOptions" :key="r.value" :value="r.value" :label="r.label" />
|
||||
</NSpace>
|
||||
</NCheckboxGroup>
|
||||
<template #feedback>
|
||||
<span class="uf__hint">
|
||||
可多选,权限取并集。内置角色的权限由系统决定,不能在这里改。
|
||||
<template v-if="!roleOptions.length">(没有可选角色:请先确认已选学校、且角色是启用状态)</template>
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="isEdit" label="状态">
|
||||
<NSpace align="center" :size="8">
|
||||
<NSwitch v-model:value="form.status" :checked-value="1" :unchecked-value="0" />
|
||||
<span class="uf__hint">
|
||||
{{ form.status === 1 ? '正常,可登录' : '停用,不能登录(数据保留)' }}
|
||||
</span>
|
||||
</NSpace>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<div class="uf__footer">
|
||||
<NSpace justify="end">
|
||||
<NButton @click="visible = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="submitting" @click="confirm">
|
||||
{{ isEdit ? '保存' : '创建账号' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 两列表单:窄屏(弹窗本身固定宽,这里只在字段名很长时兜底)也不会被 min-content 撑破 */
|
||||
.uf__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.uf__full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.uf__hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始密码块:**卡片内部的浅灰小格子**(浅灰底 + 弱描边 + 小圆角),
|
||||
* 不是再套一层卡片 —— 弹窗本身已经是一张卡片了。
|
||||
*/
|
||||
.uf__pwd-box {
|
||||
padding: 12px;
|
||||
margin: 14px 0;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.uf__pwd-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.uf__pwd-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.uf__pwd {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
font-size: 15px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.06em;
|
||||
background: var(--xt-card-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.uf__pwd-warn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.uf__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,182 +1,38 @@
|
||||
<template>
|
||||
<a-modal v-model:visible="visible" :title="report ? '导入结果' : '批量导入账号'" :width="report ? 760 : 560"
|
||||
:mask-closable="false" :footer="false" @cancel="close">
|
||||
<!-- ── 第一步:选文件 ────────────────────────────── -->
|
||||
<template v-if="!report">
|
||||
<a-alert type="info" :show-icon="false" class="user-import__intro">
|
||||
<template #title>
|
||||
表格里需要有 <strong>姓名</strong> 与 <strong>账号</strong> 两列,
|
||||
<strong>手机号</strong> 必填;<strong>组织</strong>、<strong>角色</strong> 可选。
|
||||
表头在第几行<b>不用管</b>,系统会自己找。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-form :model="formModel" layout="vertical">
|
||||
<a-form-item label="Excel 文件">
|
||||
<div class="user-import__pick">
|
||||
<!--
|
||||
这里用原生 input 而不是 a-upload:
|
||||
a-upload 的 auto-upload=false 时,拿原始 File 对象要经由 fileItem.file,
|
||||
各版本字段名不尽相同;而本场景只需要"选一个本地文件然后自己发请求",
|
||||
原生 input 的行为是完全确定的,少一层不确定性。
|
||||
-->
|
||||
<input ref="fileInput" type="file" accept=".xlsx,.xls" class="user-import__file"
|
||||
@change="onFileChange" />
|
||||
<a-button @click="pickFile">
|
||||
<template #icon><IconUpload /></template>
|
||||
选择文件
|
||||
</a-button>
|
||||
<span v-if="file" class="user-import__filename">{{ file.name }}</span>
|
||||
<span v-else class="user-import__hint">支持 .xlsx / .xls,不超过 10MB</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="没有「角色」列时的默认角色">
|
||||
<a-select v-model="formModel.defaultRoleCode" class="user-import__role">
|
||||
<a-option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</a-option>
|
||||
</a-select>
|
||||
<template #extra>
|
||||
<span class="user-import__hint">
|
||||
表格里有「角色」列时以列为准;那一行没填角色才用这里的默认值。
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="user-import__footer">
|
||||
<a-space>
|
||||
<a-button @click="close">取消</a-button>
|
||||
<a-button type="primary" :loading="submitting" :disabled="!file" @click="submit">
|
||||
开始导入
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── 第二步:体检报告 ─────────────────────────── -->
|
||||
<template v-else>
|
||||
<a-row :gutter="12" class="user-import__stats">
|
||||
<a-col :span="8">
|
||||
<div class="user-import__stat">
|
||||
<div class="user-import__stat-num">{{ report.total }}</div>
|
||||
<div class="user-import__stat-label">参与导入的行数</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="user-import__stat user-import__stat--ok">
|
||||
<div class="user-import__stat-num">{{ report.success }}</div>
|
||||
<div class="user-import__stat-label">成功创建</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="user-import__stat" :class="report.failed ? 'user-import__stat--bad' : ''">
|
||||
<div class="user-import__stat-num">{{ report.failed }}</div>
|
||||
<div class="user-import__stat-label">未能导入</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 表头识别结果:行号 + 原文 + 列映射,三样一起给才核对得动 -->
|
||||
<a-alert :type="report.errors.some((e) => e.field === 'realName' || e.field === 'username') ? 'warning' : 'success'"
|
||||
:show-icon="false" class="user-import__header-alert">
|
||||
<template #title>
|
||||
系统把第 <strong>{{ report.headerRow }}</strong> 行当成了表头。
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="report.headerText" class="user-import__header-text">{{ report.headerText }}</div>
|
||||
<div class="user-import__cols">
|
||||
<span v-for="c in report.columns" :key="c.field" class="user-import__col-tag">
|
||||
{{ c.fieldName }}
|
||||
<em>{{ c.column }} 列</em>
|
||||
</span>
|
||||
<span v-if="!report.columns.length" class="user-import__hint">
|
||||
没有识别到任何列 —— 请确认表格里有「姓名」和「账号」两列。
|
||||
</span>
|
||||
</div>
|
||||
<div class="user-import__hint">
|
||||
如果上面的行或列不对,请调整表格后重新导入;已经导入成功的账号不会重复创建。
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- 失败明细 -->
|
||||
<template v-if="report.errors.length">
|
||||
<div class="user-import__section">未能导入的行({{ report.errors.length }})</div>
|
||||
<a-table :data="report.errors" :pagination="false" row-key="row" size="small"
|
||||
:max-height="220" :scroll="{ y: 220 }">
|
||||
<template #columns>
|
||||
<a-table-column title="Excel 行号" :width="100" align="center">
|
||||
<template #cell="{ record }">第 {{ record.row }} 行</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="字段" :width="90">
|
||||
<template #cell="{ record }">
|
||||
{{ IMPORT_FIELD_NAMES[record.field] ?? record.field }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="原值" data-index="value" :width="150" ellipsis tooltip />
|
||||
<a-table-column title="原因" data-index="reason" />
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<!-- 初始密码:只这一次 -->
|
||||
<template v-if="report.initialPasswords.length">
|
||||
<div class="user-import__section">
|
||||
新账号的初始密码({{ report.initialPasswords.length }})
|
||||
</div>
|
||||
<a-alert type="warning" :show-icon="false">
|
||||
<template #title>
|
||||
<strong>这些密码只显示这一次。</strong>
|
||||
关闭后无法再查看,请现在就复制下来分发给本人。
|
||||
</template>
|
||||
</a-alert>
|
||||
<a-table :data="report.initialPasswords" :pagination="false" row-key="username" size="small"
|
||||
:max-height="200" :scroll="{ y: 200 }">
|
||||
<template #columns>
|
||||
<a-table-column title="账号" data-index="username" :width="180" />
|
||||
<a-table-column title="初始密码">
|
||||
<template #cell="{ record }">
|
||||
<code class="user-import__pwd">{{ record.password }}</code>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<div class="user-import__footer">
|
||||
<a-space>
|
||||
<a-button v-if="report.initialPasswords.length" @click="copyAll">
|
||||
<template #icon><IconCopy /></template>
|
||||
复制全部密码
|
||||
</a-button>
|
||||
<a-button type="primary" @click="finish">完成</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { RoleOption } from '@/apis/xt/role'
|
||||
import type { UserImportResult } from '@/apis/xt/user'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconCopy, IconUpload } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDataTable,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NModal,
|
||||
NSelect,
|
||||
NSpace,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { getRoleOptions } from '@/apis/xt/role'
|
||||
import * as userApi from '@/apis/xt/user'
|
||||
import { IMPORT_FIELD_NAMES, PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS } from '@/apis/xt/user'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { IMPORT_FIELD_NAMES } from '@/apis/xt/user'
|
||||
|
||||
/**
|
||||
* 账号批量导入弹窗。
|
||||
*
|
||||
* 这个弹窗的重点不是"上传",而是**体检报告**:
|
||||
* 实测的真实 Excel 里,表头一份在第 1 行、一份在第 6 行,列名也有多种写法。
|
||||
* 所以导入不可能"静默全成功或全失败",必须把
|
||||
* ① 系统认的是第几行、那行长什么样、认成了哪几列
|
||||
* ② 哪几行没进去、为什么
|
||||
* ③ 新账号的初始密码(只此一次)
|
||||
* 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。
|
||||
* 所以导入不可能"静默全成功或全失败",必须把三件事摊开给用户看:
|
||||
* ① 系统认的是第几行、那行长什么样、认成了哪几列;
|
||||
* ② 哪几行没进去、为什么(行号 + 字段 + 原值 + 原因);
|
||||
* ③ 新账号的初始密码(**只此一次**)。
|
||||
* 用户当场就能判断"这次导入能不能信"。
|
||||
*
|
||||
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"导进哪所学校"
|
||||
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定默认角色的可选范围。
|
||||
* `tenantId` 由父页面传入,**只有平台运营会传值**:表示"导进哪所学校",
|
||||
* 同时决定默认角色的可选范围。
|
||||
*/
|
||||
defineOptions({ name: 'UserImportModal' })
|
||||
|
||||
@@ -188,45 +44,88 @@ const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const file = ref<File | null>(null)
|
||||
const report = ref<UserImportResult | null>(null)
|
||||
const roleOptions = ref<RoleOption[]>([])
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
|
||||
const form = reactive({ defaultRoleCode: 'HEAD_TEACHER' })
|
||||
|
||||
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
||||
/** 与 UserFormModal 同一口径:代管某所学校时只能发学校那套角色 */
|
||||
const roleOptions = computed(() =>
|
||||
isPlatformTenant.value && !props.tenantId
|
||||
? PLATFORM_ROLE_OPTIONS
|
||||
: SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT')
|
||||
)
|
||||
/**
|
||||
* 默认角色的候选:**不含学生**。
|
||||
*
|
||||
* 学生账号是从微信进来的(他们不填 Excel,也没有密码),批量导入一批"学生账号"
|
||||
* 只会造出一堆永远登不上的账号。
|
||||
*/
|
||||
const roleSelectOptions = computed(() => roleOptions.value
|
||||
.filter((r) => r.value !== 'STUDENT')
|
||||
.map((r) => ({ label: r.label, value: r.value })))
|
||||
|
||||
/** a-form 需要一个 model;文件选择走原生 input,不进这个对象 */
|
||||
const formModel = reactive({ defaultRoleCode: 'HEAD_TEACHER' })
|
||||
const errorColumns: DataTableColumns<UserImportResult['errors'][number]> = [
|
||||
{
|
||||
title: 'Excel 行号',
|
||||
key: 'row',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (row) => `第 ${row.row} 行`
|
||||
},
|
||||
{
|
||||
title: '字段',
|
||||
key: 'field',
|
||||
width: 90,
|
||||
render: (row) => IMPORT_FIELD_NAMES[row.field] ?? row.field
|
||||
},
|
||||
{ title: '原值', key: 'value', width: 150, ellipsis: { tooltip: true }, render: (row) => row.value || '(空)' },
|
||||
{ title: '原因', key: 'reason' }
|
||||
]
|
||||
|
||||
function open() {
|
||||
const pwdColumns: DataTableColumns<UserImportResult['initialPasswords'][number]> = [
|
||||
{ title: '账号', key: 'username', width: 180 },
|
||||
{ title: '初始密码', key: 'password' }
|
||||
]
|
||||
|
||||
/** 一行可能既有字段错又有原因错,所以键要带上字段名 */
|
||||
function errorRowKey(row: UserImportResult['errors'][number]): string {
|
||||
return `${row.row}-${row.field}`
|
||||
}
|
||||
|
||||
/** 初始密码表按账号去重(同一个账号只会有一条) */
|
||||
function pwdRowKey(row: UserImportResult['initialPasswords'][number]): string {
|
||||
return row.username
|
||||
}
|
||||
|
||||
async function loadRoles(): Promise<void> {
|
||||
try {
|
||||
roleOptions.value = await getRoleOptions(props.tenantId)
|
||||
} catch {
|
||||
roleOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.tenantId, () => {
|
||||
roleOptions.value = []
|
||||
})
|
||||
|
||||
async function open(): Promise<void> {
|
||||
file.value = null
|
||||
report.value = null
|
||||
visible.value = true
|
||||
await loadRoles()
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function pickFile() {
|
||||
function pickFile(): void {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
function onFileChange(e: Event): void {
|
||||
const input = e.target as HTMLInputElement
|
||||
const picked = input.files?.[0] ?? null
|
||||
if (picked && picked.size > 10 * 1024 * 1024) {
|
||||
Message.warning('文件超过 10MB,请拆分后分批导入')
|
||||
message.warning('文件超过 10MB,请拆分后分批导入')
|
||||
input.value = ''
|
||||
file.value = null
|
||||
return
|
||||
@@ -234,160 +133,308 @@ function onFileChange(e: Event) {
|
||||
file.value = picked
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!file.value) return
|
||||
async function submit(): Promise<void> {
|
||||
if (!file.value) {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await userApi.importUsers(file.value, formModel.defaultRoleCode, props.tenantId)
|
||||
report.value = res.data ?? null
|
||||
report.value = await userApi.importUsers(file.value, form.defaultRoleCode, props.tenantId)
|
||||
emit('save-success')
|
||||
} catch {
|
||||
// 提示已由 http 层给出(如文件格式不对)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAll() {
|
||||
async function copyAll(): Promise<void> {
|
||||
const rows = report.value?.initialPasswords ?? []
|
||||
if (!rows.length) return
|
||||
if (!rows.length) {
|
||||
return
|
||||
}
|
||||
// 制表符分隔:可以直接粘进 Excel 分两列
|
||||
const text = rows.map((r) => `${r.username}\t${r.password}`).join('\r\n')
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
Message.success(`已复制 ${rows.length} 条(账号 + 密码,制表符分隔,可直接粘进 Excel)`)
|
||||
message.success(`已复制 ${rows.length} 条(账号 + 密码,制表符分隔,可直接粘进 Excel)`)
|
||||
} catch {
|
||||
Message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||||
message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
function finish(): void {
|
||||
if (report.value?.initialPasswords.length) {
|
||||
Message.warning('请确认已经复制并保存了初始密码 —— 关闭后无法再查看')
|
||||
message.warning('请确认已经复制并保存了初始密码 —— 关闭后无法再查看')
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 表头没认出姓名/账号列时把提示升级成 warning:那意味着这次导入基本没进去 */
|
||||
const headerTone = computed(() => {
|
||||
const errs = report.value?.errors ?? []
|
||||
return errs.some((e) => e.field === 'realName' || e.field === 'username') ? 'warning' : 'success'
|
||||
})
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-import {
|
||||
&__intro {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
:title="report ? '导入结果' : '批量导入账号'"
|
||||
:style="{ width: report ? '760px' : '560px' }"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<!-- ── 第一步:选文件 ────────────────────────────── -->
|
||||
<template v-if="!report">
|
||||
<NAlert type="info" :show-icon="false" class="ui__intro">
|
||||
表格里需要有 <strong>姓名</strong> 与 <strong>账号</strong> 两列,
|
||||
<strong>手机号</strong> 必填;<strong>组织</strong>、<strong>角色</strong> 可选。
|
||||
表头在第几行<strong>不用管</strong>,系统会自己找。
|
||||
</NAlert>
|
||||
|
||||
&__pick {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
<NForm label-placement="top">
|
||||
<NFormItem label="Excel 文件">
|
||||
<div class="ui__pick">
|
||||
<!-- 用原生 input 而不是上传组件:本场景只需要"选一个本地文件然后自己发请求",
|
||||
原生 input 的取值行为完全确定,少一层封装带来的不确定性 -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
class="ui__file"
|
||||
@change="onFileChange"
|
||||
>
|
||||
<NButton @click="pickFile">
|
||||
选择文件
|
||||
</NButton>
|
||||
<span v-if="file" class="ui__filename">{{ file.name }}</span>
|
||||
<span v-else class="ui__hint">支持 .xlsx / .xls,不超过 10MB</span>
|
||||
</div>
|
||||
</NFormItem>
|
||||
|
||||
&__file {
|
||||
display: none;
|
||||
}
|
||||
<NFormItem label="没有「角色」列时的默认角色">
|
||||
<NSelect v-model:value="form.defaultRoleCode" :options="roleSelectOptions" class="ui__full" />
|
||||
<template #feedback>
|
||||
<span class="ui__hint">
|
||||
表格里有「角色」列时以列为准;那一行没填角色才用这里的默认值
|
||||
</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
&__filename {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
<div class="ui__footer">
|
||||
<NSpace justify="end">
|
||||
<NButton @click="visible = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="submitting" :disabled="!file" @click="submit">
|
||||
开始导入
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<!-- ── 第二步:体检报告 ─────────────────────────── -->
|
||||
<template v-else>
|
||||
<NGrid cols="3" :x-gap="12" class="ui__stats">
|
||||
<NGridItem>
|
||||
<div class="ui__stat">
|
||||
<div class="ui__stat-num">{{ report.total }}</div>
|
||||
<div class="ui__stat-label">参与导入的行数</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<div class="ui__stat ui__stat--ok">
|
||||
<div class="ui__stat-num">{{ report.success }}</div>
|
||||
<div class="ui__stat-label">成功创建</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<div class="ui__stat" :class="report.failed ? 'ui__stat--bad' : ''">
|
||||
<div class="ui__stat-num">{{ report.failed }}</div>
|
||||
<div class="ui__stat-label">未能导入</div>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
&__role {
|
||||
width: 200px;
|
||||
}
|
||||
<!-- 表头识别结果:行号 + 原文 + 列映射,三样一起给才核对得动 -->
|
||||
<NAlert :type="headerTone" :show-icon="false" class="ui__header-alert">
|
||||
<div>
|
||||
系统把第 <strong>{{ report.headerRow }}</strong> 行当成了表头。
|
||||
</div>
|
||||
<div v-if="report.headerText" class="ui__header-text">{{ report.headerText }}</div>
|
||||
<div class="ui__cols">
|
||||
<span v-for="c in report.columns" :key="c.field" class="ui__col-tag">
|
||||
{{ c.fieldName }} <em>{{ c.column }} 列</em>
|
||||
</span>
|
||||
<span v-if="!report.columns.length" class="ui__hint">
|
||||
没有识别到任何列 —— 请确认表格里有「姓名」和「账号」两列
|
||||
</span>
|
||||
</div>
|
||||
<div class="ui__hint">
|
||||
如果上面的行或列不对,请调整表格后重新导入;已经导入成功的账号不会重复创建。
|
||||
</div>
|
||||
</NAlert>
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
<!-- 失败明细 -->
|
||||
<template v-if="report.errors.length">
|
||||
<div class="section-title ui__section">
|
||||
未能导入的行({{ report.errors.length }})
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="errorColumns"
|
||||
:data="report.errors"
|
||||
:pagination="false"
|
||||
:row-key="errorRowKey"
|
||||
size="small"
|
||||
:max-height="220"
|
||||
/>
|
||||
</template>
|
||||
|
||||
&__stats {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
<!-- 初始密码:只这一次 -->
|
||||
<template v-if="report.initialPasswords.length">
|
||||
<div class="section-title ui__section">
|
||||
新账号的初始密码({{ report.initialPasswords.length }})
|
||||
</div>
|
||||
<NAlert type="warning" :show-icon="false">
|
||||
<strong>这些密码只显示这一次。</strong>
|
||||
关闭后无法再查看,请现在就复制下来分发给本人。
|
||||
</NAlert>
|
||||
<NDataTable
|
||||
:columns="pwdColumns"
|
||||
:data="report.initialPasswords"
|
||||
:pagination="false"
|
||||
:row-key="pwdRowKey"
|
||||
size="small"
|
||||
:max-height="200"
|
||||
class="ui__pwd-table"
|
||||
/>
|
||||
</template>
|
||||
|
||||
&__stat {
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 6px;
|
||||
<div class="ui__footer">
|
||||
<NSpace justify="end">
|
||||
<NButton v-if="report.initialPasswords.length" @click="copyAll">
|
||||
复制全部密码
|
||||
</NButton>
|
||||
<NButton type="primary" @click="finish">
|
||||
完成
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
&--ok .user-import__stat-num {
|
||||
color: rgb(var(--success-6));
|
||||
}
|
||||
<style scoped>
|
||||
.ui__intro {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
&--bad .user-import__stat-num {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
}
|
||||
.ui__pick {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__stat-num {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
/* 原生 input 只用来触发文件选择,界面上出现的是它左边的按钮 */
|
||||
.ui__file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.ui__filename {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__header-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ui__hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
&__header-text {
|
||||
padding: 8px 10px;
|
||||
margin: 6px 0 8px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: var(--color-text-2);
|
||||
word-break: break-all;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ui__full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__cols {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ui__stats {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__col-tag {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 3px;
|
||||
/* 统计三格:**弹窗内部的浅灰小格子**(浅灰底 + 弱描边),不是三张嵌套卡片 */
|
||||
.ui__stat {
|
||||
padding: 10px 12px;
|
||||
text-align: center;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
em {
|
||||
margin-left: 4px;
|
||||
font-style: normal;
|
||||
color: rgb(var(--primary-6));
|
||||
}
|
||||
}
|
||||
/* 成功 / 失败两格保留状态色(绿 / 红),描边跟着状态走,避免"灰框红底"看着像两种状态 */
|
||||
.ui__stat--ok {
|
||||
background: #eaf7ee;
|
||||
border-color: #cbe9d5;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.ui__stat--bad {
|
||||
background: #fdecec;
|
||||
border-color: #f6d3d3;
|
||||
}
|
||||
|
||||
&__pwd {
|
||||
padding: 2px 6px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 13px;
|
||||
letter-spacing: 1px;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ui__stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui__stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-2);
|
||||
}
|
||||
|
||||
.ui__header-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ui__header-text {
|
||||
margin: 6px 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ui__cols {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ui__col-tag {
|
||||
padding: 1px 8px;
|
||||
font-size: 12px;
|
||||
background: var(--xt-card-bg);
|
||||
border: 1px solid var(--xt-card-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ui__col-tag em {
|
||||
font-style: normal;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/*
|
||||
* 区块标题直接用全局 `.section-title`(左侧色条 + 加粗),
|
||||
* 这里只补它与上一块之间的上间距 —— 全局那条只管 margin-bottom。
|
||||
*/
|
||||
.ui__section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ui__pwd-table {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ui__footer {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user