feat(frontend): 管理端改用 Gi Admin Pro 框架 + 腾讯云主题

前端此前只有本地实现、从未入库,本次首次纳入版本管理。

框架切换
  · 以 Gi Admin Pro(Vue3 + Vite + TS + Arco Design Vue + Pinia)为底
  · 剥离 14 个演示页面目录 / 121 个文件(demo 28、file 20、form 19、system 18…)
  · 依赖 dependencies 35 → 19,devDependencies 31 → 27
  · 保留框架核心:layout、Gi* 组件库、hooks、权限指令、错误页、登录页

主题:腾讯云 / TDesign 官方色板
  · 主色 #0052D9(腾讯蓝)、成功 #00A870、警告 #ED7B2F、危险 #E34D59
  · 页面底色 #f3f3f3、文字 rgba(0,0,0,90/60/40/26%)
  · 覆盖 Arco 底层色板 + 显式语义别名(primary/success/warning/danger/link/neutral)
  · 三个改了不生效也不报错的坑已记入注释:
      1) Arco 色值是逗号分隔 RGB 三元组,不是 #RRGGBB
      2) Arco 把色板定义在 body 而非 :root,必须写 `:root, body`
      3) setting.json 的 themeColor 会以内联样式写进 body,优先级高于样式表

对接后端(三处契约差异在 @/utils/http 统一适配)
  · 响应信封 {code,msg,data} → {code,success,message,data}
  · Token 头 token → Authorization: Bearer
  · 分页 {list,total} → 补一份 records,list 原样保留
  · 40101 → 登录页;登录密码 RSA 加密(公钥走 /auth/public-key,轮换无需重建前端)
  · 路由:后端只下发菜单键,前端在本地路由表上过滤(有哪些页面归代码,谁能看归后端)

已实现页面(浏览器实测通过)
  · 登录:RSA 加密 + 自适应验证码/租户码 + 账号锁定提示
  · 工作台:真实数据(组织 6 个 / 角色 1 个 / 权限 25 项)
  · 组织架构:三级树 + CRUD + 占用校验
  · 租户管理:开通租户 + 初始密码只显示一次的强确认
  · 待建页:未实现菜单显示所属里程碑
  · 权限菜单:学校管理员看不到「租户管理」

视觉资产按腾讯云主题重做
  · logo.svg(渐变圆角方 + 填)、favicon.svg(小尺寸做减法)、login-hero.svg(材料归集)
  · 登录页点阵纹理走主题变量,换主色自动跟随
  · 文案改为产品实际能力:自动改名 / 强制压缩 / 隐私保护

清理模板遗留(约 17MB + 三处外部依赖)
  · 百度统计脚本(上报到 gi-demo 作者的统计账号)
  · 用户菜单「项目地址」(跳转模板作者的 Gitee 仓库)
  · 两份 8.8MB mp3、388KB favicon.ico、57KB 模板插画、未引用字体与 loading.gif
  · 「个人中心」指向已删除的 /system/account(点了 404)
  · 头部头像在无 avatarUrl 时渲染成裂图 → 改为姓名首字兜底

后端修复
  · 新增 GET /auth/public-key(前端加密登录密码用)
  · DataSeeder.ancestor_path 生成错误:子节点本该是 /16/17/ 却写成 /17/
    该字段是「按院系统计」的索引,错了会漏人/多人且不报错 —— 改为从库里读父节点路径
  · seedDemoOrgs 曾放在租户作用域外 + admin 早退分支之后,导致启动失败 / 永不执行
  · 示范租户补一套三级组织树(1 学校 / 2 院系 / 3 班级),供界面演示
