Files
xiaotutongxue/frontend-pc/src/components/GiForm/GiForm.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

274 lines
9.2 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.
<!--
@file GiForm 组件
@description 动态表单组件,支持多种表单项类型、栅格布局、搜索模式等功能
-->
<template>
<a-form ref="formRef" v-bind="formProps" :model="modelValue">
<a-grid class="w-full" :col-gap="8" v-bind="props.gridProps" :collapsed="collapsed && search">
<!-- 表单项列表 -->
<template v-for="item in columns" :key="item.field">
<a-grid-item v-if="!isHide(item)" v-bind="item.gridItemProps || props.gridItemProps"
:span="item.span || item.gridItemProps?.span || props.gridItemProps?.span">
<a-form-item v-bind="item.formItemProps" :field="item.field" :rules="getFormItemRules(item)"
:disabled="isDisabled(item)">
<!-- 表单项标签 -->
<template #label>
<component :is="item.labelRender" v-if="item.labelRender"></component>
<template v-else>{{ item.label }}</template>
</template>
<!-- 表单项内容 -->
<slot v-if="getStringType(item.type) === 'slot'" :name="item.field"></slot>
<slot v-else-if="getStringType(item.type) !== 'group-title'" :name="item.field"
v-bind="{ disabled: isDisabled(item) }">
<component :is="(COMP_MAP[getStringType(item.type)] || item.type)" v-bind="getComponentBindProps(item)"
:model-value="modelValue[getModelField(item)]"
@update:model-value="updateValue($event, getModelField(item))">
<!-- 组件插槽 -->
<template v-for="(slotValue, slotKey) in item?.slots" :key="slotKey" #[slotKey]="scope">
<template v-if="typeof slotValue === 'string'">{{ slotValue }}</template>
<template v-else-if="slotValue">
<component :is="slotValue(scope)"></component>
</template>
</template>
</component>
</slot>
<!-- 分组标题 -->
<slot v-else :name="item.field">
<a-alert>{{ item.label }}</a-alert>
</slot>
<!-- 表单项插槽 -->
<template v-for="(slotValue, slotKey) in item?.formItemSlots" :key="slotKey" #[slotKey]>
<template v-if="typeof slotValue === 'string'">{{ slotValue }}</template>
<component :is="slotValue" v-else></component>
</template>
</a-form-item>
</a-grid-item>
</template>
<!-- 搜索按钮组 -->
<a-grid-item v-if="props.search" :key="!props.suffix ? String(collapsed) : undefined" v-bind="props.gridItemProps"
:span="props.gridItemProps?.span" :suffix="props.search && (props.suffix || (!props.suffix && collapsed))">
<a-space wrap style="margin-bottom: 8px">
<slot name="suffix">
<a-button type="primary" @click="emit('search')">
<template #icon><icon-search /></template>
<template #default>{{ props.searchBtnText }}</template>
</a-button>
<a-button @click="emit('reset')">重置</a-button>
<a-button v-if="!props.hideFoldBtn" class="gi-form__fold-btn" type="text" size="mini"
@click="collapsed = !collapsed">
<template #icon>
<icon-up v-if="!collapsed" />
<icon-down v-else />
</template>
<template #default>{{ collapsed ? '展开' : '收起' }}</template>
</a-button>
</slot>
</a-space>
</a-grid-item>
</a-grid>
</a-form>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import type { Component } from 'vue'
import type { FormColumnItem, FormProps as Props } from './type'
import * as A from '@arco-design/web-vue'
import { omit } from 'lodash-es'
// FormProps 默认值
const props = withDefaults(defineProps<Props>(), {
autoLabelWidth: true,
scrollToFirstError: true,
defaultCollapsed: true,
search: false,
gridItemProps: () => ({ span: { xs: 24, sm: 12 } }),
searchBtnText: '搜索',
hideFoldBtn: false,
suffix: true,
fc: () => ({})
})
/** Emits 类型定义 */
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search'): void
(e: 'reset'): void
}>()
const COMP_MAP: Record<string, Component> = {
'input': A.Input,
'input-number': A.InputNumber,
'input-tag': A.InputTag,
'input-search': A.InputSearch,
'textarea': A.Textarea,
'select': A.Select,
'tree-select': A.TreeSelect,
'radio-group': A.RadioGroup,
'checkbox-group': A.CheckboxGroup,
'date-picker': A.DatePicker,
'year-picker': A.YearPicker,
'quarter-picker': A.QuarterPicker,
'month-picker': A.MonthPicker,
'week-picker': A.WeekPicker,
'time-picker': A.TimePicker,
'range-picker': A.RangePicker,
'color-picker': A.ColorPicker,
'rate': A.Rate,
'switch': A.Switch,
'slider': A.Slider,
'cascader': A.Cascader,
'upload': A.Upload,
'auto-complete': A.AutoComplete,
'mention': A.Mention
}
/** 表单属性计算 */
const formProps = computed(() => {
const baseProps = omit(props, [
'columns',
'gridProps',
'gridItemProps',
'search',
'defaultCollapsed',
'searchBtnText',
'hideFoldBtn',
'suffix',
'layout',
'fc'
])
return {
layout: ((props.search ? 'inline' : 'horizontal') as FormInstance['layout']),
...baseProps
}
})
/** 组件状态 */
const formRef = useTemplateRef('formRef')
/** 是否折叠 */
const collapsed = ref(props.defaultCollapsed)
/** 数据字典(预留功能:用于动态加载选项数据,可通过 request 和 format 配置项加载) */
const dicData: Record<string, unknown> = reactive({})
/** 静态配置 */
const STATIC_PROPS = new Map<FormColumnItem['type'], Partial<FormColumnItem['props']>>([
['input', { allowClear: true, maxLength: 20 }],
['textarea', { allowClear: false, maxLength: 200 }],
['input-tag', { allowClear: true }],
['mention', { allowClear: true }],
['select', { allowClear: true }],
['tree-select', { allowClear: true }],
['cascader', { allowClear: true }],
['date-picker', { allowClear: true }],
['time-picker', { allowClear: true }]
])
/** 占位符映射表 */
const PLACEHOLDER_MAP: Record<string, (label?: string) => string | undefined> = {
'input': (label) => `请输入${label}`,
'input-tag': (label) => `请输入${label}`,
'mention': (label) => `请输入${label}`,
'textarea': (label) => `请填写${label}`,
'select': (label) => `请选择${label}`,
'input-search': (label) => `请选择${label}`,
'tree-select': (label) => `请选择${label}`,
'cascader': (label) => `请选择${label}`,
'date-picker': () => '请选择日期',
'time-picker': () => '请选择时间'
}
/** 获取字符串类型的表单项类型 */
const getStringType = (type: FormColumnItem['type']): string => {
return typeof type === 'string' ? type : ''
}
/** 获取模型字段名 */
const getModelField = (item: FormColumnItem) => item?.fieldName || item.field
/** 获取组件默认占位 */
const getPlaceholder = (item: FormColumnItem) => {
if (!item.type || typeof item.type !== 'string') return undefined
const placeholderFn = PLACEHOLDER_MAP[item.type]
return placeholderFn ? placeholderFn(item.label) : undefined
}
/** 获取选项数据 */
const getOptions = (item: FormColumnItem) => {
if (!item.type) return undefined
/** 需要选项数据的组件类型 */
const arr = ['select', 'tree-select', 'cascader', 'radio-group', 'checkbox-group']
if (typeof item.type === 'string' && arr.includes(item.type)) {
return dicData[item.field] || []
}
return undefined
}
/** 获取组件绑定属性 */
const getComponentBindProps = (item: FormColumnItem) => {
return {
...STATIC_PROPS.get(item.type) || {},
placeholder: getPlaceholder(item),
options: getOptions(item),
...item.props
}
}
/** 表单数据更新处理 */
const updateValue = (value: unknown, field: string) => {
const newValue = { ...props.modelValue, [field]: value }
emit('update:modelValue', newValue)
}
/** 获取表单项校验规则 */
const getFormItemRules = (item: FormColumnItem) => {
if (item.required) {
return [
{ required: true, message: `${item.label}为必填项` },
...(Array.isArray(item.rules) ? item.rules : [])
]
}
if (props.fc?.[item.field]?.required) {
return [
{
required: props.fc?.[item.field]?.required,
message: `${item.label}为必填项`
},
...(Array.isArray(item.rules) ? item.rules : [])
]
}
return item.rules
}
/** 判断表单项是否隐藏 */
const isHide = (item: FormColumnItem) => {
if (typeof item.hide === 'boolean') return item.hide
if (typeof item.hide === 'function') {
return item.hide(props.modelValue)
}
if (props.fc?.[item.field]?.hidden) return true
return false
}
/** 判断表单项是否禁用 */
const isDisabled = (item: FormColumnItem) => {
if (typeof item.disabled === 'boolean') return item.disabled
if (typeof item.disabled === 'function') {
return item.disabled(props.modelValue)
}
if (props.fc?.[item.field]?.disabled === true) return true
if (item.disabled === undefined) return (item?.props?.disabled as boolean) ?? false
}
defineExpose({ formRef })
</script>
<style lang="scss" scoped>
.gi-form__fold-btn {
padding: 0 5px;
}
</style>