87ece5a971
前端此前只有本地实现、从未入库,本次首次纳入版本管理。
框架切换
· 以 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 班级),供界面演示
395 lines
13 KiB
Vue
395 lines
13 KiB
Vue
<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>
|