21 KiB
前端 UI 开发准则
本准则适用于
frontend/目录下的管理后台前端(Vue 3 + TypeScript + Vite + TDesign Vue Next)。 面向后续新增/改造页面的统一依据。核心目标:不重复造轮子 —— 能用 TDesign 组件就不手写原生标签,能用项目共享基建就不重复造状态逻辑;布局统一、符合 TDesign 风格。
① 总则与铁律
技术栈
| 类别 | 选型 |
|---|---|
| 框架 | Vue 3.5(<script setup lang="ts">) |
| 语言 | TypeScript |
| 构建 | Vite |
| UI 组件库 | tdesign-vue-next(package.json 声明 ^1.10.6,当前锁定 1.20.5) |
| 图标库 | tdesign-icons-vue-next |
| 状态管理 | Pinia(src/stores/) |
| 路由 | Vue Router 4(hash 模式 #/path) |
组件按需自动引入
项目通过 unplugin-vue-components + TDesignResolver(见 vite.config.ts)自动按需引入 TDesign 组件,因此:
- ✅ 模板中直接写
<t-button>/<t-table>/<t-dialog>等标签,无需手动import。 - ✅ 组件 props/事件用法以 TDesign 官方文档为准。
- ⚠️ 图标例外:图标需从
tdesign-icons-vue-next手动具名导入(见 §⑤ 图标规范)。
三条铁律
- 组件优先:能用 TDesign 组件实现的交互/展示,禁止用原生标签 + 手写 CSS 复刻(树、下拉、分页、开关、标签、空态、加载、通知等,见 §⑤ 对照表)。
- 基建优先:分页/加载/表单/确认等状态逻辑,强制复用
src/composables/与src/components/的共享能力(见 §⑥),禁止每页手写一套page/total/loading/visibleref。 - Token 优先:颜色一律走设计 token(
--color-*语义色 /palette.ts系列色),禁止硬编码 hex(见 §②)。
② 设计 Token 与配色
颜色来源只有两个入口
| 场景 | 使用 | 定义位置 |
|---|---|---|
| 语义色(主色/成功/警告/错误)、文本、边框、背景、圆角、阴影 | var(--color-*) |
src/styles/tokens.css |
| 多色区分系列色(图表 / mermaid 流程图 / 分类标签) | palette / paletteBg / paletteBorder |
src/utils/palette.ts |
语义 token(--color-*,已映射到 TDesign --td-*)
主色 --color-primary / --color-primary-hover / -active / -light
状态色 --color-success / --color-warning / --color-error
状态阶 --color-{success|error|warning|info}-{bg|border|text|text-strong}
文本 --color-text-primary / -secondary / -tertiary / -disabled / -link
边框 --color-border / --color-border-strong
背景 --color-bg-page / --color-bg-subtle / --color-bg-code
圆角 --radius-sm / --radius-md / --radius-lg / --radius-card
阴影 --shadow-sm / --shadow-md / --shadow-lg
系列色(palette.ts,仅用于图表/流程图/分类多色)
import { palette, paletteBg, paletteBorder } from '@/utils/palette'
// palette.blue / .purple / .green / .red / .orange / .indigo / .pink / .gray
// palette.blueRgb 用于 rgba(..., 0.1) 填充背景
强制规则
- ❌ 禁止在组件
<style>里写死 hex 颜色(#3b82f6、#fff等);唯一例外是tokens.css内部的代码块深色--color-code-block-bg/text。 - ❌ 禁止业务样式直接混用
--td-*(现状RoleManager/SystemConfigManager/LoginPage等混用两套变量)。业务自定义样式统一--color-*。 - ✅ 布局壳
layouts/(MainLayout/Sidebar/Topbar)已用--td-*,属可接受的历史例外,不强制回改,但新增业务样式一律--color-*。 - ✅ 优先用组件自带
theme/variant/color属性表达状态(如<t-tag theme="success">、<t-statistic color>),不要用 CSS 覆盖组件色。
复用 shared.css 工具类
src/styles/shared.css 已提供,禁止各页重复定义:
| 类名 | 用途 |
|---|---|
.toolbar |
工具栏(flex + gap:8px + 换行) |
.dialog-footer |
弹窗页脚按钮行(右对齐) |
.card-box |
圆角边框卡片容器 |
.desc-text |
页头下的描述性文字 |
.code-tag |
代码/键名展示 |
.total-hint |
统计提示文字 |
③ 页面布局规范
应用壳(无需业务页关心)
src/layouts/MainLayout.vue 已提供「Topbar + Sidebar + 主内容区(<router-view>)」的固定视口壳,主内容区是唯一主滚动容器(main.main-content)。业务页面只实现自己那一块内容,禁止自建全屏容器 / 自己的滚动 body。
业务页外壳
统一用 <t-card> 作为最外层,页头统一走 title 属性(不手写 #title 插槽 + <h2>):
<template>
<t-card :bordered="false" title="用户管理">
<!-- 页面内容 -->
</t-card>
</template>
- 页头下的补充说明统一用
<p class="desc-text">…</p>(shared.css)。 - 栅格布局统一
<t-row :gutter="16">+<t-col :span="n">(24 栅格)。 - 主从分栏(左列表右详情)应复用统一的布局组件,不要像
RoleManager/SystemConfigManager那样各自复制一套约 250 行的 scoped CSS。
④ 标准「列表管理页」模板
以下为 CRUD 列表页的统一骨架,逐块遵循。完整示例见 src/views/ 现有页面(SensitiveWordManager 相对标准)。
<template>
<t-card :bordered="false" title="用户管理">
<p class="desc-text">管理后台用户账号与角色。</p>
<!-- ① 工具栏 -->
<div class="toolbar">
<t-input v-model="keyword" placeholder="搜索关键词" clearable
style="width: 220px" @enter="onSearch" @clear="onSearch" />
<t-select v-model="statusFilter" :options="statusOptions" clearable
placeholder="状态" style="width: 140px" @change="onFilterChange" />
<div style="flex: 1" />
<t-button theme="primary" @click="form.openCreate()">
<template #icon><AddIcon /></template>新建
</t-button>
</div>
<!-- ② 表格 -->
<t-table
:data="pagination.data.value"
:columns="columns"
:loading="pagination.loading.value"
row-key="id"
:pagination="{
current: pagination.page.value,
total: pagination.total.value,
pageSize: pagination.pageSize,
showJumper: true,
}"
@page-change="pagination.onPageChange"
>
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" theme="primary" @click="form.openEdit(row)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="onDelete(row)">删除</t-button>
</t-space>
</template>
</t-table>
</t-card>
<!-- ③ 弹窗表单 -->
<FormDialog
v-model:visible="form.visible.value"
:title="form.isEdit.value ? '编辑用户' : '新建用户'"
:saving="form.saving.value"
@confirm="onSave"
>
<t-form ref="formRef" :data="form.form.value" :rules="rules" label-align="top">
<t-form-item label="用户名" name="username">
<t-input v-model="form.form.value.username" placeholder="请输入用户名" />
</t-form-item>
<!-- 更多字段,多字段用 <t-row :gutter="16"><t-col :span="6"> -->
</t-form>
</FormDialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { AddIcon } from 'tdesign-icons-vue-next'
import { usePagination } from '@/composables/usePagination'
import { useCrudForm } from '@/composables/useCrudForm'
import { useConfirm } from '@/composables/useConfirm'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
import FormDialog from '@/components/FormDialog.vue'
import { getUserPage, createUser, updateUser, deleteUser } from '@/api/user'
// 分页 + 列表
const pagination = usePagination({ fetchFn: getUserPage })
const keyword = ref('')
const statusFilter = ref('')
const statusOptions = [{ label: '启用', value: 'enabled' }, { label: '禁用', value: 'disabled' }]
function onSearch() { pagination.reset(); pagination.load() }
function onFilterChange() { pagination.reset(); pagination.load() }
// 表单(创建/编辑)
const formRef = ref()
const form = useCrudForm({
defaultForm: { username: '', nickname: '' },
createFn: createUser,
updateFn: updateUser,
onSuccess: () => pagination.load(),
})
const rules = { username: [{ required: true, message: '用户名必填', trigger: 'blur' }] }
async function onSave() {
const result = await formRef.value?.validate()
if (result !== true) return // 校验失败(result 为 ValidateResultObj),不提交
const ok = await form.save()
if (!ok) toast('保存失败', 'error')
}
// 删除
const { confirm } = useConfirm()
async function onDelete(row: any) {
if (!(await confirm(`确定删除「${row.username}」?`, '删除后不可恢复'))) return
const r = await deleteUser(row.id)
r.success ? toast('删除成功', 'success') : toast(r.message || '删除失败', 'error')
pagination.load()
}
// 列定义(写在 script 中,传 :columns,不用 <t-table-column> 标签)
const columns = [
{ colKey: 'username', title: '用户名' },
{ colKey: 'nickname', title: '昵称' },
{ colKey: 'createTime', title: '创建时间', cell: (_, { row }) => formatDate(row.createTime) },
{ colKey: 'op', title: '操作', width: 140 },
]
</script>
模板要点裁决
| 点 | 规范 |
|---|---|
| 表格列定义 | 统一写在 <script> 的 columns 数组 + :columns;禁用 <t-table-column> 标签写法 |
| 分页 | 统一用 <t-table> 内置 :pagination + @page-change + showJumper;禁用独立 <t-pagination> 组件 |
| 表格 loading | 必须绑定 :loading(现状有 4 个页面缺失) |
| pageSize | 默认统一 20(现状 10/20/30 三档不一) |
| 操作列 | <t-space :size="4"> + <t-button size="small" variant="text">;危险操作 theme="danger"、主操作 theme="primary" |
| 状态列 | 用 <t-tag variant="light" theme="..."> 表达,不用带色 <span> |
| 开关列 | <t-switch v-model="row.xxx" @change="..."> |
| 弹窗 | 统一 FormDialog 组件 + .dialog-footer,<t-form label-align="top">;富文本/宽内容表单改用 FormDrawer(t-drawer) |
| 删除确认 | 统一 useConfirm().confirm()(已 100% 采用) |
| 提示 | 统一 @/utils/toast,禁止直接 import { MessagePlugin } |
⑤ 组件对照表(「不重复造轮子」核心)
现状中大量「手写原生标签复刻组件」的做法必须废止,改回 TDesign 组件:
| 场景 | ✅ 应使用 | ❌ 禁止的手写做法(现状反例) |
|---|---|---|
| 树形结构 | t-tree(可 checkable / expand-on-click-node) |
CategoryManager 手写 div.tree-row + 内联 paddingLeft + expandedIds Set |
| 级联/勾选选择 | t-tree checkable 或 t-cascader / t-tree-select |
RoleManager 手写嵌套 checkbox + 全选/半选 computed |
| 下拉选择 | t-select / t-auto-complete |
ModelConfigManager 手写 div 列表 + 键盘导航 + document 点击关闭 |
| 多标签输入 | t-tag-input |
DocUpload 手写 t-tag closable + t-input borderless 拼装 |
| 键值/描述展示 | t-descriptions |
DocDetail 手写 .info-grid + .info-item |
| 列表展示 | t-list(或 t-table) |
DashboardPanel/DocDetail/LogViewer 手写 div 列表 |
| 统计指标卡 | t-card + t-statistic |
ModelConfigManager 手写内联 div(🟢在线/🔴离线) |
| 全局通知/结果浮层 | MessagePlugin / NotificationPlugin |
McpServerManager/ModelConfigManager 手写 position:fixed div + setTimeout |
| 页签切换 | t-tabs / t-tab-panel |
ChatPanel 手写 .assistant-card + click 切 active |
| 导航/文件列表 | t-menu |
LogViewer/ChatPanel 手写 .file-item/.assistant-card |
| 加载态 | t-loading / v-loading / 表格 :loading |
PipelineFlow 手写 .pipeline-loading |
| 空态 | t-empty(可配 description / image) |
各页自定义空 div |
| 错误态 | t-alert theme="error" + t-empty(tdesign-vue-next 无 t-result) |
PipelineFlow 手写 .pipeline-error |
| 表单校验 | t-form 的 :rules(见 §⑦) |
手写 if(!x){toast(...);return} 或 :disabled 禁用按钮 |
| 确认操作 | useConfirm(DialogPlugin.confirm 封装) |
原生 window.confirm |
| 提示 | @/utils/toast(MessagePlugin 封装) |
直接 MessagePlugin / 自写浮层 |
| 拖拽排序 | t-table 的 drag-sort,或抽共享 composable |
ModelConfigManager 手写 HTML5 draggable + @dragstart/@drop |
图标规范(统一 tdesign-icons-vue-next)
- ✅ 功能图标(按钮、菜单、状态)从
tdesign-icons-vue-next具名导入:
import { AddIcon, SearchIcon, DeleteIcon, RefreshIcon, SettingIcon } from 'tdesign-icons-vue-next'
<t-button theme="primary"><template #icon><AddIcon /></template>新建</t-button>
<!-- 或直接用 t-icon -->
<t-icon name="add" />
- ❌ 禁止用 emoji 作功能图标/菜单图标/操作按钮图标(现状
ModelConfigManager操作列纯 emoji 按钮、各页标题👥/📚前缀、navigation.ts菜单icon字段用 emoji)。 - 存量 emoji 标题/菜单图标,新增代码不再使用;菜单
icon字段迁移方案:改用 tdesign-icons 组件名,Sidebar.vue渲染<t-icon :name="item.icon" />。
⑥ 共享基建使用规范
以下能力已存在于 src/,新增页面必须复用,禁止手写等价的 state 逻辑。
Composables(src/composables/)
| 能力 | 路径 | 用法 / 返回 |
|---|---|---|
| 分页 + 列表加载 | @/composables/usePagination |
usePagination({ fetchFn }) → { page, total, pageSize, loading, data, load, onPageChange, reset } |
| 表单状态(创建/编辑) | @/composables/useCrudForm |
useCrudForm({ defaultForm, createFn, updateFn, onSuccess }) → { visible, editingId, saving, form, isEdit, openCreate, openEdit, close, save } |
| loading/saving | @/composables/useLoading |
{ loading, saving, withLoading, withSaving } |
| 删除确认 | @/composables/useConfirm |
const { confirm } = useConfirm(); await confirm(title, body?) |
| 防抖 | @/composables/useDebounce |
const { debounce, cancel } = useDebounce() |
⚠️ 现状
usePagination/useLoading/useCrudForm/FormDialog均为 0 使用,各页手写了一套等价逻辑。这是最需要纠正的「重复造轮子」。说明:上述 composable 返回的是普通对象内嵌
ref(如pagination.data是Ref<T[]>),故模板中需显式写.value(pagination.data.value)。若后续优化,可让 composable 用reactive/toRefs返回以便模板免.value,但当前以既有实现为准。
组件(src/components/)
| 组件 | 路径 | 用途 |
|---|---|---|
FormDialog |
@/components/FormDialog.vue |
新增/编辑弹窗壳(封装 t-dialog + 页脚取消/保存按钮),表单内容走默认 slot,extra-buttons slot 放额外按钮 |
FormDrawer |
@/components/FormDrawer.vue |
新增/编辑抽屉表单壳(封装 t-drawer + 页脚取消/保存按钮),用于富文本/宽内容表单,接口同 FormDialog |
MessageSources |
@/components/MessageSources.vue |
AI 引用来源展示 |
工具(src/utils/)
| 工具 | 路径 | 用途 |
|---|---|---|
toast(msg, type?) |
@/utils/toast |
全站唯一提示入口(MessagePlugin 封装),type: info|success|error|warning |
formatDate(d) / formatBytes(b) |
@/utils/format |
日期 YYYY-MM-DD HH:mm:ss、文件大小 |
renderMarkdown(text) |
@/utils/markdown |
marked + DOMPurify 消毒,唯一 Markdown 渲染入口 |
palette / paletteBg / paletteBorder |
@/utils/palette |
图表/流程图系列色 |
readSSEStream* |
@/utils/sse |
SSE 流式统一处理 |
状态(src/stores/)
- 跨组件共享状态用 Pinia;
navigation.ts(菜单配置MENU_ITEMS)、dialog.ts(全局文档详情弹窗)、auth.ts、category.ts、document.ts已有。 - 页面局部状态用
<script setup>内ref/reactive,不要为单页数据建 store。
⑦ 表单与校验规范
- 表单统一
<t-form :data="..." :rules="..." label-align="top">+<t-form-item name="...">,用声明式:rules做校验。 - ❌ 废止两种土办法:① 手写
if(!x){toast('...','error');return};② 用:disabled禁用保存按钮代替校验。 - ✅ 提交前调用
formRef.value?.validate()触发校验(见 §④ 模板)。 - 多字段布局统一
<t-row :gutter="16"><t-col :span="6">(span=6为四列)。 - 弹窗统一
<t-dialog :footer="false">+ 页脚.dialog-footer放「取消 / 保存」,保存按钮:loading="saving"防重复提交。 - 弹窗 vs 抽屉的取舍:简单表单用
t-dialog(FormDialog组件);富文本/宽内容表单(Markdown 编辑器、宽预览、长文本、宽表格编辑)统一用t-drawer(FormDrawer组件,size可设'large'/'1000px'/'85%'),避免窄弹窗挤压双栏编辑器。 - 必填/格式校验规则统一以 TDesign Form 校验规则为准(
required、trigger: 'blur'等)。
⑧ 交互与反馈规范
- 搜索触发(统一裁定):
- 关键词输入:
@enter触发(或useDebounce实时触发),二选一并全站一致;不裸用@change做全量重载。 - 下拉筛选:
@change应自动重载列表(重置到第 1 页)。
- 关键词输入:
- 删除/危险操作:统一
useConfirm().confirm(title, body?),body 说明后果。 - 错误处理(沿用项目规则):
catch必须透传服务器错误信息,禁止吞掉:
try {
const r = await doSomething()
r.success ? toast(r.message || '操作成功', 'success') : toast(r.message || '操作失败', 'error')
} catch (e: any) {
toast(e.message || '操作失败', 'error') // ✅ 透传
}
// ❌ 禁止:catch (e) { console.error(e) } / catch {} / /* 静默失败 */
- 成功/失败文案统一
r.message || '默认文案';标点用半角:(不混用全角:)。 - 提交类操作按钮必须
:loading,防重复提交。
⑨ API 层规范
- 所有后端调用统一走
src/api/*.ts模块(axios实例 + 拦截器见src/api/request.ts),组件内禁止手写fetch/XMLHttpRequest、硬编码 URL、直接读localStorage.getItem('sb_token')。 - ❌ 现状反例:
DocUpload手写 XHR 到/upload/json/basic、fetch到/upload/string并直接读 token,绕过了@/api与@/utils/token封装。 - 新增接口:在对应
src/api/<module>.ts增加函数,组件 import 使用。 - 响应结构统一
{ success, code?, message?, data }(src/types/api.ts)。
⑩ 图表 / 流程图规范
- 图表(Chart.js)、流程图(mermaid)颜色唯一来源是
@/utils/palette(palettehex/rgb +paletteBg+paletteBorder),禁止散落 hex。 - 新增图表/流程图建议复用统一的初始化封装(
mermaid.initialize的 themeVariables、Chart.js 的 register/render 目前散落在DashboardPanel/PipelineFlow内联,重复可抽成src/utils/chart.ts/src/utils/mermaid.ts)。 - Markdown 渲染统一
renderMarkdown(已消毒),v-html只允许用于已消毒内容(AI 回复、mermaid 生成的 svg、先转义后高亮的日志)。
附录:存量页面已知偏差清单
截至 2026-08-15 调研时点,以下为现状与准则不符之处,供后续增量整改(行号随代码演进会漂移,仅作定位参考)。本准则落地不要求一次性整改这些存量页,但新增代码不得再犯。
高优先级(「重复造轮子」重灾区)
- 共享 composables /
FormDialog全部 0 使用(usePagination/useLoading/useCrudForm/FormDialog.vue),各 CRUD 页手写分页/loading/弹窗。 - 手写树替代
t-tree:CategoryManager.vue。 - 手写复选框树替代
t-tree checkable/t-cascader:RoleManager.vue。 - 手写下拉替代
t-select:ModelConfigManager.vue(模型下拉)。 - 手写通知浮层替代
MessagePlugin/NotificationPlugin:McpServerManager.vue、ModelConfigManager.vue。 - 主从分栏布局重复两套 scoped CSS:
RoleManager.vuevsSystemConfigManager.vue。 - 手写标签输入 / XHR / fetch / 直读 token:
DocUpload.vue。
一致性偏差
- 页头 3 种写法并存:
title属性(多数) vs#title+<h2>(ModelConfigManager) vs#title+图标(SystemConfigManager)。 - 表格缺
:loading:UserManager/AccountManager/McpServerManager/ConversationManager。 - 分页缺失/不统一:
AccountManager无分页;pageSize 10/20/30 三档。 - 表单校验分裂:全无
:rules,手写if与:disabled两种土办法并存。 - 图标体系分裂:emoji 主导,仅
SystemConfigManager用 tdesign-icons;ModelConfigManager操作列纯 emoji 按钮。 - CSS 变量双体系混用:
--color-*与--td-*交叉,甚至同文件混用。 - 搜索触发不统一:
@entervs@change+debounce vs 显式搜索按钮。 - 错误处理吞异常:多处
console.error/ 空catch{}//* 静默失败 */。 - 死 import:
ModelConfigManager.vueimport 了未使用的MessagePlugin。 - 操作列
t-space间距:size="2"(UserManager)与:size="4"(其余)不一致;v-show/v-if/动态 theme 三种显隐写法并存。