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
@@ -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>