7523ebe789
补上发布前置校验的最后一环(40019),同时确立任务的数据隔离边界 ——
班主任能看到哪些任务、能审哪些单子,全由 collect_task_org 决定。
后端 GET/PUT /tasks/{id}/orgs(整体替换语义 + 软移除 + 历史行复用);
前端「配置班级」抽屉:组织树只允许勾选班级,主班主任留空时显式提示
「将由该班负责人初审」而非留白。发布被拦时给出「去哪里补」的指引
(BLOCKING_HINTS),而不是只说"未设置覆盖班级"。
三条守住的规则:
1) 班级与初审人都必须在本租户内。跨租户的 id 一律当作"不存在或已删除",
不透露"它存在但不属于你" —— 漏了的后果是 A 校能把 B 校的班挂到自己
的任务上,B 校的班主任就出现在 A 校的审批流里。
2) 初审人必须持有班主任/副班主任角色。指定初审人本质上是一次授权,
那个人将能看到该班学生的身份证号与学籍材料;不校验角色就等于把
"谁能看学生隐私"变成任意授权。报错要给出下一步,不能只说"校验失败"。
3) 移除是软移除(status=0),且「移除再加回」必须复用原行。
后者不是优化而是必须:uk_task_org UNIQUE (task_id, org_id) 不带 status
条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束。
本轮修掉的 3 个真问题:
1) 我此前误判 uk_task_org 不存在 —— grep 只匹配含表名的行,而
CONSTRAINT uk_task_org UNIQUE (task_id, org_id) 那行没有表名。
基于这个错误前提,我写了一个多余的部分唯一索引 uk_task_org_active
和一段基于错误前提的代码,已全部撤掉并更正注释。
教训:查约束要看 CREATE TABLE 整块,不能只 grep 表名。
2) collect_task_org.expected_count 是 NOT NULL DEFAULT 0,与接口契约矛盾:
DDL 注释说它是"由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在
PUT 请求体里、说"用于与名册实际人数比对" —— 只有"管理员填的期望值 vs
名册实际值"才需要比对。而 0 是有意义的业务值("预期 0 人"),拿它兼职
表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警;告警
变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉,
而这条告警的全部价值就在那一次。已由 V4 迁移改为可空 + 去掉默认值,
并同步更正 docs/db/schema-postgresql.sql 与 PRD 里的内联 DDL。
3) 删掉迁移文件后必须 mvn clean,否则它会"复活":我只跑 mvn package,
target/classes 里的旧副本被打进 jar,Flyway 发现历史里没有 v4 就又执行
了一遍 —— 索引回来了、历史行填回去了。之后又建了同名版本号的新 V4,
于是"两个 v4"→"checksum 冲突",排查绕了好几圈。
顺带修掉:
· 任务列表操作列从 3 个链接涨到 4 个,160px 会挤成竖排 —— 把「进度」并进
「状态」列腾出 40px,操作列给到 200px,合计 820px(可用约 824px)。
· defineEmits 必须用函数调用签名(frontend-pc/.agents/skills/code-vue)。
我此前写的 5 个组件都是被禁的 tuple 形式,一并改正:
OrgFormModal / TaskFormModal / TaskOrgDrawer / UserFormModal / UserImportModal。
· e2e-m1-orgs.py 的清理抽成幂等的 cleanup_all(),并在 __main__ 的 finally
里兜一次 —— 该脚本首次运行时崩在准备阶段、清理段没执行,把两个测试账号
留在了库里(是在浏览器里看到下拉框里有个「E2E班主任」才发现的)。
其余三条 E2E 脚本仍是老样子,下一轮一并改。
验收:e2e-m1-orgs.py 48/48(含跨租户隔离);e2e-m1-task.py 71/71、
e2e-m0-user.py 55/55、e2e-m0.py 36/36 回归通过,共 210 项断言;
前端 typecheck / eslint / lint:style 0 错、build 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
394 lines
13 KiB
Vue
394 lines
13 KiB
Vue
<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 { OrgTreeVO } from '@/apis/xt/org'
|
||
import { Message } from '@arco-design/web-vue'
|
||
import { IconCopy } from '@arco-design/web-vue/es/icon'
|
||
import { getOrgTree } from '@/apis/xt/org'
|
||
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 就完事,而是把弹窗切成成功态,
|
||
* 停下来让用户当场抄下来。库里是 BCrypt 哈希,关了窗口谁也还原不出来。
|
||
* 2. **角色选项按租户过滤** —— 平台运营角色只对平台租户显示。学校管理员
|
||
* 拿到它会直接越权到平台级(能开通租户、看全平台租户列表),
|
||
* 所以前端不显示、后端也会用 40301 拒绝。
|
||
* 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定,
|
||
* 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,
|
||
* 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。
|
||
*/
|
||
defineOptions({ name: 'UserFormModal' })
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'save-success'): void
|
||
}>()
|
||
|
||
const userStore = useUserStore()
|
||
|
||
const visible = ref(false)
|
||
const submitting = ref(false)
|
||
const detailId = ref<number | null>(null)
|
||
const orgTree = ref<OrgTreeVO[]>([])
|
||
const created = ref<{ username: string, realName: string, initialPassword: string, roleNames: string[] } | null>(null)
|
||
|
||
const formRef = useTemplateRef('formRef')
|
||
|
||
const isEdit = computed(() => !!detailId.value)
|
||
const title = computed(() => (isEdit.value ? '修改账号' : '新增账号'))
|
||
|
||
/** 平台租户下才提供平台运营角色 */
|
||
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
|
||
|
||
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
|
||
|
||
const getInitForm = () => ({
|
||
username: '',
|
||
realName: '',
|
||
phone: '',
|
||
orgId: undefined as number | undefined,
|
||
roleCodes: [] as string[],
|
||
status: 1
|
||
})
|
||
|
||
// 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前
|
||
const form = reactive(getInitForm())
|
||
|
||
const roleOptions = computed(() =>
|
||
isPlatformTenant.value ? 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))
|
||
}
|
||
return [...names]
|
||
})
|
||
|
||
const rules: FormInstance['rules'] = {
|
||
username: [
|
||
{ required: true, message: '请填写登录账号' },
|
||
{
|
||
match: /^[A-Z][\w.-]{1,63}$/i,
|
||
message: '只能以字母开头,可含字母、数字、下划线、点或短横线'
|
||
}
|
||
],
|
||
realName: [{ required: true, message: '请填写姓名' }],
|
||
phone: [
|
||
{ required: true, message: '请填写手机号' },
|
||
{ match: /^1[3-9]\d{9}$/, message: '手机号格式不正确' }
|
||
],
|
||
roleCodes: [{ required: true, message: '请至少选择一个角色' }]
|
||
}
|
||
|
||
async function loadOrgTree() {
|
||
if (orgTree.value.length) return
|
||
const res = await getOrgTree()
|
||
orgTree.value = res.data ?? []
|
||
}
|
||
|
||
/** 新增 */
|
||
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
|
||
try {
|
||
await navigator.clipboard.writeText(pwd)
|
||
Message.success('已复制到剪贴板')
|
||
} catch {
|
||
Message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||
}
|
||
}
|
||
|
||
async function confirm() {
|
||
const valid = await formRef.value?.validate()
|
||
if (valid) return
|
||
|
||
const phone = form.phone.trim()
|
||
if (!phone) {
|
||
// 编辑态也要求重填手机号:列表接口按 §1.7 只返回脱敏值(138****8888),
|
||
// 拿它当明文回填会把脱敏串写进库里,比"让用户重填一次"糟糕得多。
|
||
Message.warning('请填写手机号(列表只返回脱敏值,无法回填原号码,需重填)')
|
||
return
|
||
}
|
||
|
||
const userType = derivedUserType.value
|
||
if (userType === null) {
|
||
Message.warning('请选择角色(且需属于同一类),用户类型由它自动确定')
|
||
return
|
||
}
|
||
|
||
submitting.value = true
|
||
try {
|
||
if (isEdit.value) {
|
||
await userApi.updateUser(detailId.value!, {
|
||
realName: form.realName.trim(),
|
||
phone,
|
||
orgId: form.orgId ?? null,
|
||
roleCodes: form.roleCodes,
|
||
userType,
|
||
status: form.status
|
||
})
|
||
Message.success('已保存')
|
||
visible.value = false
|
||
emit('save-success')
|
||
return
|
||
}
|
||
|
||
const res = await userApi.addUser({
|
||
username: form.username.trim(),
|
||
realName: form.realName.trim(),
|
||
phone,
|
||
orgId: form.orgId ?? null,
|
||
roleCodes: form.roleCodes,
|
||
userType
|
||
})
|
||
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
|
||
)
|
||
}
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
defineExpose({ add, edit })
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.user-form {
|
||
&__hint {
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
color: var(--color-text-3);
|
||
|
||
&--inline {
|
||
margin-left: 10px;
|
||
}
|
||
}
|
||
|
||
&__pwd-box {
|
||
margin: 16px 0;
|
||
}
|
||
|
||
&__pwd-label {
|
||
margin-bottom: 6px;
|
||
font-size: 12px;
|
||
color: var(--color-text-3);
|
||
}
|
||
|
||
&__pwd-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
&__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;
|
||
}
|
||
|
||
&__pwd-warn {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
&__footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 20px;
|
||
}
|
||
}
|
||
</style>
|