Files
xiaotutongxue/frontend-pc/src/views/task/TaskOrgDrawer.vue
T
lovife 5b877451eb 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
2026-10-10 16:48:46 +08:00

410 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 任务「覆盖班级与初审人」配置抽屉(《03-接口设计》§7.6)。
*
* 三处刻意的设计:
*
* 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 saving = ref(false)
const taskId = ref<number | null>(null)
const taskName = ref('')
const readonly = ref(false)
const readonlyReason = ref('')
const treeData = ref<TreeOption[]>([])
/** Naive 的勾选键是 `(string | number)[]`,所以这里不能窄化成 number[] */
const checkedKeys = ref<Array<string | number>>([])
const teacherOptions = ref<Array<{ id: number, label: string }>>([])
/** 班级 id → 组织节点,用于取名与取"该班负责人" */
const orgIndex = ref<Map<number, OrgTreeVO>>(new Map())
interface Row {
orgId: number
/** null = 未指定,界面上显示占位符;提交时原样传 null(接口约定的"清空"值) */
headUserId: number | null
/** null = 未设置(提交时不传/传 null),**不要用 0 代替** —— 0 是"预期 0 人" */
expectedCount: number | null
}
const rows = ref<Row[]>([])
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 ?? []) {
into.set(n.id, n)
flatten(n.children ?? [], into)
}
}
function toTreeData(nodes: OrgTreeVO[]): TreeOption[] {
return (nodes ?? []).map((n) => ({
key: n.id,
label: n.orgName,
// 只有班级可以勾选;只读时整棵树都点不动
checkboxDisabled: readonly.value || n.orgType !== 3,
children: toTreeData(n.children ?? [])
}))
}
function orgName(orgId: number) {
return orgIndex.value.get(orgId)?.orgName ?? `班级 ${orgId}`
}
/** 留空时告诉用户"将由谁审",而不是留一句让人怀疑的空白 */
function leaderHint(orgId: number) {
const org = orgIndex.value.get(orgId)
return org?.leaderName
? `将由班级负责人 ${org.leaderName} 初审`
: '该班未设负责人,建议指定初审人'
}
/** 勾选变化 → 行列表变化。rows 是唯一事实来源,勾选框只是它的一个视图 */
watch(checkedKeys, (keys) => {
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: null, expectedCount: null })
}
}
})
function removeRow(row: Row) {
rows.value = rows.value.filter((r) => r.orgId !== row.orgId)
checkedKeys.value = checkedKeys.value.filter((k) => Number(k) !== row.orgId)
}
async function open(task: TaskVO) {
taskId.value = task.id
taskName.value = task.taskName
// 与后端 EDITABLE_TASK_STATUS 一致:草稿/收集中/已暂停可改,已截止/已归档只读
const reason = ({ 2: '截止', 3: '归档' } as Record<number, string>)[task.status] ?? ''
readonly.value = !!reason
readonlyReason.value = reason
visible.value = true
rows.value = []
checkedKeys.value = []
loading.value = true
try {
const [tree, orgs] = await Promise.all([
orgApi.getOrgTree(),
taskApi.getTaskOrgs(task.id)
])
const nodes = tree ?? []
const index = new Map<number, OrgTreeVO>()
flatten(nodes, index)
orgIndex.value = index
treeData.value = toTreeData(nodes)
for (const o of orgs ?? []) {
rows.value.push({
// 后端回 null 表示"未指定";界面上显示占位符、提交时再回 null
orgId: o.orgId,
headUserId: o.headUserId ?? null,
expectedCount: o.expectedCount ?? null
})
}
checkedKeys.value = rows.value.map((r) => r.orgId)
} finally {
loading.value = false
}
// 初审人选项不影响主流程,单独加载,失败也不该让抽屉打不开
try {
teacherOptions.value = await taskApi.getTeacherOptions()
} catch {
teacherOptions.value = []
}
}
function close() {
visible.value = false
}
async function save() {
if (!taskId.value) {
return
}
const orgs: TaskOrgItem[] = rows.value.map((r) => ({
orgId: r.orgId,
headUserId: r.headUserId,
// 未填就传 null("未设置"),不要传 0
expectedCount: r.expectedCount
}))
saving.value = true
try {
await taskApi.saveTaskOrgs(taskId.value, orgs)
message.success(orgs.length ? `已保存 ${orgs.length} 个班的覆盖范围` : '已清空覆盖范围')
visible.value = false
emit('save-success')
} finally {
saving.value = false
}
}
defineExpose({ open })
</script>
<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(--xt-text-3);
}
&__readonly {
margin-bottom: 12px;
}
&__panes {
display: flex;
gap: 16px;
}
&__tree {
flex: none;
width: 250px;
padding: 10px 12px;
border: 1px solid var(--xt-card-border);
border-radius: 4px;
}
&__tree-body {
max-height: 520px;
overflow-y: auto;
}
&__list {
flex: 1;
min-width: 0;
}
&__pane-title {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
font-size: 13px;
font-weight: 500;
color: var(--xt-text);
}
&__pane-hint {
font-size: 11px;
font-weight: 400;
color: var(--xt-text-3);
}
&__row {
padding: 10px 12px;
margin-bottom: 10px;
/* 抽屉里的"卡片内小格子":浅灰底 + 弱描边(见 styles/index.scss 的卡片式规矩) */
background: var(--xt-band-bg);
border: 1px solid var(--xt-card-border-weak);
border-radius: 8px;
}
&__row-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
&__org {
font-weight: 500;
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(--xt-text-3);
}
&__fallback {
margin-top: 3px;
font-size: 11px;
line-height: 1.5;
color: var(--xt-text-3);
}
}
</style>