623e6393d5
遮罩底色被 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 的 §一·四十二。
296 lines
9.9 KiB
Vue
296 lines
9.9 KiB
Vue
<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>
|