Files
workspace/code/g3soft-erp/web/CODE_STYLE.md
T
2026-10-19 17:21:06 +08:00

35 KiB
Raw Blame History

前端代码风格(web)

适用范围:web/src、web/tests、web/e2e。

本文是细则;根目录 CODEBUDDY.md 只保留必须遵守的红线与常用命令。

原则:规范描述本仓库已经形成的写法,并收紧离群点,不重新发明一套风格。 新代码严格执行;存量代码按模块逐步清理,不做一次性大改。

目录

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 条:

  1. 只写 <script setup lang="ts">,SFC 顺序固定为 script → template → style。
  2. 组件必须 defineOptions({ name: 'XxxYyy' })。
  3. props / emits 用类型声明;props 有默认值时用 withDefaults。
  4. 请求只能走 @/services/api;views / components 禁止直接 import axios。
  5. 用户可见提示只用 G3Message;src 里禁止 console.log。
  6. 颜色、圆角、字号优先用 --g3-* 变量,不硬编码。
  7. 静态样式写进 scoped class;style 只放动态值。
  8. 跨目录引用一律 @/;同目录用 ./;不新增 ../。
  9. 基础设施层禁止 any:services、stores、utils、composables、跨页共享组件。
  10. 业务界面允许 any:views/** 及其页面私有子组件;但不得出现在对外导出的公共接口上。
  11. 列表页查询条件统一由 buildQueryCondition 生成;页面不手拼 SQL。
  12. 编辑页保存默认用 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 统一跳登录,业务代码不要重复处理。
  • 调用方捕获 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 标准结构

列表页固定五块:

  1. 查询区:共享 <QueryPanel>,页面只绑定 QueryValues、监听 @search。
  2. 工具栏:新建 / 导出 / 栏位设定 / 批量动作。
  3. 表格:G3Table。
  4. 分页:G3Pagination(或页面统一的分页组件)。
  5. 详情入口:行双击 / 行内「查看」→ 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,key g3erp: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、空值跳过、日期区间、tag in、custom、非法列名、LIKE 转义、空条件。
  • 列表页不需要为每个字段写单测;关键交互(搜索重置、分页、行跳转)用组件测试或 E2E 冒烟覆盖。

9. 编辑页(保存)

状态:utils/dataChanges.ts 的 diff 已落地;types/save.ts、saveObjectApi 类型化、 并发保护与 2003 处理尚未实现。以下是实现时必须遵守的目标规范;未实现的代码暂不改动。

9.1 标准编辑页结构

编辑页固定四块:

  1. 工具栏:保存 / 新建 / 复制 / 删除。
  2. 主表表单:按分组渲染,字段来自模块配置。
  3. 子表:G3Table 行编辑或明细面板。
  4. 状态: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 保存失败:调用方提示;
    • 2002 guard 不成立:request.ts 已统一 toast,调用方看 toasted 不再重复;
    • 2003 并发冲突:不自动重试;提示「数据已被他人修改」,重新加载当前行后让用户再操作。
  • 捕获 RequestError 时,先看 toasted;已被拦截器提示过的不要重复弹。
  • 不要在 catch 里吞掉错误;至少 console.error 保留现场。

9.6 测试

  • tests/utils/dataChanges.spec.ts:
    • 新增 / 修改 / 删除;
    • 复合主键;
    • 数字与字符串等价(40 vs '40.00');
    • null / undefined / '' 等价;
    • _* / v_* 派生字段忽略;
    • 版本字段忽略;
    • updates 只带变化列 + 主键;
    • deletes 只带主键。
  • tests/composables/useSaveFlow.spec.ts(mock saveObjectApi / 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 落地,不补旧写法。