35 KiB
前端代码风格(web)
适用范围:
web/src、web/tests、web/e2e。本文是细则;根目录
CODEBUDDY.md只保留必须遵守的红线与常用命令。原则:规范描述本仓库已经形成的写法,并收紧离群点,不重新发明一套风格。 新代码严格执行;存量代码按模块逐步清理,不做一次性大改。
目录
- 0. 总体原则与红线
- 1. 工具链
- 2. 目录与文件
- 3. 命名
- 4. TypeScript
- 5. Vue SFC
- 6. 状态与数据流
- 7. 请求与错误处理
- 8. 列表页(查询)
- 9. 编辑页(保存)
- 10. 样式
- 11. 注释
- 12. 测试
- 13. Review 清单
- 14. 现有离群点
0. 总体原则与红线
0.1 总体原则
业务代码优先短、直白、可读,参考旧项目 g3hd2026 的业务页面写法(只借鉴可读性,不照搬技术栈):
- 能一眼看完:一个列表页 / 编辑页的页面级逻辑尽量短;长逻辑拆成纯函数,但不要拆成十几个只调用一次的小函数。
- 不为一个调用点封装:只有出现第二个真实复用点,或它本身是明确的横切能力(请求、权限、diff、查询条件、保存编排)时,才抽函数 / 组件 / composable。
- 不套无意义的层:不要为了“架构感”加
xxxService/xxxHelper/xxxManager;直接调用能看懂,就不要多一跳。 - 不搞难懂的语法糖:少用深层链式
reduce、多层三元、立即执行函数、函数式 pipe、复杂泛型体操、Proxy/Reflect;优先if/for/ 提前return。 - 重复两三行可以接受:简单代码重复 2-3 行通常不值得抽;同一段逻辑重复到第三次再抽。但带副作用的复杂编排(保存、权限、SQL 生成)重复一次就值得抽。
- 命名说人话:
loadData/saveDraft/buildRequests,不用processData/handleIt/doXxx。 - 封装的收益要大于跳转成本:读一段业务代码如果需要跳 3 个文件才能明白,就是过度封装。
- 可读性优先于 DRY 和微优化:先让人看懂,再谈复用和性能。
例子:
❌ 只有一个调用点,还多一层
function getUserName() {
return user.value.name
}
const name = getUserName()
✅ 直接取
const name = user.value.name
❌ 链式嵌套,读的时候要在脑子里跑一遍
const total = rows.reduce(
(sum, row) => sum + (row.items ?? []).reduce((s, item) => s + (item.amount ?? 0), 0),
0,
)
✅ 用循环
let total = 0
for (const row of rows) {
for (const item of row.items ?? []) {
total += item.amount ?? 0
}
}
判断标准:
- 这个函数是否只有一个调用点?
- 删掉这层抽象,代码会更短还是更长?
- 新人能不能在 30 秒内看懂这段业务?
- 这层封装是在解决真实重复,还是只是在“显得有架构”?
边界:
- 该封装:
useSaveFlow(保存编排跨页重复)、buildQueryCondition(查询 SQL 生成有安全边界)、services/api(统一鉴权 / 错误处理)。 - 不该封装:页面里只用一次的 3 行格式化、只有一个调用点的
xxxHelper、为了“以后可能复用”提前抽的组件。
0.2 红线
新代码必须满足以下 12 条:
- 只写
<script setup lang="ts">,SFC 顺序固定为script → template → style。 - 组件必须
defineOptions({ name: 'XxxYyy' })。 - props / emits 用类型声明;props 有默认值时用
withDefaults。 - 请求只能走
@/services/api;views/components禁止直接import axios。 - 用户可见提示只用
G3Message;src里禁止console.log。 - 颜色、圆角、字号优先用
--g3-*变量,不硬编码。 - 静态样式写进 scoped class;
style只放动态值。 - 跨目录引用一律
@/;同目录用./;不新增../。 - 基础设施层禁止
any:services、stores、utils、composables、跨页共享组件。 - 业务界面允许
any:views/**及其页面私有子组件;但不得出现在对外导出的公共接口上。 - 列表页查询条件统一由
buildQueryCondition生成;页面不手拼 SQL。 - 编辑页保存默认用
useSaveFlow;页面不重复写busy/try-catch/ 2003 分支。
1. 工具链
| 命令 | 作用 |
|---|---|
pnpm dev |
启动 dev server(5090,/api 代理到 8088) |
pnpm typecheck |
vue-tsc --noEmit |
pnpm lint |
oxlint . --deny-warnings |
pnpm fmt |
oxfmt --write . |
pnpm fmt:check |
只检查不改写 |
pnpm test |
Vitest 单测 |
pnpm check |
lint + fmt:check + build(build 内含 typecheck) |
约定:
- 格式化只认
oxfmt(web/.oxfmtrc.json):单引号、无分号、100 列、尾逗号、2 空格。 - lint 只认
oxlint(web/.oxlintrc.json);不要引入 ESLint / Prettier,避免两套规则打架。 - TypeScript 是严格模式(
strict、noUnusedLocals、noUnusedParameters、verbatimModuleSyntax)。 - 提交前至少跑
pnpm check;CI 必须跑pnpm check。 - 单纯格式化产生的 diff 不要和业务改动混在同一个提交里。
2. 目录与文件
| 目录 | 职责 | 不该放什么 |
|---|---|---|
src/views/ |
路由页面、页面编排 | 跨页复用的组件 |
src/components/ |
真正跨页复用的组件 | 只在一个页面用的业务组件 |
src/composables/ |
有状态的复用逻辑(useXxx) |
纯函数、UI |
src/utils/ |
纯函数、数据转换、校验 | 组件、store、请求 |
src/services/ |
HTTP 客户端与接口封装 | 业务状态、页面逻辑 |
src/stores/ |
Pinia 状态 | 直接请求(应调 services) |
src/styles/ |
全局样式、皮肤、变量入口 | 页面私有样式 |
src/preferences/ |
偏好设置(config / store / types / CSS 变量) | 业务逻辑 |
src/types/ |
真正的全局类型(env、菜单等) | 只在一个模块用的类型 |
src/constants/ |
全局常量 | 页面私有常量 |
文件命名:
- Vue 组件:
PascalCase.vue,如BizTree.vue、MenuBasicPanel.vue。 - composable:
useXxx.ts,如useBizTree.ts。 - 普通 TS:
camelCase.ts,如querySchema.ts、dataChanges.ts。 - 目录:
kebab-case,如menu-management、biz-tree。 - 页面私有子组件放页面同级目录,命名
XxxPanel.vue/XxxDrawer.vue/XxxItem.vue。 - 页面私有纯逻辑放同级
utils.ts;不要把所有东西都塞进index.vue。
引用路径:
// ✅ 同目录
import MenuBasicPanel from './MenuBasicPanel.vue'
import { buildMenuPowerCode } from './utils'
// ✅ 跨目录
import { useBizTree } from '@/composables/useBizTree'
import type { MenuRow } from '@/stores/menu'
// ❌ 不新增
import { usePreferencesStore } from '../store'
index.ts 只用于“目录对外出口”(services/api、stores、components/table),不要每个目录都建桶文件。
3. 命名
- 变量 / 函数:
camelCase;函数用动词开头:loadData、buildMenuPowerRow、resolveNodeTitle。 - 常量:
UPPER_SNAKE_CASE,如SUCCESS_CODE、AUTH_ERROR_CODES。 - 类型 / 接口:
PascalCase;不加I前缀。 - 布尔值:
is/has/can/should前缀,如isAuthenticated、canAccess。 - 事件:组件内用
update:modelValue、select、load;模板里用 kebab-case:@update:model-value。 - API 函数统一
xxxApi后缀:loadDataApi、saveObjectApi、nextIdApi。 - Store:
useXxxStore,文件名xxx.ts。 - 后端字段保持 snake_case(
b_id、b_parent_id),不要转成驼峰;它们要和 SQL / 接口一一对应。 - CSS 类名:
g3-前缀 + BEM:g3-table-shell、g3-table-shell__frame、g3-table-shell--card。
4. TypeScript
4.1 类型声明
- 对象形状用
interface;联合、别名、映射类型用type。 - 只作为类型使用的导入必须
import type(verbatimModuleSyntax会检查)。 - 导出的函数写显式返回类型;内部短函数可推断。
- 不使用
enum;用as const数组/对象 + 联合类型:
export const MENU_TYPES = ['directory', 'page', 'external'] as const
export type MenuType = (typeof MENU_TYPES)[number]
4.2 any 的使用边界
允许 any 的地方:
src/views/**里的局部变量、事件回调参数、页面私有组件的 props(当后端行结构确实动态时)。- 页面内临时拼装、快速验证的数据结构。
禁止 any 的地方:
src/services/、src/stores/、src/utils/、src/composables/、src/components/等跨页共享层。- 对外导出的函数签名、公共组件的 props / emits / expose。
- 跨文件复用的类型定义。
写法要求:
// ✅ 业务页面:动态行数据
const rows = ref<any[]>([])
// ✅ 页面私有回调
function onRowClick(row: any) {
// ...
}
// ✅ 共享层:边界用 unknown,进入函数后再收窄
export function saveObjectApi(requests: unknown[]): Promise<void> { /* ... */ }
// ❌ 共享层直接 any
export function saveObjectApi(requests: any[]): Promise<void> { /* ... */ }
补充:
- 能用具体类型 /
Record<string, unknown>时,优先不用any。 any不要跨文件扩散:页面拿到any后,传给共享函数前先收窄。- 禁止
@ts-nocheck、@ts-ignore;确需压制时用@ts-expect-error并写明原因,仅限业务页面。 as只用于外部数据边界(后端行、JSON.parse、第三方类型缺陷);优先写类型守卫,而不是连续断言。as any只在业务页面使用,且不要形成as any as T链。
4.3 其他
- 不用非空断言
!;用可选链 + 显式判空。 - 只在判空场景用
== null,其余一律===。 - 空值兜底优先
??;||只用于“空字符串也算空”的场景。 - 遍历优先
for...of;forEach只用于纯副作用且不需要提前退出。 - 深拷贝用
structuredClone;不要写递归拷贝。 - 复杂逻辑抽函数,不在模板里写长表达式。
5. Vue SFC
5.1 骨架
<script setup lang="ts">
import { computed, ref } from 'vue'
import { G3Button, G3Message } from '@g3soft/ui'
import { loadDataApi } from '@/services/api'
import { useUserStore } from '@/stores'
defineOptions({ name: 'XxxYyy' })
// 状态 → 计算属性 → 方法 → 生命周期
</script>
<template>
<div class="g3-xxx">
<!-- ... -->
</div>
</template>
<style lang="scss" scoped>
.g3-xxx {
// ...
}
</style>
- 一律
<script setup lang="ts">;不要 Options API,不要export default。 defineOptions({ name })放在 import 之后、业务代码之前。- 没有样式时可以不写
<style>;不要留空块。 - 模板里 props / 事件用 kebab-case;脚本里用 camelCase。
5.2 Props / Emits
const props = withDefaults(
defineProps<{
/** 树数据 */
treeData?: TreeRow[]
/** 是否显示虚拟根 */
showRoot?: boolean
}>(),
{
treeData: () => [],
showRoot: false,
},
)
const emit = defineEmits<{
select: [keys: TreeKey[], info: { node: TreeNodeData; nativeEvent: MouseEvent }]
'update:expandedKeys': [keys: TreeKey[]]
}>()
- 对象 / 数组默认值必须用工厂函数。
- 不要用运行时对象语法声明 props。
- 事件参数超过两个时,用对象聚合,避免位置参数越来越长。
5.3 模板
- 属性顺序:控制流(
v-if/v-for)→key→v-model→ 数据属性 → 事件。 v-for必须带稳定key;不要用数组下标作为唯一键。- 简单表达式可以内联;超过一行的判断抽
computed或方法。 - 静态样式写 class;
style只放动态值:
<!-- ✅ -->
<div class="g3-xxx" :style="{ width: `${width}px` }" />
<!-- ❌ -->
<div style="display: flex; width: 100%" />
- 不用
v-html渲染用户内容;确需使用时必须经过可信清洗。 - 插槽命名用 kebab-case;作用域插槽参数解构保持简短。
5.4 组件拆分
- 不设行数红线;出现以下信号就拆:
- 页面里有第二份独立数据源 / 第二套保存流程;
- 模板里出现重复的字段组;
- 某个区块的
ref/ 方法只服务于该区块。
- 页面负责“取数 + 编排 + 保存”,面板负责“展示 + 编辑 + 抛事件”。
- 页面持有数据所有权;子面板通过
v-model读写同一份草稿,不复制第二份状态。
6. 状态与数据流
- 只用 Pinia setup store;不要 Options store。
- 默认用
ref;reactive只用于“整体不会被替换”的局部表单对象。 - 派生状态用
computed,不要用watch手动同步两个ref。 watch只做副作用(请求、存储、DOM);需要清理的定时器 / 监听在onBeforeUnmount清理。- 从 store 取状态保持
store.xxx形式;需要解构时用storeToRefs。 - 不要在模块顶层调用
useXxxStore();在组件 setup 或函数内部调用,确保 Pinia 已激活。 - 持久化必须显式列出字段与存储位置:
persist: [
{ key: 'g3erp:login', storage: sessionStorage, pick: ['loginInfo'] },
{ key: 'g3erp:user', storage: localStorage, pick: ['userInfo'] },
]
- 不要把接口返回的原始行直接长期存进全局 store;页面状态留在页面。
- 页面刷新可恢复的状态才持久化;纯 UI 临时状态不落库。
7. 请求与错误处理
- 所有 HTTP 走
src/services/request.ts;业务代码只 import@/services/api。 - 新增接口按后端 controller 分文件,并在
services/api/index.ts导出。 - API 函数命名
xxxApi,返回业务数据而不是AxiosResponse;JSDoc 写清参数与契约坑。 - 参数命名严格跟随后端:
data域 snake_case(view_name),其余域 camelCase(moduleId);不要“顺手统一”。 - 错误处理分工:
- 网络错误 / 5xx:
request.ts统一提示; - 4xx 业务错误:调用方处理;
- 401 / 40101:
request.ts统一跳登录,业务代码不要重复处理。
- 网络错误 / 5xx:
- 调用方捕获
RequestError时,先看toasted,避免重复弹提示。 - 用户提示统一
G3Message;console.error只用于全局兜底或确实需要排查的异常,禁止console.log。 - 拼
search_condition时必须转义单引号('→'');统一使用utils/sql.ts的 helper(见 §8.4),不要在页面里手拼。 - 文件上传 / 下载按
services/api/file.ts、services/api/export.ts的既有契约处理;blob 响应没有code字段,不要按普通 JSON 拆包。
8. 列表页(查询)
适用范围:海运列表、财务列表、基础资料列表等业务列表页。 目标:列表页只负责「查询条件 → 取数 → 表格 → 分页」,查询配置与 SQL 生成不写在页面里。 状态:查询配置已落地(
utils/querySchema.ts);运行时查询区、useQueryState、buildQueryCondition尚未实现。
8.1 标准结构
列表页固定五块:
- 查询区:共享
<QueryPanel>,页面只绑定QueryValues、监听@search。 - 工具栏:新建 / 导出 / 栏位设定 / 批量动作。
- 表格:
G3Table。 - 分页:
G3Pagination(或页面统一的分页组件)。 - 详情入口:行双击 / 行内「查看」→
router.push({ path, query: { id, op: 'mod' } })。
结构示意(组件 API 以现有实现为准):
<template>
<div class="g3-list-page">
<QueryPanel :schema="querySchema" v-model="queryValues" @search="handleSearch" />
<div class="g3-list-toolbar">
<G3Button type="primary" @click="handleCreate">新建</G3Button>
<G3Button @click="handleExport">导出</G3Button>
</div>
<!-- 加载态用 G3Spin 或页面统一的 loading 遮罩;G3Table 本身没有 loading prop -->
<G3Table
:columns="columns"
:data-source="rows"
row-key="b_id"
@row-dblclick="handleEdit"
/>
<G3Pagination
v-model:current="pageNo"
v-model:page-size="pageSize"
:total="total"
@change="loadData"
/>
</div>
</template>
8.2 页面职责
页面只做四件事:
- 取数:
pageDataApi+buildQueryCondition; - 分页:
pageNo/pageSize/total; - 三态:
loading/ 空数据 /error; - 跳转:新建 / 查看 / 编辑。
不要把下面这些写进列表页:
- 查询字段的模板(属于
QueryPanel); - 查询配置的解析 / 校验(属于
querySchema); - SQL 片段拼接(属于
querySql+utils/sql); - 保存 / diff / 2003 处理(属于编辑页与
useSaveFlow)。
8.3 查询区约定
- 每个列表页只放一个
<QueryPanel>,不要复制字段模板。 - 页面通过
v-model读写QueryValues,通过@search触发查询;更多查询、tag、快捷查询都在QueryPanel内部。 QueryPanel不拉列表数据、不拼 SQL;它只负责条件 UI 与选项加载。- 选项
source = 'sql'时由QueryPanel/useQueryState调loadDataBySqlApi并缓存;页面不关心。 - 查询条件变化 →
pageNo重置为 1;查询按钮与回车行为一致。 - 重置只清
QueryValues与查询区临时状态,不清表格数据(由查询结果覆盖)。
8.4 配置与运行时边界
s_module_schema的items是唯一事实来源;字段、标签组、自定义查询只在模块配置里定义。- 运行时用户只能调整普通 / 更多查询归属(
pinned)和段内顺序。 - 运行时偏好存用户侧(建议
localStorage,keyg3erp:query-layout:{moduleId}),不回写s_module_schema。 - 运行时不能新增、编辑、删除自定义查询;
custom.condition只能来自模块配置。 - 派生运行时 schema 先
cloneData,不要原地改schema.items。
8.5 SQL 生成
页面只调 buildQueryCondition(schema, values),不手拼:
const result = await pageDataApi({
viewName: 'v_ocean_booking',
orderBy: 'b_id DESC',
searchCondition: buildQueryCondition(querySchema.value, queryValues.value),
pageNo: pageNo.value,
pageSize: pageSize.value,
})
规则:
- 空条件返回
'',不要返回1 = 1。 - 列名必须过
sqlIdentifier;值必须过sqlStringLiteral/sqlNumber/sqlInList/sqlLikeContains。 - 快捷查询内部是
OR,与其它条件是AND;含OR的分组必须加括号。 - tag 固定按
in拼;空选择跳过。 - 日期统一
YYYY-MM-DD,区间用>= start AND < nextDay。 custom.condition只来自模块配置,不能接受 URL / 用户输入 / 接口返回值。 统一使用utils/sql.ts;禁止在页面里写${field} = '${value}'。
8.6 列表行为
- 请求防串号:递增序号或
AbortController,只接受最后一次响应。 loading/ 空数据 /error三态由页面统一处理;错误优先展示errorText(e)。- 导出、栏位设定等工具栏动作按权限点禁用(
canPower/canEnterMenu)。 - 行删除走
deleteApi,确认用G3Modal.confirm;删除成功后刷新当前页。 - 列表页不写保存逻辑;需要编辑就跳详情页。
8.7 测试
tests/utils/querySql.spec.ts:operator、空值跳过、日期区间、tagin、custom、非法列名、LIKE 转义、空条件。- 列表页不需要为每个字段写单测;关键交互(搜索重置、分页、行跳转)用组件测试或 E2E 冒烟覆盖。
9. 编辑页(保存)
状态:
utils/dataChanges.ts的 diff 已落地;types/save.ts、saveObjectApi类型化、 并发保护与 2003 处理尚未实现。以下是实现时必须遵守的目标规范;未实现的代码暂不改动。
9.1 标准编辑页结构
编辑页固定四块:
- 工具栏:保存 / 新建 / 复制 / 删除。
- 主表表单:按分组渲染,字段来自模块配置。
- 子表:
G3Table行编辑或明细面板。 - 状态:
loading/saving/error。
页面职责:
- 持有草稿
draft与基线org; - 提供
validate、buildRequests、afterSave、reload等业务回调;ready/targetId/loading按页面情况补充。 - 其余保存编排交给
useSaveFlow。
| 能力 | 位置 |
|---|---|
| 保存编排 | composables/useSaveFlow.ts |
| diff | utils/dataChanges.ts |
| 请求契约 | types/save.ts |
| 页面 | 编辑页(草稿 + 回调) |
- 子面板不调
saveObjectApi,只通过v-model改草稿、通过事件请求保存。 buildRequests只读草稿,不发请求、不弹提示。dataChanges.ts不依赖 Vue / services;只做纯比较与请求组装。
9.2 请求契约
export interface SaveTableRequest {
table: string
/** 逗号分隔主键列,单列也写:'b_id' / 'b_module_id,b_field' */
key_field: string
inserts?: Array<Record<string, unknown>>
updates?: Array<Record<string, unknown>>
deletes?: Array<Record<string, unknown>>
dependent_deletes?: DependentDelete[]
guards?: {
inserts?: SaveGuard[]
updates?: SaveGuard[]
deletes?: SaveGuard[]
}
}
export interface DependentDelete {
table: string
key_field: string
source_field: string
}
export interface SaveGuard {
/** SQL 片段,{列名} 是受控占位符 */
condition: string
/** 只进日志,不参与 SQL */
refs?: string[]
/** 条件不成立时给用户的话 */
message?: string
/** 默认 true;false 时文案不追加「请刷新后重试」 */
retryable?: boolean
}
字段规则:
inserts:完整行;剔除_*/v_*/ 审计列 /b_row_version。updates:只带「变化列 + 主键」;不要整行提交,避免覆盖并发修改。deletes:只带主键列。key_field:与表主键一致,复合键用逗号分隔;不要用keyFields数组。tableChange返回SaveTableRequest;调用时只传inserts/updates/deletes,不要把hasChanges展开进请求。- 审计列
b_created_*/b_updated_*由后端统一填(DataSaveService.java:58),前端不传。 b_row_version是并发版本列,不作为普通业务列提交;需要并发保护时走$original。- 现有
b_version/mx_version属于历史兼容,新代码不要新增;版本字段目标口径是b_row_version。
9.3 页面保存流程
编辑页不手写 onSave 的 try/catch/finally,统一用 useSaveFlow。页面只提供业务回调:
const saveReady = computed(() => Boolean(selectedId.value) && !configLoading.value)
const { saving, busy, save: onSave } = useSaveFlow({
ready: () => saveReady.value,
targetId: () => String(selectedId.value ?? ''),
loading: configLoading,
validate: async () => {
const errors = await basicPanel.value?.validate()
return errors?.length ? '请修正表单中标红的错误项' : ''
},
buildRequests: buildSaveData,
afterSave: async () => {
// 同步基线或重载
},
reload: (targetId) => loadConfig(targetId),
})
保存按钮:
<G3Button
type="primary"
:loading="saving"
:disabled="!saveReady || busy"
@click="onSave"
>
保存
</G3Button>
saving只驱动按钮 loading;busy同时挡加载 / 保存,用于 disabled 和快捷键判断。- Ctrl+S 与按钮走同一个
onSave,不要各写一套。
useSaveFlow 的契约:
export interface SaveFlowOptions {
/** 当前记录是否已就绪(加载完、选中项与草稿一致) */
ready?: () => boolean
/** 本次保存的目标 id,防止保存期间切换记录 */
targetId?: () => string
/** 外部加载态,与内部 saving 合并成 busy */
loading?: MaybeRefOrGetter<boolean>
/** 校验:返回错误文案,空串通过;错误定位由页面负责 */
validate?: () => string | undefined | Promise<string | undefined>
/** 纯组装:无变更返回 null */
buildRequests: () => SaveTableRequest[] | null
/** 保存成功后的基线同步 / 重载 */
afterSave?: (targetId: string) => void | Promise<void>
/** 2003 冲突时重新加载当前记录 */
reload: (targetId: string) => void | Promise<void>
}
export function useSaveFlow(options: SaveFlowOptions): {
saving: Ref<boolean>
busy: ComputedRef<boolean>
save: () => Promise<boolean>
}
Hook 负责:
busy守卫(保存中 / 加载中);- 顺序:
validate→buildRequests→saveObjectApi→afterSave; - 无变更不调接口,提示「没有需要保存的变更」;
2001/2002/2003分支:2002已 toast 不重复,2003弹确认并重载;targetId固定;afterSave失败提示「数据已保存,但刷新失败」,不误报成保存失败;saving的try/finally。
页面规则:
validate可以是异步的,负责表单校验、跨表前置条件、错误定位/展开;面板返回错误数组时,在这里收口成一条文案。buildRequests必须同步、纯:只做 diff 与请求组装;异步准备放进validate。afterSave先比较当前选中项与targetId,已切走时只刷新树/列表,不覆盖新草稿。- 特殊流程(批量保存、自动保存、非
saveObjectApi)可以不使用 Hook,但顺序与错误码必须一致。 - 页面不再写
if (saving.value) return、try/catch/finally、2003 分支。
补充约定:
save()返回true表示提交成功(afterSave失败不影响返回值);false表示被busy/ready挡住、校验未通过、无变更或提交失败。按钮 / 快捷键一般不需要判断返回值。- 成功提示在
saveObjectApi成功后立即弹;afterSave失败只追加「数据已保存,但刷新失败」,不回滚成功状态。 afterSave执行期间saving仍为true,保存按钮保持 loading,避免刷新过程中重复提交。- 新建记录必须在
buildRequests之前取号并把主键写进草稿(nextIdApi/nextCodeApi),保证targetId非空;不要保存成功后才拿主键。 - 新建页的
ready不要求selectedId,按draft是否初始化 / 是否已取号判断;targetId用取到的主键。 - 2003 的确认文案要说明「重新加载会放弃当前未保存修改」;
G3Modal.confirm用onConfirm回调(不是 Promise),reload自己处理加载失败。 - Hook 内部固定调
saveObjectApi;特殊接口(工作流、文件)自己写流程,不要给 Hook 加submit选项。 - Hook 保持薄:不读草稿、不拼请求、不做校验、不加载数据;这些都在页面回调里。
buildRequests无变更只能返回null,不要返回[];Hook 只把null当无变更。
9.4 多表、顺序与删除
- 请求顺序:
- 插入:先主表后子表;
- 删除:先子表后主表;
- 更新:按外键依赖从主到子。
- 级联删除用
dependent_deletes声明,不要让前端先查再逐行删。 - 删除入口分清:
- 列表行删除:
deleteApi({ target: 'module', moduleCode, ids }); - 表单里移除子行:
saveObjectApi的deletes; - 模块级联删除:
saveObjectApi+dependent_deletes。
- 列表行删除:
- 不要用
dependent_deletes绕过guards;需要前置条件的删除走guards.deletes。
9.5 并发与错误处理
- 并发保护是按页面显式开启,不全局默认:
- 目标表有
b_row_version才启用; updates带$original: { b_row_version: 旧值 };- 后端成功后
b_row_version + 1。
- 目标表有
guards只在业务前置条件上使用:condition写 SQL 片段,{列名}占位符由后端替换;message必须是用户能看懂的文案;retryable: false只用于「重试也没用」的条件。
- 错误码分工:
2001保存失败:调用方提示;2002guard 不成立:request.ts已统一 toast,调用方看toasted不再重复;2003并发冲突:不自动重试;提示「数据已被他人修改」,重新加载当前行后让用户再操作。
- 捕获
RequestError时,先看toasted;已被拦截器提示过的不要重复弹。 - 不要在
catch里吞掉错误;至少console.error保留现场。
9.6 测试
tests/utils/dataChanges.spec.ts:- 新增 / 修改 / 删除;
- 复合主键;
- 数字与字符串等价(
40vs'40.00'); null/undefined/''等价;_*/v_*派生字段忽略;- 版本字段忽略;
updates只带变化列 + 主键;deletes只带主键。
tests/composables/useSaveFlow.spec.ts(mocksaveObjectApi/G3Message/G3Modal,不发真实请求):- 无变更不调
saveObjectApi,save()返回 false; busy为 true /ready为 false 时直接返回 false,不调接口;targetId在调用时固定,保存期间切换记录不影响本次请求;- 2001 走通用错误提示;
- 2002 已 toast 不重复提示;
- 2003 弹确认并调用
reload; afterSave失败时返回成功且提示「已保存但刷新失败」;- 网络错误已
toasted时不重复提示。
- 无变更不调
- 页面级:
- 无变更 →
buildSaveData()返回null; - 只有子表变化 → 只产生子表请求;
- 主表 + 子表同时变化 → 一次请求数组包含两张表;
- 2003 → 不自动重试,触发重载。
- 无变更 →
9.7 禁止
- 在页面 / 子面板里直接
import axios或saveObjectApi。 - 用
Record<string, unknown>内联拼请求对象,绕过tableChange/SaveTableRequest。 updates提交整行。- 提交
_*/v_*/ 审计列 /b_row_version。 - 一次保存拆成多个
saveObjectApi调用。 - 2003 静默重试或只弹一句提示不重载。
- 在
buildSaveData里发请求或弹提示。 buildRequests返回[]当无变更(只允许null)。- 页面手写
onSave的 busy / try-catch / 2003 分支(特殊流程除外)。 - Hook 内做 diff、表单校验、数据加载。
10. 样式
- 全局样式只放在
src/styles/;页面 / 组件样式一律<style lang="scss" scoped>。 - 样式入口顺序固定:tokens →
@g3soft/ui→ global → skin,不要调整。 - 颜色、圆角、字号、间距优先用
--g3-*变量;需要兜底时写var(--g3-color-primary, #165dff)。 - 不硬编码颜色;不要为了“看起来一样”复制色值。
- 类名用
g3-前缀 + BEM;不要起.box、.left这类无语义类名。 - 只考虑桌面端;不为移动端 / 窄屏写响应式兜底,除非需求明确要求。
- 深色模式必须用变量适配;改了颜色相关样式,至少在浅色 / 深色两种模式下各看一次。
:deep()只用于覆盖第三方组件内部结构,并在上方注释说明原因。- 避免
!important;确需覆盖第三方样式时,注释说明为什么普通选择器不够。 - 动态
style只放运行时计算值(宽高、位移、层级);能把静态部分拆成 class 就拆。
11. 注释
- 文件头写“这个文件负责什么 + 关键设计取舍 + 已知坑”,参考
views/module/menu-management/index.vue。 - 段落用分隔注释:TS 用
/* ================= xxx ================= */,SCSS 用// ---------- xxx ----------。 - 导出函数 / 类型写 JSDoc:用途、参数、返回值、契约限制。
- 注释只写 why,不逐行翻译代码:
// ❌
// 把 rows 赋给 tableData
tableData.value = rows
// ✅
// 后端分页返回的是当前页,切换页码时必须整体替换,不能追加
tableData.value = rows
- 注释用中文,标识符用英文。
- 改动代码时同步改注释;过期注释按 bug 处理。
- 不留注释掉的旧代码(需要回溯用 git)。
- TODO 统一格式:
// TODO(模块或负责人): 说明。
12. 测试
- 单测放
web/tests/,目录镜像src/:src/utils/tree.ts→tests/utils/tree.spec.ts。 - 测试里引用被测文件一律用
@/别名。 utils、composables的核心逻辑必须有单测;纯展示组件不强制。- 组件测试测行为(点击、输入、抛出的事件),不测内部实现细节。
describe/it用中文描述场景;一个用例只断言一个行为。- 测试数据用工厂函数,避免跨用例共享可变状态。
- E2E 只覆盖登录 / 冒烟 / 关键链路,不要求每个改动都补;日常开发以
pnpm test为准。 - 修 bug 时优先补一个能复现的失败用例,再修。
13. Review 清单
提交前自查:
pnpm check通过。- 没有为单一调用点新增函数 / 组件 / composable。
- 页面级逻辑能一眼看完;没有为了“架构感”多套一层。
- 没有为炫技使用难懂的链式 / 函数式写法。
- 没有在
views/components里直接 import axios。 - 新接口在
services/api有封装、有类型、有 JSDoc。 - 没有新增
console.log。 - 组件有
defineOptions({ name }),props / emits 是类型声明。 - 没有新增静态 inline style。
- 颜色 / 圆角 / 字号用的是
--g3-*变量。 - 跨目录引用用
@/,没有新增../。 - 共享层没有新增
any;业务页面的any没有泄漏到公共接口。 - 纯逻辑在
utils/ 同级utils.ts,核心逻辑有测试。 - 查询条件由
buildQueryCondition生成,列名 / 值都过utils/sql.ts的 helper。 - 保存请求由
buildSaveData产出SaveTableRequest[],只调一次saveObjectApi。 updates只带变化列 + 主键,未提交派生字段 / 审计列 /b_row_version。- 2003 并发冲突有重载分支,且没有被
toasted重复提示。 - 列表页查询区用共享
QueryPanel,没有复制字段模板。 - 编辑页保存用
useSaveFlow,没有复制onSave编排。 - 注释写的是“为什么”,不是“做了什么”。
- UI 文案遵守
CODEBUDDY.md的“不写解释性文案”约定。
14. 现有离群点
以下是当前代码里与本文不一致、但不阻塞新代码的存量问题,按模块逐步清理:
- 3 个布局图标 SFC 没有
lang="ts"(preferences/components/layout-icons/)。 - 部分简单展示组件没有
defineOptions。 PreferencesDrawer.vue有 3 处../引用。- 少量
style静态内联样式(views/ 组件模板)。 - import 分组不完全统一;不强制字母序,按“vue → 第三方 →
@/→./”分组、组间空行即可。 - SQL 字面量 helper 有 3 份,
'...'与N'...'口径不一致(useBizTree.ts、menu-management/index.vue、module-management/utils.ts)。 dataChanges.ts忽略b_version/mx_version,与后端并发字段b_row_version不一致。saveObjectApi仍是unknown[],页面请求对象是内联Record<string, unknown>。- 2003 并发冲突尚未在页面处理;
guards/$original/b_row_version前端未使用。 dataChanges.ts缺单测;errorText在 3 处重复。useQueryState/QueryPanel/useSaveFlow尚未实现;新页面按 §8 / §9 落地,不补旧写法。