Skip to content

通用组件与 hooks ​

适用端:Web | 类型:参考

src/components(63 个目录)与 src/hooks(24 个文件)是所有页面的公共底座。本页讲清「组件是怎么变成可以直接写的标签的」、「哪些是本项目定制、哪些是上游遗留」、「新写页面该用哪个」,以及几个已经被证明是死代码的能力——避免照着上游文档去用根本不生效的东西。

前置阅读:前端工程结构(自动导入机制)。

它解决什么问题 ​

关键结论:自动注册的粒度是「.vue 文件名」,不是「目录名」。Components 插件的 glob 是 src/components/**/**.{vue, md},所以 63 个目录里的每一个 .vue 都会成为一个全局标签——包括本来只想内部使用的子组件。实测生成的 src/types/auto-components.d.ts 里有 509 个 GlobalComponents 条目。

代码在哪 ​

路径说明
src/components/<Name>/index.ts目录出口,export { Name } + 导出类型/工具函数
src/components/<Name>/src/<Name>.vue组件实现(上游约定的一层 src/)
src/components/index.tssetupGlobCom(app),被 src/main.ts:72 调用
src/hooks/web/*.ts23 个业务 hook
src/hooks/event/useScrollTo.ts唯一一个不在 web/ 下的 hook
src/utils/dict.ts字典取值函数 + DICT_TYPE 枚举(实测 215 条)
src/utils/permission.tscheckPermi / checkRole,供 JS 逻辑里判权限
src/utils/table.tsindexMethod,跨页连续序号
src/utils/download.tsBlob → 文件下载
src/types/auto-components.d.ts自动生成的全局组件声明(gitignore,只在 pnpm dev 时产出)

核心机制 ​

全局组件从哪里来 ​

src/components/index.ts 全文只有这么点东西:

ts
import type { App } from 'vue'
import { Icon } from './Icon'
import { LottieAnimation } from './LottieAnimation'

export const setupGlobCom = (app: App<Element>): void => {
  app.component('Icon', Icon)
  app.component('LottieAnimation', LottieAnimation)
}

其余 507 个全局名都来自 unplugin-vue-components。509 个名字按来源目录的分布(实测 top):

来源目录注册的全局名数量性质
SimpleProcessDesignerV258上游,流程设计器内部节点
bpmnProcessDesigner56上游,BPMN 面板内部组件
FormCreate52上游 + 定制控件混在一起
Portal28★ 定制,门户设计器/运行时
Verifition8上游,滑块验证码
DiyEditor8上游(components/mobile/** 已被 glob 显式排除)
UploadFile / FormLogic / Cropper各 6—
其余 50 余个目录各 1–4—

全局命名空间是被污染的,起组件名要避开

流程设计器、表单设计器的内部子组件(CallActivity、ChildProcessNode、AreaSelect、CollapseToggle……)全都成了全局标签。新写组件时如果取了个通用名字(Node、Panel、Item),很可能与已注册名冲突——ElementPlusResolver 与本地扫描的优先级会让结果难以预测。取名前先 grep 一遍 src/types/auto-components.d.ts。

标签写法:PascalCase 与 kebab-case 并存 ​

同一套组件在不同页面里有两种写法,都是有效的(Vue 会自动解析)。实测使用量(src/views + src/layout):

组件PascalCasekebab-case说明
Icon8560全站最常用
ContentWrap3350页面外层卡片容器
Pagination1330分页
Dialog1270弹窗(新增/编辑表单的标准外壳)
DocAlert0106只写 <doc-alert>
DictTag2227只写 <dict-tag>,另有 1 处显式 import
Echart400图表
TableActions290★ 定制,行操作按钮组
StatusSwitch210★ 定制,状态开关
Editor90富文本
CodeEditor80★ 定制,代码编辑器
OperateLogV280操作日志

新写页面统一用 PascalCase,与 ContentWrap / Dialog / Pagination 这批高频组件一致;dict-tag 与 doc-alert 保持现状不要批量改(改动量大、收益为零)。

常用组件选型 ​

需求用什么备注
页面外层白卡片ContentWraptitle / message(标题右侧问号 tooltip)/ bodyStyle,另有 header 插槽;带自动高度逻辑,见下
列表分页Pagination:total(必填)+ v-model:page / v-model:limit + @pagination="getList";page-sizes 固定 [20,50,80,100],total 为 0 时整个组件 v-show 隐藏
新增/编辑弹窗Dialog + el-form独立成 XxxForm.vue,v-model 控开关;props:title / width(默认 '40%')/ scroll + maxHeight(默认 400px)/ loading / fullscreen(默认 true,指显示全屏切换按钮)
状态字典渲染<dict-tag :type="DICT_TYPE.XXX" :value="row.status" />只认字典值,未匹配显示原值
行操作按钮TableActions★ 定制,见下
行内状态开关StatusSwitch★ 定制,见下
序号列<el-table-column type="index" :index="indexMethod(queryParams)" />indexMethod 免 import,跨页连续编号:(pageNo - 1) * pageSize + index + 1
图表Echart:options + :height
图标Icon三种前缀,见下方「Icon 的三种来源」
文件上传UploadFile / UploadImg在 src/components/UploadFile/ 下
用户选择UserSelectForm部门选择 DeptSelectForm
页面顶部提示条<doc-alert />上游文档链接,本项目内容多为遗留

ContentWrap 不只是个卡片壳子 ​

定制功能

src/components/ContentWrap/src/ContentWrap.vue(253 行)被本项目大幅改造过——实测 git 历史里有 8 次以上本项目提交(0fe8f3a33 2026-09-12 起,到 29cfe0dab 2026-10-11「UI 质感统一第 4 步」止),其中 8d91e041b 的提交信息写着「121 页经 ContentWrap/AppView 全站生效」。上游的 ContentWrap 只是一个带标题的 ElCard。

它多了一套列表页自动撑满高度的逻辑:onMounted 后挂 ResizeObserver(观察卡片、父元素、最近的 section、滚动视口)与 MutationObserver(观察卡片 body 的子树),在 requestAnimationFrame 里判断——

  • 卡片 body 里同时存在 .el-table 与 .el-pagination;
  • 且表格没有自定义高度(height / max-height 属性、内联 style、h- / min-h- / max-h- 开头的 UnoCSS 类,正则 ^(?:!+)?(?:h|min-h|max-h)-);
  • 且卡片自身也没有自定义高度;

三条都满足时给卡片加 --list 变体类,并把卡片高度钉成 Math.floor(视口底或下一张卡片顶 - 卡片顶 - section 的 padding-bottom - 卡片 margin-bottom),让表格在卡片内部滚动而不是整页滚动。算出来小于 200px 时放弃(窄屏下搜索区可能已占满首屏,压缩成几像素不如让外层页面滚动)。

对写页面的影响:

  • 想要自动高度,就别给 el-table 设 height;想自己控高度,就设了它——--list 会自动让路;
  • 一个卡片里放多个表格、或表格不在卡片 body 的直接子层,可能不触发;
  • 底部留白由 :not(:has(~ .prefix-content-wrap)):not(:has(~ * .prefix-content-wrap)) 这条 SCSS 决定(末位面板不再叠加下外边距)。注释里写明了为什么不能用 :last-child:页面末尾总有 Dialog 渲染出的零高度 .el-overlay 兄弟节点。

Icon 的三种来源 ​

vue
<!-- src/components/Icon/src/Icon.vue 的判定就一行 -->
const isLocal = computed(() => props.icon?.startsWith('svg-icon:'))
写法走哪条路离线可用
icon="svg-icon:xxx"本地 SVG symbol(vite-plugin-svg-icons-ng 扫 src/assets/svgs,virtual:svg-icons-register 注册)。实测 src/views + src/layout 里只有 5 处✅
icon="ep:xxx" / fa:xxx" / fa-solid:xxx@iconify/vue,但这三个集合被 src/plugins/svgIcon/index.ts 用 addCollection() 离线内置了(@iconify/json/json/{ep,fa,fa-solid}.json)。ep: 实测 806 处✅
其他前缀(fa6-solid:、mdi: …)@iconify/vue 现拉 Iconify 公共 API❌ 内网/离线会空白

菜单图标只能用 ep: / fa: / fa-solid: / svg-icon:

后台「系统管理 → 菜单管理」里填的图标名会直接进 <Icon :icon="...">。填了别的集合前缀,在离线部署环境里菜单图标会全部空白——而且开发机联着网,看不出来。

★ 定制组件:TableActions 与 StatusSwitch ​

两者都由提交 1dee10e09(2026-10-11,「为列表页统一改造铺底座」)引入,上游没有。它们的 API 设计里有几处刻意的取舍,值得单独讲,因为照着 Element Plus 的直觉用会写错。

TableActions:把一行的操作按钮统一成「配置数组 + 自动折叠 + 权限过滤」。

ts
// src/components/TableActions/src/actions.ts(原文)
export interface TableActionButton {
  key: string                        // v-for 的 key,折叠后必须稳定,不要用数组下标
  label: RowValue<string>            // 给函数是为了「同一位置按行改文案」
  type?: 'primary' | 'success' | 'warning' | 'danger' | 'info'   // 只作用于行内按钮
  permission?: string[]              // 任一命中即显示;不传/空数组 = 不门控
  show?: (row: any) => boolean       // 行级显示条件,等价于模板里的 v-if
  disabled?: RowValue<boolean>
  loading?: RowValue<boolean>
  tooltip?: RowValue<string>         // 空串/不传 = 不挂;行内与「更多」下拉项都会挂
  divided?: boolean                  // 收进「更多」后在该项之前画分隔线
  onClick: (row: any) => void
}

用法(src/views/agent/def/index.vue):<TableActions :row="scope.row" :buttons="rowButtons" />。

:buttons 必须是 setup 层的 const,不能是每行调一次的工厂函数

写成 :buttons="makeButtons(scope.row)" 会每行每次渲染都新建数组,computed 的依赖追踪会退化。行级差异靠配置里的 show 与 RowValue 函数表达——它们在渲染时被调用,读到的 ref 自然被追踪。这条约束写在组件 props 的注释里。

另一处刻意:组件不定义任何 emits。动作走配置里的 onClick 回调,这样各页不用再维护一套「command 字符串 → handler」的 handleCommand switch。

StatusSwitch:列表页的状态开关。

ts
// src/components/StatusSwitch/src/StatusSwitch.vue(props 节选)
modelValue: boolean          // 单向:只表达「服务端已生效的状态」,组件永远不会自己改它
permission?: string[]        // 不传/空数组 = 不门控(与 TableActions 同一口径)
disabled?: boolean           // 额外禁用条件,与权限门控取或
loading?: boolean            // 按行 loading:页面传 statusLoadingId === row.id
activeText?: string          // 默认「启用」
inactiveText?: string        // 默认「禁用」
width?: number               // 默认 52;EP 默认的 40 会把两个汉字截掉

为什么它只有 change、没有 update:modelValue

组件刻意不 emit update:modelValue,这样页面写不出 v-model——「乐观更新」这个误用被 API 本身挡住,而不是靠注释劝阻。点击后 EP 的开关视觉由 checked = modelValue 驱动,prop 不变 → 动画回弹原位,这正是「服务端确认前不改本地状态」的可见表达;取消二次确认时也不需要回滚任何东西。

二次确认也刻意留在页面里:确认文案是领域事实(要引用具体的服务端行为才核得动),而「只在往禁用方向确认」这种非对称策略塞进组件就得长出 confirmOn / confirmMessage 回调 DSL。

为什么权限做成 prop 而不是让页面挂 v-hasPermi

v-hasPermi 会把整个开关隐藏掉,用户连当前状态都读不到。语义差别是:操作列按钮无权限 = 隐藏;状态开关无权限 = 可见但不可操作(状态是要读的信息,不是动作)。

上游遗留:Table / Search 组件是死代码 ​

组件实测引用情况
src/components/Table只被 src/hooks/web/useTable.ts import;src/views / src/layout 中零使用
src/components/Search全仓零引用
useTablesrc/views 里只有 ai/model/{model,apiKey}/index.vue 的注释提到它,没有真实调用点
useCrudSchemas同上

也就是说上游那套「useCrudSchemas 定义 schema → useTable + Table + Search 组件自动渲染列表页」的链路在本项目完全没被采用。现有页面一律是手写 el-table + getList() + Pagination。

不要为了「用上 hook」去重构现有列表页

它们仍在 build/vite/index.ts 的 AutoImport.imports 清单里(所以看起来像是一等公民),但零调用点意味着零实战检验。新写页面请跟随现有手写风格。

同理,以下上游组件在 src/views 中零使用:ContentDetailWrap、Descriptions、Card、Infotip、SummaryCard、Crontab、JsonEditor、Map、Sticky、ShortcutDateRangePicker、ImageViewer(仅被其他组件内部引用)。它们在文档/教程里出现频率很高,别当成常用件。

hooks 全清单(24 个) ​

hook行数引用文件数来源用途
useMessage95345上游16 个方法:info/error/success/warning、alert/alertError/alertSuccess/alertWarning、notify/notifyError/notifySuccess/notifyWarning、confirm/delConfirm/exportConfirm/prompt
useI18n53204上游(自研包装)见下方说明
useForm9438上游配 components/Form,几乎只在流程设计器里用
useDesign1838上游读 global.module.scss 变量 + getPrefixCls(scope)
useTagsView6323上游标签页 closeAll/closeLeft/closeRight/closeOther/closeCurrent
useCache5413上游wsCache(localStorage/sessionStorage)+ CACHE_KEY + deleteUserCache()
useLocale357上游切换语言
useIcon96上游h(Icon, props),给 render 函数用
useUserLabel705★ 定制(2026-10-15)用户 id → 「昵称(用户名)」
useCrudSchemas3263(其中 2 是注释)上游未采用
useTable2234(其中 2 是注释)上游未采用
useFieldPermission482★ 定制(2026-09-19)字段级隐藏/只读
useTitle242上游改浏览器标题(permission.ts 里用)
useWatermark722上游页面水印
useConfigGlobal91上游读 app store 的全局配置
useNProgress331上游顶部进度条
usePageLoading181上游全局 loading
useNow601上游当前时间响应式
useValidator581上游required 等表单规则工厂
useScrollTo601上游滚动动画(唯一在 hooks/event/)
useEmitt220上游零引用
useGuide490上游零引用(driver.js 引导)
useNetwork210上游零引用,且它没有 export(写的是 const useNetwork = () => {})
useTimeAgo490上游零引用

「来源」一列由 git log --diff-filter=A 实测:除 useFieldPermission(868c48504,2026-09-19)与 useUserLabel(557013da5,2026-10-15)外,其余 22 个全部来自上游初始化提交(56f3017ba,2023-02-11)或上游后续提交(2023-10-31)。

useI18n 是本项目的包装,不是 vue-i18n 的那个 ​

ts
// src/hooks/web/useI18n.ts(节选)
const tFn = (key: string, ...arg: any[]) => {
  if (!key) return ''
  if (!key.includes('.') && !namespace) return key     // ← 关键:没有「.」就直接原样返回
  return t(getKey(namespace, key), ...arg)
}

所以 t('删除成功') 返回 '删除成功'——中文字面量可以直接当 key 用,不需要在语言包里登记。这就是为什么全仓大量提示文案是硬编码中文而不是 t('xxx.yyy')。同时文件末尾还导出了一个恒等函数 export const t = (key: string) => key,供模块顶层(非 setup 上下文)使用,例如 src/directives/permission/hasPermi.ts。

别从 vue-i18n 直接 import useI18n

自动导入登记的是 @/hooks/web/useI18n。直接 import { useI18n } from 'vue-i18n' 会拿到语义不同的实现(必须在 setup 里调用、key 不存在时返回 key 本身但会打警告),行为与全站不一致。

★ 定制 hook 一:useFieldPermission ​

字段级权限(隐藏 / 只读)。它补的缺口是:后端 @PermissionFieldCrop 只把判成 HIDDEN 的字段置空(VO 是强类型的,删不掉属性),于是前端表格照样渲染出一列空白——对「值不能出去」是够了,但和「看不到这一列」不是一回事。所以隐藏列必须由前端做。

ts
// src/views/system/user/UserForm.vue / index.vue(实测仅有的两个调用点)
const { isVisible, isReadonly } = useFieldPermission('system:user')        // 详情字段(:fld:)
const { isVisible } = useFieldPermission('system:user', true)              // 列表列(:col:)
// 模板里:v-if="isVisible('mobile')"

返回 { modes, loaded, load, isVisible, isReadonly, hiddenFields }。取不到模式时按「不受限」处理(modes = {}),与后端 PermissionFieldHelper.resolve 的降级口径一致——反过来(取不到就全隐藏)会让一次偶发故障表现成整页的列都消失。

★ 定制 hook 二:useUserLabel ​

把「存着 system_users.id 的列」翻译成「昵称(用户名)」。库里 creator / updater 是 BaseDO 的字符串字段、业务表的 user_id 是 bigint,装的都是用户编号,直接展示是一串数字。

ts
// src/views/infra/dataInterface/LogList.vue:直接用 el-table-column 的 formatter
const { userFormatter } = useUserLabel('调用人')      // 参数只用于失败提示里的列名
// <el-table-column label="调用人" :formatter="userFormatter" />

// src/views/infra/billTemplate/BillTemplateLogList.vue:要保住空值那个灰色的 `-`,改用 userLabel
const { userLabel } = useUserLabel('取号人')
// <span v-if="row.userId != null">{{ userLabel(row.userId) }}</span>

// src/views/agent/workflow/index.vue:一个页面多处用,重命名解构
const { userFormatter: ownerFormatter } = useUserLabel('负责人')

返回 { userList, userMap, loadUsers, userLabel, userFormatter },onMounted 里自动 loadUsers()。userFormatter 只能返回字符串,需要给空值单独上色时用 <template #default> 配 userLabel——这条写在 hook 的注释里,两个真实调用点正好各用一种。

两个设计点:走 /system/user/simple-list(它没有 @PreAuthorize,任何登录用户都能调,不会因为某个角色没被授予「用户查询」就让整个列表页 403),代价是只返回启用状态的用户;查不到就原样显示编号而不是空白(用户被删除或被停用都会查不到,显示数字至少能拿去库里查出是谁)。失败只降级不抛出,并 message.warning 提示该列将显示编号。

字典没有 hook ​

不存在 useDict

取字典走 src/utils/dict.ts 的函数(底层是 dict store):

函数返回
getIntDictOptions(dictType)NumberDictDataType[],值是 number
getStrDictOptions(dictType)值是 string
getBoolDictOptions(dictType)值是 boolean
getDictOptions(dictType)原始数组
getDictLabel(dictType, value)单个值 → 标签
getDictObj(dictType, value)单个值 → 完整对象
getDictTree / getDictPathLabels / getDictChildren树形字典(如部门、地区)

只有 DICT_TYPE 枚举(215 条)被登记进了自动导入,函数本身要 import { getIntDictOptions } from '@/utils/dict'。

数据模型 ​

组件目录的主流形状(上游约定,定制组件也遵守):

src/components/
  TableActions/
    index.ts                 # export { TableActions } + export * from './src/actions'
    src/
      TableActions.vue       # 实现,defineOptions({ name: 'TableActions' })
      actions.ts             # 类型与纯函数
      actions.spec.ts        # 纯函数的单测(vitest,node 环境)

但这不是硬性约定

实测 63 个目录里,38 个是「index.ts + src/」的标准形状,25 个是扁平的(.vue 直接放在目录根,如 Pagination/index.vue、DocAlert/、SummaryCard/)。有意思的是:0fe8f3a33 那批定制的 Form* 与 Portal 全部是扁平形状。自动注册按 .vue 文件名走,两种都能用。新建组件时建议照 TableActions 的标准形状写——它把类型与纯函数单独放 actions.ts,因此可以被 actions.spec.ts 测到。

把纯逻辑抽到同目录 .ts 并配 .spec.ts 是本项目定制部分的固定做法——vitest.config.ts 的 environment 是 node,测不了组件渲染,所以可测的部分必须先脱离组件。src/components/FormWidgets/ 是这套做法的极致:45 个 .ts 文件里有 20 个是 .spec.ts(arrayValue / cellMerge / columnSetting / customButton / formSteps / inlineEdit / listCell / printTemplate / renderScene / rowGroup / searchWidget / stepValidate / template / uploadValue 等),承载了低代码表单的取值、合并、校验契约。

怎么扩展 ​

新增一个全局组件

  1. 建 src/components/<Name>/index.ts + src/components/<Name>/src/<Name>.vue;
  2. .vue 里写 defineOptions({ name: '<Name>' });
  3. 跑一次 pnpm dev——unplugin-vue-components 会把它写进 src/types/auto-components.d.ts(该文件被 gitignore,只在 dev 下生成);
  4. 之后任意页面直接 <Name />,不用 import。

跳过第 3 步,pnpm ts:check 会报「找不到组件」

build/vite/index.ts 里 Components({ dts: !isBuild && ... })——构建时不生成声明文件。CI 或本地首次 clone 后必须先跑一次 dev 再做类型检查。

若组件名与 Element Plus 冲突,ElementPlusResolver 优先,改用别的名字。

新增一个 hook:放 src/hooks/web/<name>.ts。只有希望「免 import 直接用」的才去 build/vite/index.ts 的 AutoImport.imports 里登记(现有登记的只有 useI18n、useMessage、useTable、useCrudSchemas 四个),登记后同样要重跑 pnpm dev 生成 auto-imports.d.ts。

在 JS 逻辑里判权限(不是模板):import { checkPermi } from '@/utils/permission',checkPermi(['system:user:update'])。它就是 hasPermi.ts 里 hasPermission 的转手,含超管 *:*:* 短路。另有 checkRole(['super_admin']) 读 localStorage 里的 user.roles。

新增一个字典类型:后端「系统管理 → 字典管理」里建类型与数据,前端在 src/utils/dict.ts 的 DICT_TYPE 枚举里补一行(枚举值 = 后端字典类型字符串),然后 getIntDictOptions(DICT_TYPE.XXX) 即可。字典数据由 dict store 在登录后一次性拉取并缓存(CACHE_KEY.DICT_CACHE / DICT_VERSION)。

什么时候该抽组件:跟 TableActions / StatusSwitch 的判据一致——同一段模板在 3 个以上页面重复,且行为约束需要被 API 强制(例如「不许乐观更新」)时才抽;只是长得像,就留在页面里。

坑与注意 ​

  • src/types/auto-components.d.ts 与 auto-imports.d.ts 都不入库,且 dts: !isBuild。新增组件/hook 后必须先 pnpm dev 再 pnpm ts:check。
  • setupGlobCom 只注册 2 个组件,别把它当成「全局组件的总入口」去加东西——加了会与自动注册重复。
  • Icon 的非内置前缀在离线环境会空白,见「Icon 的三种来源」。菜单管理里填图标名时尤其要注意。
  • ContentWrap 的自动高度会被 el-table 的 height 关掉——这是设计好的让路机制,不是 bug。想让表格在卡片内滚动就别设高度。
  • useTable / useCrudSchemas / Table / Search 是一套未采用的上游链路,见上文 danger。
  • useEmitt / useGuide / useNetwork / useTimeAgo 零引用;其中 useNetwork 连 export 都没有,import 它会拿到 undefined。
  • useFieldPermission 目前只有 system/user 两个页面接入。它是定制能力,不是全站默认行为——给别的页面加字段权限需要显式调用,并且后端要配好对应的 :col: / :fld: 规则。
  • TableActions 的 permission 不传或传空数组 = 不门控(永远显示),不是「谁都不能看」。组件里显式跳过了 checkPermi([]),因为那对超管返回 true、对普通用户返回 false(*:*:* 的短路在 .some() 之前),语义不稳定。
  • StatusSwitch 不能用 v-model,只能 :model-value + @change。
  • useDesign().getPrefixCls(scope) 依赖 src/styles/global.module.scss,该文件被 vite.config.ts 的 additionalData 显式排除注入(否则会 Sass duplicate global variables 报错)。改 SCSS 注入逻辑时别把它一起处理。
  • DICT_TYPE 里有 IM_* 等 17 个字典,但后端的 enlev-module-im 已被整体删除——这些字典项是上游遗留,前端能用、后端没有对应业务。别按它们去找接口。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

相关页面 ​