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,291 @@
<template>
<div class="gi-table-setting" :class="{ 'gi-table-setting--fullscreen': isFullscreen }">
<a-row justify="space-between" align="center" class="g-row-tool">
<a-space wrap>
<slot name="custom-title">
<div class="gi-table-setting__title">{{ title }}</div>
</slot>
</a-space>
<a-space wrap>
<slot name="custom-extra" />
<a-tooltip content="导出">
<a-button class="gi-table-setting__btn" type="text" size="mini" @click="emit('export')">
<template #icon><icon-download :size="18" /></template>
</a-button>
</a-tooltip>
<a-tooltip content="斑马纹">
<a-button class="gi-table-setting__btn" type="text" size="mini" @click="stripe = !stripe">
<template #icon><icon-brush :size="18" /></template>
</a-button>
</a-tooltip>
<a-tooltip content="刷新">
<a-button class="gi-table-setting__btn" type="text" size="mini" @click="emit('refresh')">
<template #icon><icon-refresh :size="18" /></template>
</a-button>
</a-tooltip>
<a-tooltip :content="isFullscreen ? '退出全屏' : '全屏'">
<a-button class="gi-table-setting__btn" type="text" size="mini" @click="toggleFullscreen">
<template #icon>
<icon-fullscreen v-if="!isFullscreen" :size="18" />
<icon-fullscreen-exit v-else :size="18" />
</template>
</a-button>
</a-tooltip>
<a-tooltip content="显示边框">
<a-button class="gi-table-setting__btn" type="text" size="mini" @click="toggleBorder">
<template #icon><icon-apps :size="18" /></template>
</a-button>
</a-tooltip>
<a-dropdown @select="handleSizeChange">
<a-tooltip content="表格尺寸">
<a-button class="gi-table-setting__btn" type="text" size="mini">
<template #icon>
<icon-font-colors :size="18" />
</template>
</a-button>
</a-tooltip>
<template #content>
<a-doption v-for="item in TABLE_SIZE_OPTIONS" :key="item.value" :value="item.value"
:active="item.value === size">
{{ item.label }}
</a-doption>
</template>
</a-dropdown>
<a-popover v-if="showSettingColumnBtn" trigger="click" position="br"
:content-style="{ minWidth: '160px', padding: '6px 8px 10px' }">
<a-button class="gi-table-setting__btn" type="text" size="mini">
<template #icon><icon-settings :size="18" /></template>
</a-button>
<template #content>
<div class="gi-table-setting__draggable">
<VueDraggable :model-value="currentSettingColumns" :animation="150"
@update:model-value="onSettingColumnsUpdate">
<div v-for="item in currentSettingColumns" :key="item.key" class="gi-table-setting__draggable-item">
<div class="gi-table-setting__draggable-item-move">
<icon-drag-dot-vertical />
</div>
<a-checkbox v-model:model-value="item.show" :disabled="item.disabled">
{{ item.title }}
</a-checkbox>
<div class="gi-table-setting__draggable-item-fixed">
<span class="gi-table-setting__pin-btn" :class="{ 'is-active': item.fixedLeft }"
@click.stop="toggleFixedLeft(item.key)">
<icon-pushpin />
</span>
<span class="gi-table-setting__pin-btn gi-table-setting__pin-btn--right"
:class="{ 'is-active': item.fixedRight }" @click.stop="toggleFixedRight(item.key)">
<icon-pushpin />
</span>
</div>
</div>
</VueDraggable>
</div>
<a-divider :margin="6" />
<a-row justify="center">
<a-button type="primary" size="mini" long @click="resetSettingColumns">
<template #icon><icon-refresh /></template>
<template #default>重置</template>
</a-button>
</a-row>
</template>
</a-popover>
</a-space>
</a-row>
<div class="gi-table-setting__body">
<slot :setting-columns="settingColumns" :is-fullscreen="isFullscreen" :table-props="tableProps" />
</div>
</div>
</template>
<script setup lang="ts">
import type { DropdownInstance, TableColumnData, TableInstance } from '@arco-design/web-vue'
import type { GiTableSettingProps, TableSettingColumnListItem } from './type'
import { computed, ref } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { useTableColumnSetting } from './useTableColumnSetting'
defineOptions({ name: 'GiTableSetting' })
const props = withDefaults(defineProps<GiTableSettingProps>(), {
title: '',
columns: () => [],
disabledColumnKeys: () => []
})
const emit = defineEmits<{
refresh: []
export: []
}>()
defineSlots<{
'custom-title'?: () => unknown
'custom-extra'?: () => unknown
'default': (props: {
settingColumns: TableColumnData[]
isFullscreen: boolean
tableProps: {
stripe: boolean
bordered: { cell: boolean }
size: TableInstance['size']
}
}) => unknown
}>()
const stripe = ref(false)
const size = ref<TableInstance['size']>('medium')
const isBordered = ref(true)
const isFullscreen = ref(false)
const TABLE_SIZE_OPTIONS = [
{ label: '迷你', value: 'mini' },
{ label: '小型', value: 'small' },
{ label: '中等', value: 'medium' },
{ label: '大型', value: 'large' }
] as const
const tableProps = computed(() => ({
stripe: stripe.value,
bordered: { cell: isBordered.value },
size: size.value
}))
const {
showSettingColumnBtn,
currentSettingColumns,
settingColumnList,
settingColumns,
resetSettingColumns,
toggleFixedLeft,
toggleFixedRight
} = useTableColumnSetting({
columns: () => props.columns,
disabledColumnKeys: () => props.disabledColumnKeys
})
function onSettingColumnsUpdate(val: TableSettingColumnListItem[]) {
settingColumnList.value = val
}
const handleSizeChange: DropdownInstance['onSelect'] = (value) => {
if (value) {
size.value = value as TableInstance['size']
}
}
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
}
function toggleBorder() {
isBordered.value = !isBordered.value
}
</script>
<style lang="scss" scoped>
.gi-table-setting {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: var(--color-bg-1);
&--fullscreen {
position: fixed;
inset: 0;
z-index: 1001;
box-sizing: border-box;
padding: var(--padding);
}
&__btn {
color: var(--color-text-1);
}
&__title {
font-size: 18px;
font-weight: 500;
line-height: 1.5;
color: var(--color-text-1);
}
&__body {
flex: 1;
min-height: 0;
overflow: hidden;
}
&__draggable {
box-sizing: border-box;
max-height: 250px;
padding: 1px 0;
overflow: hidden auto;
}
&__draggable-item {
display: flex;
align-items: center;
cursor: pointer;
&:hover {
background-color: var(--color-fill-2);
}
&-move {
padding: 0 2px;
cursor: move;
}
&-fixed {
display: flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
margin-left: auto;
}
:deep(.arco-checkbox) {
flex: 1;
min-width: 0;
font-size: 12px;
.arco-checkbox-icon {
width: 14px;
height: 14px;
}
}
}
&__pin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
font-size: 14px;
color: var(--color-text-3);
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-text-2);
}
&.is-active {
color: rgb(var(--primary-6));
}
&--right {
transform: scaleX(-1);
}
}
}
</style>
@@ -0,0 +1,5 @@
import TableSetting from './GiTableSetting.vue'
export type * from './type'
export type TableSettingInstance = InstanceType<typeof TableSetting>
export { TableSetting as GiTableSetting }
@@ -0,0 +1,22 @@
import type { TableInstance } from '@arco-design/web-vue'
export interface GiTableSettingProps {
/** 工具栏标题 */
title?: string
/** 列配置(用于列设置) */
columns?: TableInstance['columns']
/** 禁止控制显示隐藏的列 key */
disabledColumnKeys?: string[]
}
/** 列设置项类型 */
export interface TableSettingColumnListItem {
title: string
key: string
show: boolean
disabled: boolean
/** 固定左侧 */
fixedLeft?: boolean
/** 固定右侧 */
fixedRight?: boolean
}
@@ -0,0 +1,141 @@
import type { TableColumnData, TableInstance } from '@arco-design/web-vue'
import type { MaybeRefOrGetter } from 'vue'
import type { TableSettingColumnListItem } from './type'
import { computed, ref, toValue, watch } from 'vue'
export interface UseTableColumnSettingOptions {
columns: MaybeRefOrGetter<TableInstance['columns'] | undefined>
disabledColumnKeys?: MaybeRefOrGetter<string[]>
}
/** 获取列的 key */
export function getTableColumnKey(column: TableColumnData, index?: number) {
if (column.dataIndex) return String(column.dataIndex)
if (typeof column.title === 'string' && column.title) return column.title
return `__column_${index ?? Date.now()}__`
}
/** 表格列设置:显隐、排序、固定 */
export function useTableColumnSetting(options: UseTableColumnSettingOptions) {
const showSettingColumnBtn = computed(() => {
const columns = Array.isArray(toValue(options.columns)) ? toValue(options.columns) : undefined
return Boolean(columns?.length)
})
const initialSettingColumns = computed<TableSettingColumnListItem[]>(() => {
const raw = toValue(options.columns)
if (!raw) return []
const columns = Array.isArray(raw) ? raw : []
return columns.map((column, index) => {
const key = getTableColumnKey(column, index)
const fixed = column.fixed
const disabledKeys = toValue(options.disabledColumnKeys) ?? []
return {
key,
title: typeof column.title === 'string' ? column.title : '',
show: true,
disabled: disabledKeys.includes(key),
fixedLeft: fixed === 'left',
fixedRight: fixed === 'right'
}
})
})
const settingColumnList = ref<TableSettingColumnListItem[]>([])
const isColumnStructureMatch = (
userColumns: TableSettingColumnListItem[],
initialColumns: TableSettingColumnListItem[]
): boolean => {
if (userColumns.length === 0 || userColumns.length !== initialColumns.length) return false
const initialKeys = new Set(initialColumns.map((item) => item.key))
const userKeys = new Set(userColumns.map((item) => item.key))
return initialKeys.size === userKeys.size && [...initialKeys].every((key) => userKeys.has(key))
}
const columnMap = computed(() => {
const raw = toValue(options.columns)
if (!raw) return new Map<string, TableColumnData>()
const columns = Array.isArray(raw) ? raw : []
return new Map(columns.map((col, index) => [getTableColumnKey(col, index), col]))
})
const currentSettingColumns = computed(() => {
const isValid = isColumnStructureMatch(settingColumnList.value, initialSettingColumns.value)
return isValid ? settingColumnList.value : initialSettingColumns.value
})
const resetSettingColumns = () => {
settingColumnList.value = [...initialSettingColumns.value]
}
const ensureSettingColumnList = () => {
if (settingColumnList.value.length === 0 && initialSettingColumns.value.length > 0) {
settingColumnList.value = initialSettingColumns.value.map((item) => ({ ...item }))
}
}
const toggleFixedLeft = (key: string) => {
ensureSettingColumnList()
settingColumnList.value = settingColumnList.value.map((item) =>
item.key === key
? { ...item, fixedLeft: !item.fixedLeft, fixedRight: false }
: item
)
}
const toggleFixedRight = (key: string) => {
ensureSettingColumnList()
settingColumnList.value = settingColumnList.value.map((item) =>
item.key === key
? { ...item, fixedRight: !item.fixedRight, fixedLeft: false }
: item
)
}
watch(
() => toValue(options.columns),
() => {
if (!isColumnStructureMatch(settingColumnList.value, initialSettingColumns.value)) {
resetSettingColumns()
}
},
{ deep: true }
)
const settingColumns = computed(() => {
const raw = toValue(options.columns)
if (!raw) return []
const shown = currentSettingColumns.value.filter((item) => item.show)
const leftFixed: typeof shown = []
const noFixed: typeof shown = []
const rightFixed: typeof shown = []
for (const item of shown) {
if (item.fixedLeft) leftFixed.push(item)
else if (item.fixedRight) rightFixed.push(item)
else noFixed.push(item)
}
const ordered = [...leftFixed, ...noFixed, ...rightFixed]
return ordered
.map((item) => {
const col = columnMap.value.get(item.key)
if (!col) return null
const fixed = item.fixedRight ? 'right' : item.fixedLeft ? 'left' : undefined
return { ...col, fixed }
})
.filter(Boolean) as TableColumnData[]
})
return {
showSettingColumnBtn,
currentSettingColumns,
settingColumnList,
settingColumns,
resetSettingColumns,
toggleFixedLeft,
toggleFixedRight
}
}