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:
2026-10-10 16:48:46 +08:00
parent 0bbac7d228
commit 5b877451eb
270 changed files with 12964 additions and 25889 deletions
+404 -356
View File
@@ -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>
+134 -89
View File
@@ -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>
+304
View File
@@ -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>
+449 -311
View File
@@ -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 {
// 逐条列出被跳过的原因 —— 这是"用户下次能不能自己判断"的关键。
// ⚠️ 这里用 &lt;pre&gt; 纯文本而不是富文本:本页是 `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>
-693
View File
@@ -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>
+413 -426
View File
@@ -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>
+476 -293
View File
@@ -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>
+4 -5
View File
@@ -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>
+4 -5
View File
@@ -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>
-11
View File
@@ -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>
+517 -354
View File
@@ -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>
-272
View File
@@ -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
View File
@@ -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>
-285
View File
@@ -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>
+431 -344
View File
@@ -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>
+245 -150
View File
@@ -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>
+123 -87
View File
@@ -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">
类型必须比上级更低一级:学校 &gt; 院系/专业 &gt; 班级。
</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">类型必须比上级更低一级:学校 &gt; 院系/专业 &gt; 班级</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
View File
@@ -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>
-13
View File
@@ -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>
+472
View File
@@ -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>
+224 -170
View File
@@ -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="选项一&#10;选项二" />
</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="选项一&#10;选项二"
/>
</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>
+448 -332
View File
@@ -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>
+211 -162
View File
@@ -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
+445 -323
View File
@@ -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>
+436 -296
View File
@@ -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>
+343 -309
View File
@@ -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>
+347 -300
View File
@@ -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