Files
xiaotutongxue/frontend-pc/src/views/role/RoleFormModal.vue
T
lovife 623e6393d5 fix(pc): 弹窗遮罩改毛玻璃并清掉 9 处提示折行
遮罩底色被 Naive 写死在 css-render 里(Modal / Drawer 主题没有 mask token,
themeOverrides 改不到),只能上 CSS;加 backdrop-filter 后遮罩后面的页面糊掉、
弹层本体仍是纯白,NModal / NDrawer / useDialog 共用 .n-modal-mask 一条规则覆盖。

折行的根因是格子宽度写死(半宽 264px / span 8 192px / 定宽 160px),与窗口宽度无关:
能压短的压短;「文件名预览」那处是 .n-form-item-blank 属于 flex 行、把提示挤成 3 行,
改成包一层块级容器;材料预览失败的占位在 108px 小格里曾折 3 行且被裁掉。

约定写进 frontend-pc/AGENTS.md 第 11 条与 code-css skill;实测与刻意不改的清单见
docs/交接摘要.md 的 §一·四十二。
2026-10-14 15:34:40 +08:00

296 lines
9.9 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">
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
}
/**
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
* (naive 的渲染是 `children || feedback ? feedback : renderExplains`,
* 插槽存在时 `renderExplains` 永不渲染)。本表单有 3 个字段带 `#feedback`,
* 所以校验失败必须自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
* 用户看到的是"点了确定没反应"。
*/
function firstError(errors: unknown): string | null {
if (!Array.isArray(errors)) {
return null
}
// ⚠️ naive 的 `Form.validate()` reject 出来的是**嵌套数组**(`[[{message}]…]`),
// 见 node_modules/naive-ui/es/form/src/Form.mjs:96-107 —— 每一层的 `result.errors` 被整个 push 进去。
// 所以不能直接读 `errors[0].message`,必须摊平后再找第一条。
for (const entry of errors.flat(2)) {
const message = (entry as { message?: unknown } | null)?.message
if (typeof message === 'string' && message) {
return message
}
}
return null
}
async function confirm(): Promise<void> {
try {
await formRef.value?.validate()
} catch (errors) {
// Naive 的 `validate()` 在不过时是 reject(不是返回 false)
message.error(firstError(errors) ?? '请检查表单填写')
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>