Files
xiaotutongxue/frontend-pc/src/views/tenant/index.vue
T
lovife 87ece5a971 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 班级),供界面演示
2026-09-28 20:49:55 +08:00

395 lines
13 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.
<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>