This commit is contained in:
2026-09-28 20:49:55 +08:00
parent 9d7a8ca845
commit 87ece5a971
237 changed files with 25215 additions and 26 deletions
+394
View File
@@ -0,0 +1,394 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<template #title>租户</template>
<template #extra>
<a-space>
<span class="tenant__hint">共 {{ total }} 个单位</span>
<a-button type="primary" size="small" @click="onAdd">
<template #icon><IconPlus /></template>
开通租户
</a-button>
</a-space>
</template>
<div class="g-row-tool">
<a-space wrap>
<a-button v-for="c in statusChips" :key="String(c.value)"
:type="statusFilter === c.value ? 'primary' : 'outline'" size="small" @click="setStatus(c.value)">
{{ c.label }}
</a-button>
</a-space>
<a-input-search v-model="query.keyword" placeholder="搜索名称或编码" allow-clear class="tenant__search"
@search="search" @clear="search" />
</div>
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination" @page-change="onPageChange">
<template #columns>
<a-table-column title="单位名称" data-index="tenantName">
<template #cell="{ record }">
<div class="tenant__name">{{ record.tenantName }}</div>
<div class="tenant__id">#{{ record.id }}</div>
</template>
</a-table-column>
<a-table-column title="编码" data-index="tenantCode" :width="130" />
<a-table-column title="类型" :width="110">
<template #cell="{ record }">
{{ TENANT_TYPE_NAMES[record.tenantType] ?? '—' }}
</template>
</a-table-column>
<a-table-column title="联系人" data-index="contactName" :width="120" />
<a-table-column title="到期" :width="120">
<template #cell="{ record }">
{{ (record.expireTime ?? '').slice(0, 10) || '—' }}
</template>
</a-table-column>
<a-table-column title="状态" :width="100">
<template #cell="{ record }">
<a-tag :color="TENANT_STATUS[record.status]?.color ?? 'gray'">
{{ TENANT_STATUS[record.status]?.text ?? '未知' }}
</a-tag>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<!-- 开通租户:成功态与表单态共用一个弹窗,弱化"关掉就看不到密码了"的风险 -->
<a-modal v-model:visible="visible" :title="created ? '租户已开通' : '开通租户'" :width="620"
:mask-closable="false" :closable="!created" :footer="false">
<!-- 成功态 -->
<template v-if="created">
<a-alert type="success" :show-icon="false">
<template #title>
<strong>「{{ createdName }}」已开通。</strong>
系统已自动创建 6 个内置角色,以及首个管理员账号。
</template>
</a-alert>
<div class="tenant__pwd-box">
<div class="tenant__pwd-label">管理员初始密码</div>
<div class="tenant__pwd-row">
<code class="tenant__pwd">{{ created.adminInitialPassword }}</code>
<a-button type="primary" size="small" @click="copyPassword">
<template #icon><IconCopy /></template>
复制
</a-button>
</div>
<a-alert type="warning" :show-icon="false" class="tenant__pwd-warn">
<strong>这个密码只显示这一次。</strong>
关闭窗口后就查不到了 —— 请现在复制并发给管理员,由其首次登录后自行修改。
</a-alert>
</div>
<a-descriptions :column="1" size="small">
<a-descriptions-item label="租户 ID">{{ created.id }}</a-descriptions-item>
<a-descriptions-item label="小程序码参数">{{ created.miniProgramScene }}</a-descriptions-item>
<a-descriptions-item label="管理员账号">{{ form.adminUsername }}</a-descriptions-item>
</a-descriptions>
<div class="tenant__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="tenantName" label="单位名称">
<a-input v-model="form.tenantName" placeholder="如 江西机电职业技术学院" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="tenantCode" label="租户编码">
<a-input v-model="form.tenantCode" placeholder="如 jxjd" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="tenantType" label="类型">
<a-select v-model="form.tenantType">
<a-option v-for="(name, code) in TENANT_TYPE_NAMES" :key="code" :value="Number(code)">
{{ name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="expireTime" label="到期时间">
<a-date-picker v-model="form.expireTime" value-format="YYYY-MM-DD" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="contactName" label="联系人">
<a-input v-model="form.contactName" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="contactPhone" label="联系电话">
<a-input v-model="form.contactPhone" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">首个管理员</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item field="adminUsername" label="登录账号">
<a-input v-model="form.adminUsername" allow-clear />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="adminRealName" label="姓名">
<a-input v-model="form.adminRealName" allow-clear />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="adminPhone" label="手机号">
<a-input v-model="form.adminPhone" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-alert type="info" :show-icon="false">
<template #title>
提交后会一次性完成:<strong>建租户</strong> → <strong>发放 6 个内置角色</strong> →
<strong>创建首个管理员</strong>。初始密码在下一步显示,<strong>只显示一次</strong>。
</template>
</a-alert>
</a-form>
<div class="tenant__footer">
<a-space>
<a-button @click="visible = false">取消</a-button>
<a-button type="primary" :loading="submitting" @click="onSubmit">确认开通</a-button>
</a-space>
</div>
</template>
</a-modal>
</GiPageLayout>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import { Message } from '@arco-design/web-vue'
import { IconCopy, IconPlus } from '@arco-design/web-vue/es/icon'
import * as tenantApi from '@/apis/xt/tenant'
import type { TenantCreateResult, TenantVO } from '@/apis/xt/tenant'
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
/**
* 租户管理(仅平台运营)。
*
* 两个容易做错的地方:
* 1. **初始密码只返回一次** —— 成功后不能弹个 toast 就完事,
* 必须停下来让用户当场记下/复制,并明确告知"关掉就再也看不到"。
* 2. **租户列表是跨租户的**(sys_tenant 没有 tenant_id),
* 所以权限把关全在服务端;学校管理员调它会拿到 40301,前端也据此隐藏入口。
*/
defineOptions({ name: 'Tenant' })
const loading = ref(false)
const submitting = ref(false)
const list = ref<TenantVO[]>([])
const total = ref(0)
const statusFilter = ref<number | undefined>(undefined)
const query = reactive({ pageNum: 1, pageSize: 10, keyword: '' })
const visible = ref(false)
const created = ref<TenantCreateResult | null>(null)
const createdName = ref('')
const formRef = useTemplateRef('formRef')
const getInitForm = () => ({
tenantName: '',
tenantCode: '',
tenantType: 1,
contactName: '',
contactPhone: '',
expireTime: '',
adminUsername: 'admin',
adminRealName: '',
adminPhone: ''
})
const form = reactive(getInitForm())
const rules: FormInstance['rules'] = {
tenantName: [{ required: true, message: '请填写单位名称' }],
tenantCode: [
{ required: true, message: '请填写租户编码' },
{ match: /^[a-z][a-z0-9_-]{1,31}$/, message: '小写字母开头,后接小写字母、数字、下划线或短横线' }
],
tenantType: [{ required: true, message: '请选择类型' }],
expireTime: [{ required: true, message: '请选择到期时间' }],
contactName: [{ required: true, message: '请填写联系人' }],
contactPhone: [{ required: true, message: '请填写联系电话' }],
adminUsername: [{ required: true, message: '请填写管理员账号' }],
adminRealName: [{ required: true, message: '请填写管理员姓名' }],
adminPhone: [{ required: true, message: '请填写管理员手机号' }]
}
const statusChips = computed(() => [
{ label: `全部 ${total.value}`, value: undefined },
{ label: '正常', value: 1 },
{ label: '试用', value: 2 },
{ label: '已停用', value: 0 }
])
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true
}))
async function load() {
loading.value = true
try {
const res = await tenantApi.getTenantList({
pageNum: query.pageNum,
pageSize: query.pageSize,
keyword: query.keyword.trim() || undefined,
status: statusFilter.value
})
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
loading.value = false
}
}
function search() {
query.pageNum = 1
load()
}
function setStatus(v: number | undefined) {
statusFilter.value = v
search()
}
function onPageChange(page: number) {
query.pageNum = page
load()
}
function onAdd() {
Object.assign(form, getInitForm())
created.value = null
visible.value = true
}
async function onSubmit() {
const valid = await formRef.value?.validate()
if (valid) return
submitting.value = true
try {
const res = await tenantApi.createTenant({
...form,
tenantName: form.tenantName.trim(),
tenantCode: form.tenantCode.trim().toLowerCase(),
contactName: form.contactName.trim(),
contactPhone: form.contactPhone.trim(),
adminUsername: form.adminUsername.trim(),
adminRealName: form.adminRealName.trim(),
adminPhone: form.adminPhone.trim()
})
createdName.value = form.tenantName.trim()
created.value = res.data
await load()
} finally {
submitting.value = false
}
}
async function copyPassword() {
if (!created.value) return
try {
await navigator.clipboard.writeText(created.value.adminInitialPassword)
Message.success('已复制到剪贴板')
} catch {
Message.warning('浏览器不允许自动复制,请手动选中后复制')
}
}
function closeCreated() {
created.value = null
visible.value = false
}
onMounted(load)
</script>
<style lang="scss" scoped>
.tenant {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__search {
width: 220px;
margin-left: auto;
}
&__name {
font-weight: 600;
}
&__id {
margin-top: 2px;
font-size: 11px;
color: var(--color-text-3);
}
&__pwd-box {
padding: 16px;
margin: 16px 0;
background: var(--color-fill-1);
border: 1px solid var(--color-border-2);
border-radius: var(--border-radius-large);
}
&__pwd-label {
margin-bottom: 8px;
font-size: 12px;
color: var(--color-text-3);
}
&__pwd-row {
display: flex;
gap: 10px;
align-items: center;
}
&__pwd {
flex: 1;
padding: 10px 14px;
font-family: Consolas, Menlo, monospace;
font-size: 19px;
font-weight: 700;
letter-spacing: 0.14em;
user-select: all;
background: #fff;
border: 1px solid var(--color-border-3);
border-radius: var(--border-radius-medium);
}
&__pwd-warn {
margin-top: 12px;
}
&__footer {
display: flex;
justify-content: flex-end;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid var(--color-border-2);
}
}
</style>