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
@@ -0,0 +1,71 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<template #title>
<a-space>
<icon-clock-circle />
<span>{{ menu?.title ?? '功能' }}</span>
</a-space>
</template>
<template #extra>
<a-tag color="orange">{{ milestone }}</a-tag>
</template>
<a-alert type="info" :show-icon="false">
<template #title>
<strong>这个页面还没做。</strong>
{{ menu?.note ?? '该功能尚未实现。' }}
</template>
</a-alert>
<p class="coming-soon__why">
左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它,
说明你的角色确实有这个权限 —— 只是界面还没排到。
当前已实现的页面:<strong>工作台、组织架构、租户管理</strong>(以及本页)。
</p>
</a-card>
</GiPageLayout>
</template>
<script setup lang="ts">
import { findMenu } from '@/config/xt-routes'
/**
* 「待建」页。
*
* 为什么不直接隐藏未实现的菜单:
* 1. 菜单本身就是要展示的东西(权限驱动的导航);
* 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息;
* 3. 一个页面服务所有占位路由,成本极低。
*/
defineOptions({ name: 'ComingSoon' })
const route = useRoute()
const menu = computed(() => findMenu((route.meta.menu as string) ?? ''))
/** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
const MILESTONES: Record<string, string> = {
task: 'M1', form: 'M1', material: 'M1',
roster: 'M2', join: 'M2',
cleanup: 'M3',
audit: 'M5',
export: 'M6',
user: 'M0 剩余', role: '二期', log: 'M0 剩余'
}
const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中')
</script>
<style lang="scss" scoped>
.coming-soon__why {
margin: 16px 0 0;
font-size: 13px;
line-height: 1.9;
color: var(--color-text-2);
strong {
color: var(--color-text-1);
}
}
</style>
+253
View File
@@ -0,0 +1,253 @@
<template>
<GiPageLayout>
<!-- 欢迎条 -->
<a-card :bordered="false" class="dash__welcome">
<div class="dash__hello">
<a-avatar :size="46" class="dash__avatar">{{ avatarText }}</a-avatar>
<div>
<h3 class="dash__name">{{ userStore.name || '—' }},欢迎回来</h3>
<p class="dash__meta">
{{ userStore.tenantName || '平台运营' }} ·
{{ roleLabel || '—' }}
</p>
</div>
<a-tag class="dash__scope" color="arcoblue">一期范围:学校收集 + 自主认定</a-tag>
</div>
</a-card>
<!-- 统计 -->
<a-row :gutter="14" class="dash__stats">
<a-col v-for="s in stats" :key="s.label" :xs="12" :sm="12" :md="6">
<a-card :bordered="false" class="dash__stat">
<div class="dash__stat-label">
<component :is="s.icon" />
<span>{{ s.label }}</span>
</div>
<div class="dash__stat-num" :class="s.tone">
{{ s.value }}<small v-if="s.unit">{{ s.unit }}</small>
</div>
<div class="dash__stat-foot">{{ s.foot }}</div>
</a-card>
</a-col>
</a-row>
<!-- 快捷入口 -->
<a-card :bordered="false">
<template #title>快捷入口</template>
<template #extra>
<span class="dash__hint">当前角色可访问的页面</span>
</template>
<a-space wrap>
<a-button v-for="m in shortcuts" :key="m.key" @click="router.push(m.path)">
<template #icon>
<component :is="m.icon" />
</template>
{{ m.title }}
</a-button>
</a-space>
</a-card>
<!-- 进度 -->
<a-card :bordered="false">
<template #title>开发进度</template>
<template #extra>
<span class="dash__hint">M0 后端已完成,界面建设中</span>
</template>
<a-alert type="success" :show-icon="false" class="dash__note">
<template #title>
<strong>M0 后端已通过验收(36/36)。</strong>
租户开通、组织架构、登录鉴权、跨租户隔离、权限菜单均已可用;
数据库由 Flyway 管理,26 张表(20 业务 + 5 标准 + 1 迁移记录)。
</template>
</a-alert>
<a-alert type="info" :show-icon="false" class="dash__note">
<template #title>
界面按里程碑推进:<strong>M1</strong> 任务与表单配置 → <strong>M2</strong> 名册与门禁 →
<strong>M3</strong> 填报主流程(改名 / 压缩)→ <strong>M5</strong> 审核 → <strong>M6</strong> 导出打包。
左侧标「待建」的菜单点进去能看到它属于哪个里程碑。
</template>
</a-alert>
</a-card>
</GiPageLayout>
</template>
<script setup lang="ts">
import { IconCheckCircle, IconFolder, IconIdcard, IconSafe, IconStamp } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org'
import * as tenantApi from '@/apis/xt/tenant'
import type { OrgTreeVO } from '@/apis/xt/org'
import { XT_MENUS, ROLE_NAMES } from '@/config/xt-routes'
import { useUserStore } from '@/stores'
/**
* 工作台。
*
* 取舍:**只显示真实能查到的数字**。
* 原型里的「异常待处理」这类统计需要 M1/M2 的任务与校验数据,
* 现在没有对应接口,所以不做假数据充数 —— 空着比编数字诚实。
*/
defineOptions({ name: 'Dashboard' })
const router = useRouter()
const userStore = useUserStore()
const orgCount = ref<number | null>(null)
const tenantCount = ref<number | null>(null)
const loading = ref(true)
const avatarText = computed(() => (userStore.name || '—').slice(0, 1))
const roleLabel = computed(() =>
userStore.roles.map((c) => ROLE_NAMES[c] ?? c).join(' / ')
)
const isPlatform = computed(() => userStore.menus?.includes('tenant') ?? false)
/** 已实现且当前角色可访问的页面 */
const shortcuts = computed(() =>
XT_MENUS.filter((m) => m.ready && userStore.menuKeys.includes(m.key))
)
const stats = computed(() => [
{
label: '组织节点',
icon: IconFolder,
value: loading.value ? '—' : (orgCount.value ?? '—'),
unit: '个',
foot: '学校 / 院系 / 班级',
tone: ''
},
{
label: '租户',
icon: IconStamp,
value: isPlatform.value ? (loading.value ? '—' : (tenantCount.value ?? '—')) : '—',
unit: isPlatform.value ? '个' : '',
foot: isPlatform.value ? '平台可见' : '仅平台运营可见',
tone: 'is-success'
},
{
label: '我的角色',
icon: IconSafe,
value: userStore.roles.length,
unit: '个',
foot: roleLabel.value || '—',
tone: ''
},
{
label: '可用权限',
icon: IconIdcard,
value: userStore.permissions.length,
unit: '项',
foot: '按角色由服务端下发',
tone: ''
}
])
function countNodes(nodes: OrgTreeVO[]): number {
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
}
onMounted(async () => {
try {
const res = await orgApi.getOrgTree()
orgCount.value = countNodes(res.data ?? [])
} catch {
orgCount.value = null
}
if (isPlatform.value) {
try {
const res = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 })
tenantCount.value = res.data?.total ?? null
} catch {
tenantCount.value = null
}
}
loading.value = false
})
</script>
<style lang="scss" scoped>
.dash {
&__welcome {
margin-bottom: 14px;
}
&__hello {
display: flex;
gap: 14px;
align-items: center;
}
&__avatar {
flex: none;
font-size: 18px;
background: rgb(var(--primary-6));
}
&__name {
margin: 0;
font-size: 17px;
font-weight: 600;
}
&__meta {
margin: 5px 0 0;
font-size: 12px;
color: var(--color-text-3);
}
&__scope {
margin-left: auto;
}
&__stats {
margin-bottom: 14px;
}
&__stat {
height: 100%;
}
&__stat-label {
display: flex;
gap: 6px;
align-items: center;
font-size: 12px;
color: var(--color-text-3);
}
&__stat-num {
margin-top: 8px;
font-size: 27px;
font-weight: 700;
line-height: 1;
color: var(--color-text-1);
small {
margin-left: 3px;
font-size: 13px;
font-weight: 500;
color: var(--color-text-3);
}
&.is-success {
color: rgb(var(--success-6));
}
}
&__stat-foot {
margin-top: 7px;
font-size: 11px;
color: var(--color-text-3);
}
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__note:not(:last-child) {
margin-bottom: 10px;
}
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<template>
<ErrorPage :code="403"></ErrorPage>
</template>
<script setup lang="ts">
import ErrorPage from './components/ErrorPage.vue'
defineOptions({ name: 'Error403' })
</script>
<style lang="scss" scoped></style>
+11
View File
@@ -0,0 +1,11 @@
<template>
<ErrorPage :code="404"></ErrorPage>
</template>
<script setup lang="ts">
import ErrorPage from './components/ErrorPage.vue'
defineOptions({ name: 'Error404' })
</script>
<style lang="scss" scoped></style>
+11
View File
@@ -0,0 +1,11 @@
<template>
<ErrorPage :code="500"></ErrorPage>
</template>
<script setup lang="ts">
import ErrorPage from './components/ErrorPage.vue'
defineOptions({ name: 'Error500' })
</script>
<style lang="scss" scoped></style>
@@ -0,0 +1,163 @@
<template>
<div class="error-page">
<section class="error__container">
<div class="error__img">
<component :is="IconMap[props.code]" class="error__icon"></component>
</div>
<div class="error__tip">
<div class="error__tip--a">抱歉!</div>
<div class="error__tip--b">当前页面不存在...</div>
<div class="error__tip--c">请检查您输入的网址是否正确,或点击下面的按钮返回首页</div>
<a-button type="primary" shape="round" size="large" @click="back">{{ countDownTime || '' }} 返回首页</a-button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import Icon403 from '@/components/icons/Icon403.vue'
import Icon404 from '@/components/icons/Icon404.vue'
import Icon500 from '@/components/icons/Icon500.vue'
interface Props {
code?: number
}
defineOptions({ name: 'ErrorPage' })
const props = withDefaults(defineProps<Props>(), {
code: 403
})
const router = useRouter()
const IconMap: Record<number, Component> = {
403: Icon403,
404: Icon404,
500: Icon500
}
const countDownTime = ref(5)
let timer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
onCountDownTime()
})
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer)
}
})
// 返回页面
const back = () => {
router.replace({ path: '/' })
}
// 倒计时
function onCountDownTime() {
timer = setInterval(() => {
if (countDownTime.value) {
countDownTime.value--
} else {
// back()
if (timer) {
clearInterval(timer)
timer = undefined
}
}
}, 1000)
}
</script>
<style lang="scss" scoped>
.error-page {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: var(--color-bg-1);
}
.error {
&__container {
display: flex;
flex-direction: column;
align-items: center;
}
&__img {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
overflow: hidden;
}
&__icon {
max-width: 90%;
height: 50vh;
}
&__tip {
display: flex;
flex-direction: column;
align-items: center;
&--a {
margin-bottom: 20px;
font-size: 32px;
font-weight: bold;
line-height: 40px;
color: rgb(var(--primary-6));
opacity: 0;
animation-name: slideUp;
animation-duration: 0.5s;
animation-fill-mode: forwards;
}
&--b {
margin-bottom: 10px;
font-size: 20px;
font-weight: bold;
line-height: 24px;
color: var(--color-text-1);
opacity: 0;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.1s;
animation-fill-mode: forwards;
}
&--c {
padding: 0 30px;
margin-bottom: 20px;
font-size: 13px;
line-height: 20px;
color: var(--color-text-2);
text-align: center;
opacity: 0;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.2s;
animation-fill-mode: forwards;
}
}
}
@keyframes slideUp {
0% {
opacity: 0;
transform: translateY(60px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
</style>
+286
View File
@@ -0,0 +1,286 @@
<template>
<div class="login-left">
<div class="login-left__inner">
<header class="login-left__header">
<div class="login-left__brand">
<div class="login-left__logo">
<img src="@/assets/images/logo.svg" alt="填报通" />
</div>
<div class="login-left__brand-text">
<span class="login-left__title">填报通</span>
</div>
</div>
</header>
<section class="login-left__hero">
<h1 class="login-left__heading">
报名材料 · 一站收集
<span class="login-left__heading-sub">职业技能等级认定材料收集中台</span>
</h1>
<p class="login-left__desc">
学生三分钟填完,老师一键过审 —— 系统按名册自动改名、把扫描件压到 1M 内,并生成可直接上报的汇总包。
</p>
</section>
<div class="login-left__illustration" aria-hidden="true">
<img class="login-left__illustration-img" src="@/assets/images/login-hero.svg"
alt="散乱的材料自动归集为规范包" />
</div>
<ul class="login-left__features">
<li v-for="item in features" :key="item.title" class="login-left__feature">
<span class="login-left__feature-icon">
<Icon :icon="item.icon" :width="20" :height="20" />
</span>
<span class="login-left__feature-text">
<strong>{{ item.title }}</strong>
<em>{{ item.subtitle }}</em>
</span>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
defineOptions({ name: 'LoginLeft' })
/**
* 三条卖点对应产品最硬的三个能力(数字都来自实测):
* · 规范文件名由名册生成,不靠学生手打 —— 归属判定靠证件号反查名册
* · 扫描件强制压到 1M 内 —— 实测整班 444MB → 22MB,压缩率 95%
* · 证件号加密存储 + 名单不下发前端 —— 门禁在服务端完成
* 图标名已核对 icon-park-outline 图标集确实存在(写错会渲染成空白且不报错)。
*/
const features = [
{ title: '自动改名', subtitle: '规范名由名册生成', icon: 'icon-park-outline:file-editing' },
{ title: '强制压缩', subtitle: '扫描件压到 1M 内', icon: 'icon-park-outline:compression' },
{ title: '隐私保护', subtitle: '证件号加密存储', icon: 'icon-park-outline:shield' }
] as const
</script>
<style lang="scss" scoped>
.login-left {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
// 两层叠加:
// ① 极淡的点阵网格 —— 给大片留白一点"纸张"的质感,同时暗示"表单/表格"
// ② 主色浅阶到白的斜向渐变 —— 承接渲染出来的头图
// 点阵用主色很低的不透明度,换成别的主题色时自动跟随,不需要改图。
background:
radial-gradient(circle at 1px 1px, rgb(var(--primary-6), 0.06) 1px, transparent 0) 0 0 / 22px 22px,
linear-gradient(165deg,
rgb(var(--primary-1)) 0%,
var(--color-bg-2) 42%,
var(--color-bg-1) 100%);
&::before {
position: absolute;
top: -120px;
right: -80px;
width: 360px;
height: 360px;
content: '';
background: radial-gradient(circle, rgb(var(--primary-6), 0.12) 0%, transparent 68%);
border-radius: 50%;
}
&::after {
position: absolute;
bottom: 8%;
left: -60px;
width: 240px;
height: 240px;
content: '';
background: radial-gradient(circle, rgb(var(--primary-6), 0.08) 0%, transparent 70%);
border-radius: 50%;
}
&__inner {
position: relative;
z-index: 1;
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: clamp(24px, 4vh, 48px) clamp(20px, 4vw, 56px) clamp(20px, 3vh, 40px);
overflow: auto;
}
&__header {
flex-shrink: 0;
margin-bottom: clamp(20px, 3vh, 36px);
}
&__brand {
display: flex;
gap: 14px;
align-items: center;
}
&__logo {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 6px;
background: var(--color-bg-1);
border: 1px solid var(--color-border-2);
border-radius: 12px;
box-shadow: 0 4px 16px rgb(var(--primary-6), 0.08);
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__brand-text {
display: flex;
flex-direction: column;
gap: 6px;
}
&__title {
font-size: clamp(18px, 2.5vw, 26px);
font-weight: 700;
line-height: 1.2;
color: var(--color-text-1);
letter-spacing: -0.02em;
}
&__hero {
flex-shrink: 0;
margin-bottom: clamp(12px, 2vh, 24px);
}
&__heading {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0 0 12px;
font-size: clamp(24px, 3vw, 38px);
font-weight: 700;
line-height: 1.2;
color: var(--color-text-1);
letter-spacing: -0.02em;
}
&__heading-sub {
font-size: clamp(16px, 2vw, 22px);
font-weight: 600;
color: rgb(var(--primary-6));
}
&__desc {
max-width: 480px;
margin: 0;
font-size: clamp(13px, 1.4vw, 15px);
line-height: 1.65;
color: var(--color-text-3);
}
&__illustration {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 160px;
margin: clamp(8px, 2vh, 24px) 0;
}
&__illustration-img {
width: min(100%, 520px);
max-height: min(42vh, 320px);
object-fit: contain;
filter: drop-shadow(0 12px 32px rgb(var(--primary-6), 0.1));
animation: login-float 6s ease-in-out infinite;
}
&__features {
display: grid;
flex-shrink: 0;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 0;
margin: 0;
list-style: none;
}
&__feature {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 14px 12px;
background: color-mix(in srgb, var(--color-bg-1) 72%, transparent);
border: 1px solid var(--color-border-2);
border-radius: 12px;
box-shadow: 0 2px 12px rgb(var(--primary-6), 0.05);
}
&__feature-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: rgb(var(--primary-6));
background: rgb(var(--primary-1));
border-radius: 10px;
}
&__feature-text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
strong {
font-size: 13px;
font-weight: 600;
line-height: 1.3;
color: var(--color-text-1);
}
em {
font-size: 11px;
font-style: normal;
line-height: 1.4;
color: var(--color-text-3);
}
}
}
@media (prefers-reduced-motion: reduce) {
.login-left__illustration-img {
animation: none;
}
}
@keyframes login-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@media (width <=1200px) {
.login-left__features {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,100 @@
<template>
<div class="login-palette">
<div class="login-palette__dots">
<div
v-for="(color, index) in COLORS"
:key="color"
class="login-palette__dot"
:style="{ 'background': color, '--index': index }"
@click="handleClick(color)"
/>
</div>
<div class="login-palette__btn">
<icon-palette :size="18" />
</div>
</div>
</template>
<script setup lang="ts">
import { useTheme } from '@/hooks'
defineOptions({ name: 'LoginPalette' })
const COLORS = ['#165DFF', '#18A058', '#007AFF', '#5856D6', '#536dfe', '#AF52DE', '#f4511e', '#6d4c41']
const { setThemeColor } = useTheme()
const handleClick = (color: string) => {
setThemeColor(color)
}
</script>
<style scoped lang="scss">
.login-palette {
position: relative;
display: flex;
align-items: center;
&:hover &__dots {
pointer-events: auto;
opacity: 1;
transform: translateX(0);
}
&:hover &__dot {
opacity: 1;
transform: translateX(0) scale(1);
}
&:hover &__btn :deep(.arco-icon) {
color: rgb(var(--primary-6));
}
&__dots {
position: absolute;
right: 0;
display: flex;
gap: 8px;
align-items: center;
padding: 8px 36px 8px 10px;
pointer-events: none;
border-radius: 100px;
opacity: 0;
backdrop-filter: blur(10px);
transform: translateX(10px);
transition: all 0.3s ease;
}
&__dot {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
cursor: pointer;
border-radius: 50%;
box-shadow: 0 2px 4px rgb(0 0 0 / 15%);
opacity: 0;
transform: translateX(20px) scale(0.8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: calc(var(--index) * 0.05s);
&:hover {
box-shadow: 0 4px 8px rgb(0 0 0 / 20%);
transform: translateX(0) scale(1.1);
}
}
&__btn {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
transition: all 0.3s;
}
}
</style>
+414
View File
@@ -0,0 +1,414 @@
<template>
<div class="login">
<a-row align="stretch" class="login__row">
<a-col :xs="0" :sm="0" :md="14" :lg="14" :xl="15" class="login__col login__col--brand">
<LoginLeft />
</a-col>
<a-col :xs="24" :sm="24" :md="10" :lg="10" :xl="9" class="login__col">
<div class="login__panel">
<div class="login__toolbar">
<LoginPalette />
<GiThemeBtn />
</div>
<div class="login__body">
<div class="login__content">
<header class="login__mobile-brand">
<div class="login__mobile-logo">
<img src="@/assets/images/logo.svg" alt="填报通" />
</div>
<div>
<h2 class="login__mobile-title">填报通</h2>
<p class="login__mobile-desc">报名材料收集系统</p>
</div>
</header>
<h3 class="login__title">欢迎回来</h3>
<p class="login__subtitle">请使用管理员分配的账号登录</p>
<a-form ref="formRef" class="login__form" size="large" :model="form" :rules="rules" layout="vertical"
:label-col-props="{ style: { marginBottom: '0' } }" @submit="onSubmit">
<a-form-item field="username" label="账号">
<a-input v-model="form.username" placeholder="请输入账号" allow-clear autocomplete="username"
:disabled="loading">
<template #prefix>
<icon-user :stroke-width="3" class="login__input-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item field="password" label="密码">
<a-input-password v-model="form.password" placeholder="请输入密码" allow-clear
autocomplete="current-password" :disabled="loading" @press-enter="login">
<template #prefix>
<icon-lock :stroke-width="3" class="login__input-icon" />
</template>
</a-input-password>
</a-form-item>
<!-- 账号在多个租户下重名时才出现(服务端返回 40012 + "多个单位") -->
<a-form-item v-if="needTenant" field="tenantCode" label="租户码">
<a-input v-model="form.tenantCode" placeholder="如 demo" allow-clear :disabled="loading"
@press-enter="login">
<template #prefix>
<icon-home :stroke-width="3" class="login__input-icon" />
</template>
</a-input>
<template #extra>
<span class="login__hint">该账号在多个单位下存在,请指明单位</span>
</template>
</a-form-item>
<!-- 连续失败 2 次后服务端才要求(返回 40001) -->
<a-form-item v-if="needCaptcha" field="captchaCode" label="图形验证码">
<div class="login__captcha">
<a-input v-model="form.captchaCode" placeholder="请输入图中字符" allow-clear :max-length="6"
:disabled="loading" @press-enter="login">
<template #prefix>
<icon-safe :stroke-width="3" class="login__input-icon" />
</template>
</a-input>
<button class="login__captcha-img" type="button" title="点击刷新" @click="refreshCaptcha">
<img v-if="captchaImage" :src="captchaImage" alt="验证码" />
<span v-else>加载中</span>
</button>
</div>
</a-form-item>
<!-- 服务端返回的错误统一显示在这里,而不是只用 toast(toast 会消失,用户看不到原因) -->
<a-alert v-if="errText" class="login__alert" type="error">
{{ errText }}
</a-alert>
<a-form-item hide-label>
<a-space direction="vertical" fill class="w-full">
<a-button class="login__btn" type="primary" size="large" long html-type="submit" :loading="loading"
:disabled="loading">
登 录
</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
</div>
<footer class="login__footer">
<span>忘记密码请联系本单位管理员重置</span>
</footer>
</div>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import { Message } from '@arco-design/web-vue'
import { encryptPassword, fetchPublicKey, resetPublicKey } from '@/utils/rsa'
import { getCaptcha } from '@/apis/xt'
import { useLoading } from '@/hooks'
import { useTabsStore, useUserStore } from '@/stores'
import LoginLeft from './LoginLeft.vue'
import LoginPalette from './LoginPalette.vue'
defineOptions({ name: 'Login' })
/**
* 登录页。
*
* 三个「按需出现」的机制,都是**服务端提示驱动**的,不靠前端猜:
* 1. 图形验证码 —— 连续失败 2 次后服务端开始要求(返回 40001)
* 2. 租户码 —— 账号在多个租户下重名时服务端要求(返回 40012 + "多个单位")
* 3. 锁定倒计时 —— 连错 5 次后返回 40013
*
* 密码用 RSA 公钥加密后传输,目的不是替代 HTTPS,
* 而是防止网关/APM/链路追踪把明文密码记进日志。
*/
const router = useRouter()
const userStore = useUserStore()
const tabsStore = useTabsStore()
const year = new Date().getFullYear()
const form = reactive({
username: '',
password: '',
tenantCode: '',
captchaCode: ''
})
const rules: FormInstance['rules'] = {
username: [
{ required: true, message: '请输入账号' },
{ minLength: 2, message: '账号至少 2 个字符' }
],
password: [
{ required: true, message: '请输入密码' },
{ minLength: 6, message: '密码至少 6 位' }
]
}
const formRef = useTemplateRef('formRef')
const { loading, setLoading } = useLoading()
const needCaptcha = ref(false)
const needTenant = ref(false)
const errText = ref('')
const captchaKey = ref('')
const captchaImage = ref('')
/** 拉验证码:key 必须与提交时一起回传,服务端校验一次即失效 */
async function refreshCaptcha() {
try {
const res = await getCaptcha()
captchaKey.value = res.data.captchaKey
captchaImage.value = res.data.image
form.captchaCode = ''
} catch {
errText.value = '验证码加载失败,请点击图片重试'
}
}
function showCaptcha() {
if (!needCaptcha.value) {
needCaptcha.value = true
refreshCaptcha()
}
}
async function login() {
const valid = await formRef.value?.validate()
if (valid) return
errText.value = ''
setLoading(true)
try {
const cipher = await encryptPassword(form.password)
await userStore.login({
username: form.username.trim(),
password: cipher,
tenantCode: needTenant.value ? (form.tenantCode.trim() || undefined) : undefined,
captchaKey: needCaptcha.value ? captchaKey.value : undefined,
captchaCode: needCaptcha.value ? (form.captchaCode.trim() || undefined) : undefined
})
tabsStore.reset()
const { redirect, ...othersQuery } = router.currentRoute.value.query
await router.push({
path: (redirect as string) || '/',
query: { ...othersQuery }
})
Message.success('登录成功')
} catch (error) {
handleFailure(error as Error & { bizCode?: number })
} finally {
setLoading(false)
}
}
/** 把错误码翻译成「下一步该做什么」,而不是只显示一句话 */
function handleFailure(e: Error & { bizCode?: number }) {
errText.value = e.message || '登录失败,请重试'
switch (e.bizCode) {
// 40001:本场景下就是「需要验证码」
case 40001:
showCaptcha()
refreshCaptcha()
break
// 40012:账号在多个租户下重名时,服务端拒绝猜测并要求指明单位
case 40012:
if (e.message.includes('多个单位')) {
needTenant.value = true
} else {
showCaptcha()
}
break
// 40101:token 失效(这里的场景是登录会话过期)
case 40101:
resetPublicKey()
break
default:
break
}
}
function onSubmit(data: { values: Record<string, unknown>, errors: Record<string, unknown> | undefined }) {
if (data.errors) return
login()
}
// 预取公钥:让点击登录时少一次往返,失败也无所谓(真正加密时会再取)
onMounted(() => {
fetchPublicKey().catch(() => undefined)
})
</script>
<style lang="scss" scoped>
.login {
height: 100%;
background-color: var(--color-bg-5);
&__row {
width: 100%;
height: 100%;
}
&__col {
height: 100%;
&--brand {
min-height: 0;
}
}
&__panel {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
padding: 24px 20px 16px;
background: var(--color-bg-1);
}
&__body {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
width: 100%;
min-height: 0;
}
&__toolbar {
position: absolute;
top: 20px;
right: clamp(16px, 3vw, 30px);
z-index: 10;
display: flex;
gap: 12px;
align-items: center;
}
&__content {
box-sizing: border-box;
width: 100%;
max-width: 400px;
}
&__mobile-brand {
display: none;
gap: 12px;
align-items: center;
margin-bottom: 28px;
}
&__mobile-logo {
width: 40px;
height: 40px;
overflow: hidden;
border-radius: var(--border-radius-medium);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__mobile-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
&__mobile-desc {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-text-3);
}
&__title {
margin: 0;
font-size: 26px;
font-weight: 600;
}
&__subtitle {
margin: 8px 0 28px;
font-size: 13px;
color: var(--color-text-3);
}
&__form {
:deep(.arco-form-item) {
margin-bottom: 18px;
}
}
&__input-icon {
color: var(--color-text-3);
}
&__hint {
font-size: 12px;
color: rgb(var(--warning-6));
}
&__alert {
margin-bottom: 16px;
}
&__captcha {
display: flex;
gap: 10px;
width: 100%;
align-items: center;
}
&__captcha-img {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 116px;
height: 40px;
padding: 0;
overflow: hidden;
font-size: 12px;
color: var(--color-text-3);
cursor: pointer;
background: #fff;
border: 1px solid var(--color-border-3);
border-radius: var(--border-radius-medium);
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__btn {
height: 44px;
letter-spacing: 0.12em;
}
&__footer {
padding-top: 16px;
font-size: 12px;
color: var(--color-text-3);
text-align: center;
border-top: 1px solid var(--color-border-2);
}
@media (max-width: 768px) {
&__mobile-brand {
display: flex;
}
}
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<template>
<a-modal v-model:visible="visible" :title="title" :width="480" :mask-closable="false" @ok="confirm" @cancel="close">
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-form-item v-if="!isEdit" label="上级组织">
<a-input :model-value="parentName" disabled />
<template #extra>
<span class="org-form__hint">
新增后不支持直接拖拽调整层级,请在建的时候就选对上级。
</span>
</template>
</a-form-item>
<a-form-item field="orgName" label="组织名称">
<a-input v-model="form.orgName" placeholder="如 24中技数控1班" :max-length="128" allow-clear />
</a-form-item>
<a-form-item field="orgType" label="组织类型">
<a-select v-model="form.orgType" placeholder="请选择">
<a-option v-for="(name, code) in ORG_TYPE_NAMES" :key="code" :value="Number(code)">
{{ name }}
</a-option>
</a-select>
<template #extra>
<span class="org-form__hint">
类型必须比上级更低一级:学校 &gt; 院系/专业 &gt; 班级。
</span>
</template>
</a-form-item>
<a-form-item field="orgCode" label="组织编码">
<a-input v-model="form.orgCode" placeholder="班号或简称,租户内唯一" :max-length="64" allow-clear />
</a-form-item>
<a-form-item field="sortNo" label="排序号">
<a-input-number v-model="form.sortNo" :min="0" :max="9999" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import { Message } from '@arco-design/web-vue'
import * as orgApi from '@/apis/xt/org'
import type { OrgTreeVO, OrgUpsertReq } from '@/apis/xt/org'
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
/**
* 组织新增 / 编辑弹窗。
*
* 三条服务端规则在界面上提前提示,减少"提交了才知道不行":
* 1. 组织编码租户内唯一
* 2. 组织类型必须比上级更低一级
* 3. 修改时不提供改上级(避免误操作把整棵子树搬走)
*/
defineOptions({ name: 'OrgFormModal' })
const visible = ref(false)
const submitting = ref(false)
const detailId = ref<number | null>(null)
const parentId = ref<number | null>(null)
const parentName = ref('(顶级)')
const formRef = useTemplateRef('formRef')
const isEdit = computed(() => !!detailId.value)
const title = computed(() => (isEdit.value ? '修改组织' : '新增组织'))
const getInitForm = (): OrgUpsertReq => ({
parentId: null,
orgName: '',
orgType: 1,
orgCode: '',
sortNo: 0
})
const form = reactive<OrgUpsertReq>(getInitForm())
const rules: FormInstance['rules'] = {
orgName: [{ required: true, message: '请填写组织名称' }],
orgType: [{ required: true, message: '请选择组织类型' }],
orgCode: [{ required: true, message: '请填写组织编码' }]
}
/** 由父节点推断下一级类型,减少用户选错 */
function nextTypeOf(parent: OrgTreeVO | null): number {
if (!parent) return 1
return Math.min((parent.orgType ?? 1) + 1, 4)
}
/** 新增 */
function add(parent: OrgTreeVO | null) {
detailId.value = null
parentId.value = parent?.id ?? null
parentName.value = parent?.orgName ?? '(顶级)'
Object.assign(form, getInitForm())
form.parentId = parent?.id ?? null
form.orgType = nextTypeOf(parent)
visible.value = true
}
/** 编辑 */
function edit(row: OrgTreeVO) {
detailId.value = row.id
parentId.value = null
parentName.value = '(不修改)'
Object.assign(form, getInitForm())
form.orgName = row.orgName
form.orgType = row.orgType
form.orgCode = row.orgCode ?? ''
form.sortNo = row.sortNo ?? 0
visible.value = true
}
function close() {
visible.value = false
}
async function confirm() {
const valid = await formRef.value?.validate()
if (valid) return
submitting.value = true
try {
if (isEdit.value) {
// 修改时不传 parentId:改层级由服务端重算子树路径,风险高,这里不开放
await orgApi.updateOrg(detailId.value!, {
orgName: form.orgName.trim(),
orgType: form.orgType,
orgCode: form.orgCode.trim(),
sortNo: form.sortNo ?? 0
})
Message.success('已保存')
} else {
await orgApi.addOrg({
parentId: form.parentId ?? null,
orgName: form.orgName.trim(),
orgType: form.orgType,
orgCode: form.orgCode.trim(),
sortNo: form.sortNo ?? 0
})
Message.success('已新增')
}
visible.value = false
emit('save-success')
} finally {
submitting.value = false
}
}
const emit = defineEmits<{ 'save-success': [] }>()
defineExpose({ add, edit })
</script>
<style lang="scss" scoped>
.org-form__hint {
font-size: 12px;
line-height: 1.7;
color: var(--color-text-3);
}
</style>
+270
View File
@@ -0,0 +1,270 @@
<template>
<GiPageLayout>
<!-- 左:组织树 -->
<template #left>
<a-card :bordered="false" class="org__tree-card">
<template #title>
<a-space>
<IconFolder />
<span>组织架构</span>
</a-space>
</template>
<template #extra>
<span class="org__hint">{{ totalNodes }} 个节点</span>
</template>
<a-input-search v-model="keyword" placeholder="搜索名称或编码" allow-clear class="org__search" />
<a-spin v-if="loading" class="org__loading" />
<a-empty v-else-if="!tree.length" description="还没有组织">
<a-button v-if="canManage" type="primary" size="small" @click="onAdd(null)">
新增顶级组织
</a-button>
</a-empty>
<a-tree v-else v-model:selected-keys="selectedKeys" :data="treeData" :field-names="{ key: 'id', title: 'orgName', children: 'children' }"
block-node :default-expand-all @select="onSelect">
<template #title="node">
<span class="org__node">
<span class="org__node-name">{{ node.orgName }}</span>
<a-tag size="small" class="org__node-type">{{ ORG_TYPE_NAMES[node.orgType] ?? '—' }}</a-tag>
<span v-if="node.studentCount" class="org__node-count">{{ node.studentCount }} 人</span>
</span>
</template>
</a-tree>
<template #footer>
<a-button v-if="canManage" long size="small" @click="onAdd(null)">
<template #icon><IconPlus /></template>
新增顶级组织
</a-button>
</template>
</a-card>
</template>
<!-- 右:详情 -->
<a-card v-if="!selected" :bordered="false">
<a-empty description="从左侧选择一个组织" />
</a-card>
<template v-else>
<a-card :bordered="false">
<template #title>{{ selected.orgName }}</template>
<template #extra>
<a-tag>{{ ORG_TYPE_NAMES[selected.orgType] ?? '未知' }}</a-tag>
</template>
<a-descriptions :column="2" size="large">
<a-descriptions-item label="组织编码">{{ selected.orgCode ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="负责人">{{ selected.leaderName ?? '未指定' }}</a-descriptions-item>
<a-descriptions-item label="排序号">{{ selected.sortNo }}</a-descriptions-item>
<a-descriptions-item label="学生数">{{ selected.studentCount ?? 0 }} 人</a-descriptions-item>
<a-descriptions-item label="下级组织">{{ (selected.children ?? []).length }} 个</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="selected.status === 1 ? 'green' : 'gray'">
{{ selected.status === 1 ? '正常' : '停用' }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-space v-if="canManage" class="org__acts">
<a-button type="primary" size="small" @click="onAdd(selected)">
<template #icon><IconPlus /></template>
新增下级
</a-button>
<a-button size="small" @click="onEdit(selected)">
<template #icon><IconEdit /></template>
修改
</a-button>
<a-popconfirm content="删除后不可恢复,确认删除?" type="warning" @ok="onDelete(selected)">
<a-button size="small" status="danger">
<template #icon><IconDelete /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</a-card>
<a-card v-if="(selected.children ?? []).length" :bordered="false">
<template #title>下级组织</template>
<template #extra>
<span class="org__hint">{{ (selected.children ?? []).length }} 个</span>
</template>
<a-table :data="selected.children" :pagination="false" row-key="id" size="small">
<template #columns>
<a-table-column title="名称" data-index="orgName" />
<a-table-column title="类型" :width="110">
<template #cell="{ record }">
<a-tag size="small">{{ ORG_TYPE_NAMES[record.orgType] ?? '—' }}</a-tag>
</template>
</a-table-column>
<a-table-column title="编码" data-index="orgCode" :width="130" />
<a-table-column title="学生数" data-index="studentCount" :width="90" />
<a-table-column title="操作" :width="80">
<template #cell="{ record }">
<a-link @click="onSelect([record.id], { node: record })">查看</a-link>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
</template>
<OrgFormModal ref="formModalRef" @save-success="load" />
</GiPageLayout>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue'
import { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org'
import type { OrgTreeVO } from '@/apis/xt/org'
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
import { useUserStore } from '@/stores'
import OrgFormModal from './OrgFormModal.vue'
/**
* 组织架构。
*
* 形态:左树右详情,是管理后台最常规的主从布局。
* 服务端的约束(编码唯一、类型层级、有下级不可删)在界面上提前提示,
* 但不做前端拦截 —— 判定权威在服务端,前端只负责减少无效提交。
*/
defineOptions({ name: 'Org' })
const userStore = useUserStore()
const loading = ref(false)
const tree = ref<OrgTreeVO[]>([])
const selected = ref<OrgTreeVO | null>(null)
const selectedKeys = ref<(string | number)[]>([])
const keyword = ref('')
const formModalRef = useTemplateRef('formModalRef')
const canManage = computed(() => userStore.permissions.includes('org:manage'))
const totalNodes = computed(() => countNodes(tree.value))
function countNodes(nodes: OrgTreeVO[]): number {
return nodes.reduce((s, n) => s + 1 + countNodes(n.children ?? []), 0)
}
/** 按关键字过滤(命中则保留其祖先链) */
const treeData = computed(() => {
const k = keyword.value.trim().toLowerCase()
if (!k) return tree.value
const walk = (nodes: OrgTreeVO[]): OrgTreeVO[] =>
nodes
.map((n) => {
const children = walk(n.children ?? [])
const hit = `${n.orgName} ${n.orgCode ?? ''}`.toLowerCase().includes(k)
return hit || children.length ? { ...n, children } : null
})
.filter(Boolean) as OrgTreeVO[]
return walk(tree.value)
})
function findById(nodes: OrgTreeVO[], id: number): OrgTreeVO | null {
for (const n of nodes) {
if (n.id === id) return n
const hit = findById(n.children ?? [], id)
if (hit) return hit
}
return null
}
async function load() {
loading.value = true
try {
const res = await orgApi.getOrgTree({ withCount: true })
tree.value = res.data ?? []
// 刷新后保持选中
if (selected.value) {
selected.value = findById(tree.value, selected.value.id)
}
} finally {
loading.value = false
}
}
function onSelect(keys: (string | number)[], data?: { node: OrgTreeVO }) {
const id = Number(keys[0] ?? '')
if (!id) {
selected.value = null
return
}
selected.value = data?.node?.id === id ? data.node : findById(tree.value, id)
}
function onAdd(parent: OrgTreeVO | null) {
formModalRef.value?.add(parent)
}
function onEdit(row: OrgTreeVO) {
formModalRef.value?.edit(row)
}
async function onDelete(row: OrgTreeVO) {
await orgApi.deleteOrg(row.id)
Message.success('已删除')
if (selected.value?.id === row.id) {
selected.value = null
selectedKeys.value = []
}
await load()
}
onMounted(load)
</script>
<style lang="scss" scoped>
.org {
&__tree-card {
height: 100%;
}
&__search {
margin-bottom: 10px;
}
&__loading {
display: block;
padding: 40px 0;
text-align: center;
}
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__node {
display: inline-flex;
gap: 6px;
align-items: center;
}
&__node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__node-type {
flex: none;
transform: scale(0.88);
}
&__node-count {
flex: none;
font-size: 11px;
color: var(--color-text-3);
}
&__acts {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--color-border-2);
}
}
</style>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div></div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const { params, query } = route
const { path } = params
router.replace({ path: `/${path}`, query })
</script>
+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>