@agile-team/wl-skills-kit 2.3.3 → 2.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +24 -23
  2. package/README.md +15 -146
  3. package/files/.cursor/mcp.json +8 -0
  4. package/files/.github/guides/README.md +13 -13
  5. package/files/.github/guides/architecture.md +555 -555
  6. package/files/.github/guides/mcp-setup.md +109 -0
  7. package/files/.github/guides/usage.md +184 -176
  8. package/files/.github/reports/README.md +65 -65
  9. package/files/.github/reports/SYS_DICT_INFO.md +50 -50
  10. package/files/.github/reports/SYS_MENU_INFO.md +247 -247
  11. package/files/.github/reports/SYS_PERMISSION_INFO.md +20 -20
  12. package/files/.github/reports//347/273/204/344/273/266/346/217/220/345/217/226/345/273/272/350/256/256.md +33 -33
  13. package/files/.github/reports//350/247/204/350/214/203/345/256/241/346/237/245/346/212/245/345/221/212.md +44 -44
  14. package/files/.github/skills/_compat/README.md +108 -108
  15. package/files/.github/skills/_compat/editors.json +7 -0
  16. package/files/.github/skills/_compat/headers/agents.txt +8 -8
  17. package/files/.github/skills/_compat/headers/claude-code.txt +7 -7
  18. package/files/.github/skills/_compat/headers/cline.txt +7 -7
  19. package/files/.github/skills/_compat/headers/cursor-mdc.txt +16 -16
  20. package/files/.github/skills/_compat/headers/cursor-rules.txt +7 -7
  21. package/files/.github/skills/_compat/headers/github-copilot.txt +1 -1
  22. package/files/.github/skills/_compat/headers/kiro.txt +10 -10
  23. package/files/.github/skills/_compat/headers/qoder.txt +8 -0
  24. package/files/.github/skills/_compat/headers/trae.txt +11 -11
  25. package/files/.github/skills/_compat/headers/windsurf.txt +7 -7
  26. package/files/.github/skills/_registry.md +81 -81
  27. package/files/.github/skills/core/api-contract/SKILL.md +344 -344
  28. package/files/.github/skills/core/api-contract/USAGE.md +110 -110
  29. package/files/.github/skills/core/convention-audit/SKILL.md +189 -189
  30. package/files/.github/skills/core/convention-audit/USAGE.md +99 -99
  31. package/files/.github/skills/core/page-codegen/SKILL.md +973 -973
  32. package/files/.github/skills/core/page-codegen/USAGE.md +102 -102
  33. package/files/.github/skills/core/page-codegen/templates/_index.md +46 -46
  34. package/files/.github/skills/core/page-codegen/templates/domains/_CONTRIBUTING.md +107 -107
  35. package/files/.github/skills/core/page-codegen/templates/domains/produce/TPL-OPERATION-STATION.md +442 -442
  36. package/files/.github/skills/core/page-codegen/templates/domains/sale/README.md +26 -26
  37. package/files/.github/skills/core/page-codegen/templates/universal/TPL-CHANGE-HISTORY.md +276 -276
  38. package/files/.github/skills/core/page-codegen/templates/universal/TPL-DETAIL-TABS.md +1145 -1145
  39. package/files/.github/skills/core/page-codegen/templates/universal/TPL-DRIVEN.md +309 -309
  40. package/files/.github/skills/core/page-codegen/templates/universal/TPL-FORM-ROUTE.md +436 -436
  41. package/files/.github/skills/core/page-codegen/templates/universal/TPL-LIST.md +191 -191
  42. package/files/.github/skills/core/page-codegen/templates/universal/TPL-MASTER-DETAIL.md +148 -148
  43. package/files/.github/skills/core/page-codegen/templates/universal/TPL-RECORD-FORM.md +376 -376
  44. package/files/.github/skills/core/page-codegen/templates/universal/TPL-TREE-LIST.md +186 -186
  45. package/files/.github/skills/core/prototype-scan/SKILL.md +498 -498
  46. package/files/.github/skills/core/prototype-scan/USAGE.md +95 -95
  47. package/files/.github/skills/core/template-extract/SKILL.md +139 -139
  48. package/files/.github/skills/core/template-extract/USAGE.md +93 -93
  49. package/files/.github/skills/domain/README.md +51 -51
  50. package/files/.github/skills/sync/menu-sync/SKILL.md +263 -263
  51. package/files/.github/skills/sync/menu-sync/USAGE.md +104 -104
  52. package/files/.github/skills/sync/menu-sync/env/env.local.json +7 -7
  53. package/files/.github/skills/sync/menu-sync/env/guide.md +99 -99
  54. package/files/.github/skills/sync/permission-sync/SKILL.draft.md +91 -91
  55. package/files/.github/standards/01-toolchain.md +57 -57
  56. package/files/.github/standards/02-code-structure.md +111 -111
  57. package/files/.github/standards/03-comments.md +53 -53
  58. package/files/.github/standards/04-coding-basics.md +33 -33
  59. package/files/.github/standards/05-logging.md +38 -38
  60. package/files/.github/standards/06-security.md +44 -44
  61. package/files/.github/standards/07-config.md +52 -52
  62. package/files/.github/standards/08-git.md +60 -60
  63. package/files/.github/standards/09-typescript.md +71 -71
  64. package/files/.github/standards/10-pinia.md +57 -57
  65. package/files/.github/standards/11-form-validation.md +81 -81
  66. package/files/.github/standards/12-base-table.md +153 -153
  67. package/files/.github/standards/13-platform-components.md +123 -123
  68. package/files/.github/standards/index.md +89 -89
  69. package/files/.kiro/settings/mcp.json +8 -0
  70. package/files/.mcp.json +8 -0
  71. package/files/.vscode/mcp.json +9 -0
  72. package/files/demo/produce/aiflow/mmwr-customer-apply-change-history/data.ts +196 -196
  73. package/files/demo/produce/aiflow/mmwr-customer-apply-change-history/index.scss +150 -150
  74. package/files/demo/produce/aiflow/mmwr-customer-apply-change-history/index.vue +79 -79
  75. package/files/docs/jh-date-range.md +257 -257
  76. package/files/docs/jh-date.md +222 -222
  77. package/files/docs/jh-dept-picker.md +190 -190
  78. package/files/docs/jh-drag-row.md +590 -590
  79. package/files/docs/jh-file-upload.md +216 -216
  80. package/files/docs/jh-picker.md +218 -218
  81. package/files/docs/jh-select.md +148 -148
  82. package/files/docs/jh-text.md +248 -248
  83. package/files/docs/jh-user-picker.md +197 -197
  84. package/files/src/components/global/C_RightToolbar/data.ts +228 -228
  85. package/files/src/components/global/C_RightToolbar/index.scss +44 -44
  86. package/files/src/components/global/C_Splitter/index.scss +61 -61
  87. package/files/src/components/global/C_SvgIcon/index.scss +15 -15
  88. package/files/src/components/global/C_TagStatus/index.scss +20 -20
  89. package/files/src/components/global/C_Tree/data.ts +61 -61
  90. package/files/src/components/local/c_listModal/index.scss +4 -4
  91. package/package.json +1 -1
@@ -2,440 +2,440 @@
2
2
 
3
3
  > 见 SKILL.md 主文件(约束 + 按钮规则 + Mock 规范等共用规则)。
4
4
 
5
-
6
- > 复杂表单(多 Tab、多子表、独立布局)使用独立路由而非弹窗。
7
- > 表单页 `data.ts` **不继承 `AbstractPageQueryHook`**,改为导出 `useXxx` Composable。
8
- > 需在 `pages.ts` 单独注册路由,路径规则见"FORM_ROUTE 表单页"章节。
9
-
10
- #### data.ts
11
-
12
- ```typescript
13
- import { getAction, postAction } from "@jhlc/common-core/src/api/action";
14
- import { ElMessage } from "element-plus";
15
- import { useRouter } from "vue-router"; // ✅ 仅用于 router.back()
16
-
17
- export const API_CONFIG = {
18
- getById: "/[服务缩写]/[资源名]/getById",
19
- save: "/[服务缩写]/[资源名]/save",
20
- submit: "/[服务缩写]/[资源名]/submit"
21
- } as const;
22
-
23
- export function use[PageName]Form(tabsRef: any) {
24
- const router = useRouter();
25
- const loading = ref(false);
26
- const isEdit = ref(false);
27
- const currentId = ref<string>("");
28
-
29
- async function loadDetail(id: string) {
30
- loading.value = true;
31
- isEdit.value = true;
32
- currentId.value = id;
33
- try {
34
- const res = await getAction(API_CONFIG.getById, { id });
35
- if (res?.data) tabsRef.value?.loadData(res.data);
36
- } finally {
37
- loading.value = false;
38
- }
39
- }
40
-
41
- async function handleSave() {
42
- const valid = await tabsRef.value?.validate();
43
- if (!valid) { ElMessage.warning("请完善必填项"); return; }
44
- loading.value = true;
45
- try {
46
- const formData = tabsRef.value?.collectFormData();
47
- const payload = isEdit.value ? { ...formData, id: currentId.value } : formData;
48
- const res = await postAction(API_CONFIG.save, payload);
49
- if (res?.code === 200) {
50
- ElMessage.success("保存成功");
51
- if (!isEdit.value && res.data?.id) {
52
- currentId.value = res.data.id;
53
- isEdit.value = true;
54
- }
55
- }
56
- } finally {
57
- loading.value = false;
58
- }
59
- }
60
-
61
- function handleCancel() {
62
- router.back(); // ✅ back() 允许,不影响菜单激活
63
- }
64
-
65
- return { loading, isEdit, loadDetail, handleSave, handleCancel };
66
- }
67
- ```
68
-
69
- #### index.vue
70
-
71
- ```vue
72
- <template>
73
- <div class="app-container app-page-container" v-loading="loading">
74
- <div class="page-header">
75
- <span class="page-title">[页面标题]</span>
76
- <span class="page-tag page-tag--add">新增</span>
77
- <el-checkbox v-model="onlyRequired" class="only-required-check">只看必填项</el-checkbox>
78
- </div>
79
- <div class="page-toolbar">
80
- <el-button type="primary" @click="handleSave">保存</el-button>
81
- <el-button @click="handleCancel">取消</el-button>
82
- </div>
83
- <c_[业务名]Tabs ref="tabsRef" mode="add" :only-required="onlyRequired" />
84
- </div>
85
- </template>
86
-
87
- <script setup lang="ts">
88
- import { useRoute } from "vue-router";
89
- import { use[PageName]Form } from "./data";
90
- import c_[业务名]Tabs from "@/components/local/c_[业务名]Tabs/index.vue";
91
-
92
- const tabsRef = ref();
93
- const route = useRoute();
94
- const onlyRequired = ref(false);
95
- const { loading, loadDetail, handleSave, handleCancel } = use[PageName]Form(tabsRef);
96
-
97
- onMounted(() => {
98
- const id = route.query.id as string;
99
- if (id) loadDetail(id);
100
- });
101
- </script>
102
-
103
- <style scoped lang="scss">
104
- @import "./index.scss";
105
- </style>
106
- ```
107
-
108
- ---
109
-
110
- ### Template C: FLAT_DETAIL 平铺详情页
111
-
112
- > 适用场景:单页平铺 Section 式详情/编辑页面(无 Tab 组件),如「临时客户档案详情」。
113
- > 与 Template B 的区别:不使用 `c_[业务名]Tabs`,而是直接在 `el-form` 中按 Section 分块铺设表单字段。
114
-
115
- #### C-1 data.ts 模板
116
-
117
- ```typescript
118
- import { getAction, postAction } from "@jhlc/common-core/src/api/action";
119
- import { ElMessage, ElMessageBox } from "element-plus";
120
- import { useRouter } from "vue-router";
121
-
122
- export const API_CONFIG = {
123
- getById: "/sale/[业务名]/getById",
124
- save: "/sale/[业务名]/save"
125
- // ...其他业务操作
126
- } as const;
127
-
128
- export const OPTS = {
129
- // 下拉选项集合
130
- // [字段名]: [{ label: "显示文本", value: "值" }]
131
- };
132
-
133
- export interface [PageName]Form {
134
- id: string;
135
- // ...所有字段
136
- }
137
-
138
- /** 开发期 Mock 数据 */
139
- export function createMockData(): [PageName]Form {
140
- return {
141
- id: "mock-001"
142
- // ...所有字段的模拟值
143
- };
144
- }
145
-
146
- export function use[PageName]Detail() {
147
- const router = useRouter();
148
- const loading = ref(false);
149
- const form = reactive<[PageName]Form>(createMockData());
150
-
151
- async function loadDetail(id: string) {
152
- loading.value = true;
153
- try {
154
- const res = await getAction(API_CONFIG.getById, { id });
155
- if (res?.data) Object.assign(form, res.data);
156
- } finally {
157
- loading.value = false;
158
- }
159
- }
160
-
161
- async function handleSave() {
162
- loading.value = true;
163
- try {
164
- const res = await postAction(API_CONFIG.save, { ...form });
165
- if (res?.code === 200) ElMessage.success("保存成功");
166
- } finally {
167
- loading.value = false;
168
- }
169
- }
170
-
171
- function handleCancel() { router.back(); }
172
-
173
- return { loading, form, loadDetail, handleSave, handleCancel };
174
- }
175
- ```
176
-
177
- #### C-2 index.vue 模板
178
-
179
- ```vue
180
- <template>
181
- <div class="app-container [page-class]" v-loading="loading">
182
- <!-- 标题栏 -->
183
- <div class="title-bar">
184
- <span class="customer-name">{{ form.[标题字段] }}</span>
185
- <el-tag type="warning" effect="plain" size="small">{{ form.[状态字段] }}</el-tag>
186
- </div>
187
-
188
- <!-- 工具栏 -->
189
- <div class="page-toolbar">
190
- <el-button type="primary" @click="handleSave">保存</el-button>
191
- <!-- ...其他按钮 -->
192
- <el-button @click="handleCancel">返回</el-button>
193
- </div>
194
-
195
- <el-form :model="form" label-position="top" class="detail-form">
196
- <!-- 头部信息网格 -->
197
- <div class="header-info">
198
- <el-row :gutter="12">
199
- <el-col :span="4">
200
- <el-form-item label="[字段名]">
201
- <el-input v-model="form.[字段]" disabled />
202
- </el-form-item>
203
- </el-col>
204
- <!-- ...更多头部字段 -->
205
- </el-row>
206
- </div>
207
-
208
- <!-- Section: 按业务分块,每个 Section 一个 .form-section -->
209
- <div class="form-section">
210
- <div class="section-title">[分区名称]</div>
211
- <el-row :gutter="12">
212
- <el-col :span="[n]">
213
- <el-form-item label="[字段名]">
214
- <el-input v-model="form.[字段]" />
215
- </el-form-item>
216
- </el-col>
217
- <!-- ...更多字段 -->
218
- </el-row>
219
- </div>
220
-
221
- <!-- 子表格 Section(如跟进记录) -->
222
- <div class="form-section">
223
- <div class="section-title">[表格标题]</div>
224
- <el-table :data="form.[列表字段]" border size="small">
225
- <el-table-column type="index" label="序号" width="55" align="center" />
226
- <!-- ...更多列 -->
227
- <el-table-column label="操作" width="100" fixed="right">
228
- <template #default="{ $index }">
229
- <el-button type="primary" link size="small">编辑</el-button>
230
- <el-button type="danger" link size="small" @click="removeRecord($index)">删除</el-button>
231
- </template>
232
- </el-table-column>
233
- </el-table>
234
- <div class="add-row-btn" @click="addRecord">+ 新增行</div>
235
- </div>
236
- </el-form>
237
- </div>
238
- </template>
239
-
240
- <script setup lang="ts">
241
- import { useRoute } from "vue-router";
242
- import { use[PageName]Detail, OPTS } from "./data";
243
-
244
- const route = useRoute();
245
- const { loading, form, loadDetail, handleSave, handleCancel } = use[PageName]Detail();
246
-
247
- onMounted(() => {
248
- const id = route.query.id as string;
249
- if (id) loadDetail(id);
250
- });
251
- </script>
252
-
253
- <style scoped lang="scss">
254
- @import "./index.scss";
255
- </style>
256
- ```
257
-
258
- #### C-3 index.scss 要点
259
-
260
- ```scss
261
- .[page-class] {
262
- padding: 0 !important;
263
- display: flex;
264
- flex-direction: column;
265
- overflow: hidden;
266
-
267
- .title-bar { /* 标题 + 状态 Tag,灰色背景 */ }
268
- .page-toolbar { /* 按钮行,白底,底部边框 */ }
269
- .detail-form { flex: 1; overflow-y: auto; padding: 0 16px 16px; }
270
- .header-info { padding: 12px 0 4px; border-bottom: 1px solid #f0f2f5; }
271
- .form-section { margin-top: 16px;
272
- .section-title { border-left: 3px solid var(--el-color-primary); padding-left: 10px; font-weight: 600; }
273
- }
274
- .add-row-btn { color: #409eff; cursor: pointer; margin-top: 8px; }
275
- .el-form-item { margin-bottom: 10px; }
276
- }
277
- ```
278
-
279
- ---
280
-
281
- ## Template D: MULTI_TABLE — 多表联动实绩页
282
-
283
- > 适用场景:多个 BaseTable 上下/左右联动,选中上表行驱动下表查询。
284
- > 典型页面:精整实绩(抛丸/倒棱/矫直/酸洗/剥皮/检验/包装)、加热管理(装炉/出炉)、剔钢操作。
285
- >
286
- > 项目中已有两种落地方式:
287
- > - **配置驱动模板组件**:`FinishingAchievementTemplate`(7 个精整页面共用)
288
- > - **独立页面编排**:`mmwr-heating-management`、`mmwr-steel-stripping-operations`
289
-
290
- ### D-0 核心特征
291
-
292
- | 特征 | 说明 |
293
- |---|---|
294
- | **多 AbstractPageQueryHook 实例** | 每个表格区域一个实例,各自管理 `list/page/queryParam/columns` |
295
- | **主从联动** | 选中上表行 → 调用下表实例的 `selectByPlan(row)` 驱动查询 |
296
- | **可拖拽分隔** | `<jh-drag-row :top-height="N">` 上下分隔,可嵌套 |
297
- | **Tab 切换** | `<el-tabs type="border-card">` 或 `<jh-tabs>` 切换录入/查询视角 |
298
- | **操作区** | 在上下表之间放置 `BaseForm` + 按钮,或 `BaseToolbar` |
299
- | **懒加载** | Tab 切换时才加载对应数据,避免首次全量查询 |
300
-
301
- ### D-1 判断何时使用配置驱动 vs 独立编排
302
-
303
- | 条件 | 方式 |
304
- |---|---|
305
- | 3+ 页面布局完全相同,仅 API/工序代码/列不同 | 提取 `src/components/template/XxxTemplate/`,页面仅传 config |
306
- | 页面布局有显著差异(不同 Tab 结构、不同表数量) | 独立页面,在 data.ts 中定义多个 `createXxxPage()` |
307
-
308
- ### D-2 配置驱动模板组件结构(参考 FinishingAchievementTemplate)
309
-
310
- ```
311
- src/components/template/[TemplateName]/
312
- ├── index.vue ← 模板组件(接收 config prop)
313
- ├── data.ts ← createXxxPage() 工厂函数
314
- ├── types.ts ← 配置类型定义(ApiConfig, ColumnsConfig, UiConfig 等)
315
- ├── index.scss ← 模板样式
316
- └── README.md ← 使用说明
317
-
318
- src/views/.../[page-name]/
319
- ├── index.vue ← <TemplateName :config="xxxConfig" />(极简)
320
- └── data.ts ← export const xxxConfig: FinishingAchievementConfig = { ... }
321
- ```
322
-
323
- **types.ts 要点**:
324
- ```typescript
325
- export interface XxxTemplateConfig {
326
- api: Record<string, string>; // 各表格 API 端点
327
- processCode: string; // 工序标识,用于查询参数
328
- query?: { plan?: { items: BaseQueryItemDesc<any>[]; defaultParams?: Record<string, any> } };
329
- columns?: { planColumns: TableColumnDesc<any>[]; detailColumns: TableColumnDesc<any>[] };
330
- ui?: Partial<UiConfig>; // 可选 UI 覆盖(Tab 标题、区域标题等)
331
- }
332
- ```
333
-
334
- **页面 data.ts 要点**(仅配置,不写逻辑):
335
- ```typescript
336
- import type { XxxTemplateConfig } from "@/components/template/XxxTemplate/types";
337
-
338
- export const xxxConfig: XxxTemplateConfig = {
339
- api: { planList: "/mmwr/...", materialList: "/mmwr/..." },
340
- processCode: "PW",
341
- query: { plan: { items: [...], defaultParams: { firstProcess: "D", subBacklogCode: "PW" } } }
342
- };
343
- ```
344
-
345
- ### D-3 独立编排页面结构(参考 mmwr-steel-stripping-operations)
346
-
347
- **data.ts 要点**(多个 createPage 工厂函数):
348
- ```typescript
349
- // 上表
350
- export function createEntryPage() {
351
- return new (class extends AbstractPageQueryHook {
352
- constructor() { super({ url: { list: API_CONFIG.planList }, page: { current: 1, size: 10 } }); }
353
- queryDef() { return [...]; }
354
- toolbarDef() { return []; }
355
- columnsDef() { return [...]; }
356
- })();
357
- }
358
-
359
- // 下表(主从联动)
360
- export function createEntryBottomPage(rejectForm: any) {
361
- const Page = new (class extends AbstractPageQueryHook {
362
- constructor() { super({ url: { list: API_CONFIG.detailList } }); }
363
- queryDef() { return []; }
364
- toolbarDef() { return [...]; }
365
- columnsDef() { return [...]; }
366
- // 关键:由上表行驱动查询
367
- async selectByPlan(planRow: any) {
368
- this.queryParam.value.loNo = planRow.loNo;
369
- this.queryParam.value.lotNo = planRow.lotNo;
370
- await this.select();
371
- }
372
- })();
373
- return Page;
374
- }
375
- ```
376
-
377
- **index.vue 要点**:
378
- ```vue
379
- <template>
380
- <div class="app-container app-page-container [page-class]">
381
- <el-tabs v-model="activeTab" type="border-card">
382
- <el-tab-pane label="录入" name="entry">
383
- <jh-drag-row :top-height="420">
384
- <template #top>
385
- <BaseQuery :form="..." :items="..." @select="..." @reset="..." />
386
- <BaseTable ref="..." :data="..." :columns="..." highlight-current-row @current-change="handleRowClick" />
387
- <jh-pagination ... />
388
- </template>
389
- <template #bottom>
390
- <BaseToolbar v-if="selectedRow" :items="..." />
391
- <el-empty v-if="!selectedRow" description="请先在上方列表中选择一行数据" />
392
- <BaseTable v-else ref="..." :data="..." :columns="..." />
393
- <jh-pagination ... />
394
- </template>
395
- </jh-drag-row>
396
- </el-tab-pane>
397
- <el-tab-pane label="查询" name="query" lazy>
398
- <!-- 标准 LIST 模式 -->
399
- </el-tab-pane>
400
- </el-tabs>
401
- </div>
402
- </template>
403
-
404
- <script setup lang="ts">
405
- import { createEntryPage, createEntryBottomPage, createQueryPage } from "./data";
406
-
407
- const activeTab = ref("entry");
408
- const selectedRow = ref(null);
409
-
410
- const EntryPage = createEntryPage();
411
- const { tableRef, page, queryParam, list, queryItems, columns, select } = EntryPage;
412
-
413
- const BottomPage = createEntryBottomPage();
414
- const { list: bottomList, columns: bottomColumns, select: bottomSelect, selectByPlan } = BottomPage;
415
-
416
- const handleRowClick = (row: any) => {
417
- selectedRow.value = row;
418
- if (row) selectByPlan(row);
419
- };
420
-
421
- onMounted(() => select());
422
- watch(activeTab, (tab) => { if (tab === "query") QueryPage.select(); });
423
- </script>
424
- ```
425
-
426
- ### D-4 index.scss 要点
427
-
428
- ```scss
429
- .[page-class] {
430
- .section-header { display: flex; align-items: center; gap: 6px; margin: 8px 0; }
431
- .section-header .title-bar { width: 3px; height: 14px; background: var(--el-color-primary); border-radius: 1px; }
432
- .section-header .section-title { font-size: 14px; font-weight: 600; margin: 0; }
433
- .empty-tip { padding: 40px 0; }
434
- .operation-area { padding: 8px 0; }
435
- .operation-buttons { display: flex; gap: 8px; margin: 8px 0; }
436
- .results-container { display: flex; gap: 16px; /* 左右分栏时 */ }
437
- .results-container .section { flex: 1; min-width: 0; }
438
- }
439
- ```
440
-
5
+
6
+ > 复杂表单(多 Tab、多子表、独立布局)使用独立路由而非弹窗。
7
+ > 表单页 `data.ts` **不继承 `AbstractPageQueryHook`**,改为导出 `useXxx` Composable。
8
+ > 需在 `pages.ts` 单独注册路由,路径规则见"FORM_ROUTE 表单页"章节。
9
+
10
+ #### data.ts
11
+
12
+ ```typescript
13
+ import { getAction, postAction } from "@jhlc/common-core/src/api/action";
14
+ import { ElMessage } from "element-plus";
15
+ import { useRouter } from "vue-router"; // ✅ 仅用于 router.back()
16
+
17
+ export const API_CONFIG = {
18
+ getById: "/[服务缩写]/[资源名]/getById",
19
+ save: "/[服务缩写]/[资源名]/save",
20
+ submit: "/[服务缩写]/[资源名]/submit"
21
+ } as const;
22
+
23
+ export function use[PageName]Form(tabsRef: any) {
24
+ const router = useRouter();
25
+ const loading = ref(false);
26
+ const isEdit = ref(false);
27
+ const currentId = ref<string>("");
28
+
29
+ async function loadDetail(id: string) {
30
+ loading.value = true;
31
+ isEdit.value = true;
32
+ currentId.value = id;
33
+ try {
34
+ const res = await getAction(API_CONFIG.getById, { id });
35
+ if (res?.data) tabsRef.value?.loadData(res.data);
36
+ } finally {
37
+ loading.value = false;
38
+ }
39
+ }
40
+
41
+ async function handleSave() {
42
+ const valid = await tabsRef.value?.validate();
43
+ if (!valid) { ElMessage.warning("请完善必填项"); return; }
44
+ loading.value = true;
45
+ try {
46
+ const formData = tabsRef.value?.collectFormData();
47
+ const payload = isEdit.value ? { ...formData, id: currentId.value } : formData;
48
+ const res = await postAction(API_CONFIG.save, payload);
49
+ if (res?.code === 200) {
50
+ ElMessage.success("保存成功");
51
+ if (!isEdit.value && res.data?.id) {
52
+ currentId.value = res.data.id;
53
+ isEdit.value = true;
54
+ }
55
+ }
56
+ } finally {
57
+ loading.value = false;
58
+ }
59
+ }
60
+
61
+ function handleCancel() {
62
+ router.back(); // ✅ back() 允许,不影响菜单激活
63
+ }
64
+
65
+ return { loading, isEdit, loadDetail, handleSave, handleCancel };
66
+ }
67
+ ```
68
+
69
+ #### index.vue
70
+
71
+ ```vue
72
+ <template>
73
+ <div class="app-container app-page-container" v-loading="loading">
74
+ <div class="page-header">
75
+ <span class="page-title">[页面标题]</span>
76
+ <span class="page-tag page-tag--add">新增</span>
77
+ <el-checkbox v-model="onlyRequired" class="only-required-check">只看必填项</el-checkbox>
78
+ </div>
79
+ <div class="page-toolbar">
80
+ <el-button type="primary" @click="handleSave">保存</el-button>
81
+ <el-button @click="handleCancel">取消</el-button>
82
+ </div>
83
+ <c_[业务名]Tabs ref="tabsRef" mode="add" :only-required="onlyRequired" />
84
+ </div>
85
+ </template>
86
+
87
+ <script setup lang="ts">
88
+ import { useRoute } from "vue-router";
89
+ import { use[PageName]Form } from "./data";
90
+ import c_[业务名]Tabs from "@/components/local/c_[业务名]Tabs/index.vue";
91
+
92
+ const tabsRef = ref();
93
+ const route = useRoute();
94
+ const onlyRequired = ref(false);
95
+ const { loading, loadDetail, handleSave, handleCancel } = use[PageName]Form(tabsRef);
96
+
97
+ onMounted(() => {
98
+ const id = route.query.id as string;
99
+ if (id) loadDetail(id);
100
+ });
101
+ </script>
102
+
103
+ <style scoped lang="scss">
104
+ @import "./index.scss";
105
+ </style>
106
+ ```
107
+
108
+ ---
109
+
110
+ ### Template C: FLAT_DETAIL 平铺详情页
111
+
112
+ > 适用场景:单页平铺 Section 式详情/编辑页面(无 Tab 组件),如「临时客户档案详情」。
113
+ > 与 Template B 的区别:不使用 `c_[业务名]Tabs`,而是直接在 `el-form` 中按 Section 分块铺设表单字段。
114
+
115
+ #### C-1 data.ts 模板
116
+
117
+ ```typescript
118
+ import { getAction, postAction } from "@jhlc/common-core/src/api/action";
119
+ import { ElMessage, ElMessageBox } from "element-plus";
120
+ import { useRouter } from "vue-router";
121
+
122
+ export const API_CONFIG = {
123
+ getById: "/sale/[业务名]/getById",
124
+ save: "/sale/[业务名]/save"
125
+ // ...其他业务操作
126
+ } as const;
127
+
128
+ export const OPTS = {
129
+ // 下拉选项集合
130
+ // [字段名]: [{ label: "显示文本", value: "值" }]
131
+ };
132
+
133
+ export interface [PageName]Form {
134
+ id: string;
135
+ // ...所有字段
136
+ }
137
+
138
+ /** 开发期 Mock 数据 */
139
+ export function createMockData(): [PageName]Form {
140
+ return {
141
+ id: "mock-001"
142
+ // ...所有字段的模拟值
143
+ };
144
+ }
145
+
146
+ export function use[PageName]Detail() {
147
+ const router = useRouter();
148
+ const loading = ref(false);
149
+ const form = reactive<[PageName]Form>(createMockData());
150
+
151
+ async function loadDetail(id: string) {
152
+ loading.value = true;
153
+ try {
154
+ const res = await getAction(API_CONFIG.getById, { id });
155
+ if (res?.data) Object.assign(form, res.data);
156
+ } finally {
157
+ loading.value = false;
158
+ }
159
+ }
160
+
161
+ async function handleSave() {
162
+ loading.value = true;
163
+ try {
164
+ const res = await postAction(API_CONFIG.save, { ...form });
165
+ if (res?.code === 200) ElMessage.success("保存成功");
166
+ } finally {
167
+ loading.value = false;
168
+ }
169
+ }
170
+
171
+ function handleCancel() { router.back(); }
172
+
173
+ return { loading, form, loadDetail, handleSave, handleCancel };
174
+ }
175
+ ```
176
+
177
+ #### C-2 index.vue 模板
178
+
179
+ ```vue
180
+ <template>
181
+ <div class="app-container [page-class]" v-loading="loading">
182
+ <!-- 标题栏 -->
183
+ <div class="title-bar">
184
+ <span class="customer-name">{{ form.[标题字段] }}</span>
185
+ <el-tag type="warning" effect="plain" size="small">{{ form.[状态字段] }}</el-tag>
186
+ </div>
187
+
188
+ <!-- 工具栏 -->
189
+ <div class="page-toolbar">
190
+ <el-button type="primary" @click="handleSave">保存</el-button>
191
+ <!-- ...其他按钮 -->
192
+ <el-button @click="handleCancel">返回</el-button>
193
+ </div>
194
+
195
+ <el-form :model="form" label-position="top" class="detail-form">
196
+ <!-- 头部信息网格 -->
197
+ <div class="header-info">
198
+ <el-row :gutter="12">
199
+ <el-col :span="4">
200
+ <el-form-item label="[字段名]">
201
+ <el-input v-model="form.[字段]" disabled />
202
+ </el-form-item>
203
+ </el-col>
204
+ <!-- ...更多头部字段 -->
205
+ </el-row>
206
+ </div>
207
+
208
+ <!-- Section: 按业务分块,每个 Section 一个 .form-section -->
209
+ <div class="form-section">
210
+ <div class="section-title">[分区名称]</div>
211
+ <el-row :gutter="12">
212
+ <el-col :span="[n]">
213
+ <el-form-item label="[字段名]">
214
+ <el-input v-model="form.[字段]" />
215
+ </el-form-item>
216
+ </el-col>
217
+ <!-- ...更多字段 -->
218
+ </el-row>
219
+ </div>
220
+
221
+ <!-- 子表格 Section(如跟进记录) -->
222
+ <div class="form-section">
223
+ <div class="section-title">[表格标题]</div>
224
+ <el-table :data="form.[列表字段]" border size="small">
225
+ <el-table-column type="index" label="序号" width="55" align="center" />
226
+ <!-- ...更多列 -->
227
+ <el-table-column label="操作" width="100" fixed="right">
228
+ <template #default="{ $index }">
229
+ <el-button type="primary" link size="small">编辑</el-button>
230
+ <el-button type="danger" link size="small" @click="removeRecord($index)">删除</el-button>
231
+ </template>
232
+ </el-table-column>
233
+ </el-table>
234
+ <div class="add-row-btn" @click="addRecord">+ 新增行</div>
235
+ </div>
236
+ </el-form>
237
+ </div>
238
+ </template>
239
+
240
+ <script setup lang="ts">
241
+ import { useRoute } from "vue-router";
242
+ import { use[PageName]Detail, OPTS } from "./data";
243
+
244
+ const route = useRoute();
245
+ const { loading, form, loadDetail, handleSave, handleCancel } = use[PageName]Detail();
246
+
247
+ onMounted(() => {
248
+ const id = route.query.id as string;
249
+ if (id) loadDetail(id);
250
+ });
251
+ </script>
252
+
253
+ <style scoped lang="scss">
254
+ @import "./index.scss";
255
+ </style>
256
+ ```
257
+
258
+ #### C-3 index.scss 要点
259
+
260
+ ```scss
261
+ .[page-class] {
262
+ padding: 0 !important;
263
+ display: flex;
264
+ flex-direction: column;
265
+ overflow: hidden;
266
+
267
+ .title-bar { /* 标题 + 状态 Tag,灰色背景 */ }
268
+ .page-toolbar { /* 按钮行,白底,底部边框 */ }
269
+ .detail-form { flex: 1; overflow-y: auto; padding: 0 16px 16px; }
270
+ .header-info { padding: 12px 0 4px; border-bottom: 1px solid #f0f2f5; }
271
+ .form-section { margin-top: 16px;
272
+ .section-title { border-left: 3px solid var(--el-color-primary); padding-left: 10px; font-weight: 600; }
273
+ }
274
+ .add-row-btn { color: #409eff; cursor: pointer; margin-top: 8px; }
275
+ .el-form-item { margin-bottom: 10px; }
276
+ }
277
+ ```
278
+
279
+ ---
280
+
281
+ ## Template D: MULTI_TABLE — 多表联动实绩页
282
+
283
+ > 适用场景:多个 BaseTable 上下/左右联动,选中上表行驱动下表查询。
284
+ > 典型页面:精整实绩(抛丸/倒棱/矫直/酸洗/剥皮/检验/包装)、加热管理(装炉/出炉)、剔钢操作。
285
+ >
286
+ > 项目中已有两种落地方式:
287
+ > - **配置驱动模板组件**:`FinishingAchievementTemplate`(7 个精整页面共用)
288
+ > - **独立页面编排**:`mmwr-heating-management`、`mmwr-steel-stripping-operations`
289
+
290
+ ### D-0 核心特征
291
+
292
+ | 特征 | 说明 |
293
+ |---|---|
294
+ | **多 AbstractPageQueryHook 实例** | 每个表格区域一个实例,各自管理 `list/page/queryParam/columns` |
295
+ | **主从联动** | 选中上表行 → 调用下表实例的 `selectByPlan(row)` 驱动查询 |
296
+ | **可拖拽分隔** | `<jh-drag-row :top-height="N">` 上下分隔,可嵌套 |
297
+ | **Tab 切换** | `<el-tabs type="border-card">` 或 `<jh-tabs>` 切换录入/查询视角 |
298
+ | **操作区** | 在上下表之间放置 `BaseForm` + 按钮,或 `BaseToolbar` |
299
+ | **懒加载** | Tab 切换时才加载对应数据,避免首次全量查询 |
300
+
301
+ ### D-1 判断何时使用配置驱动 vs 独立编排
302
+
303
+ | 条件 | 方式 |
304
+ |---|---|
305
+ | 3+ 页面布局完全相同,仅 API/工序代码/列不同 | 提取 `src/components/template/XxxTemplate/`,页面仅传 config |
306
+ | 页面布局有显著差异(不同 Tab 结构、不同表数量) | 独立页面,在 data.ts 中定义多个 `createXxxPage()` |
307
+
308
+ ### D-2 配置驱动模板组件结构(参考 FinishingAchievementTemplate)
309
+
310
+ ```
311
+ src/components/template/[TemplateName]/
312
+ ├── index.vue ← 模板组件(接收 config prop)
313
+ ├── data.ts ← createXxxPage() 工厂函数
314
+ ├── types.ts ← 配置类型定义(ApiConfig, ColumnsConfig, UiConfig 等)
315
+ ├── index.scss ← 模板样式
316
+ └── README.md ← 使用说明
317
+
318
+ src/views/.../[page-name]/
319
+ ├── index.vue ← <TemplateName :config="xxxConfig" />(极简)
320
+ └── data.ts ← export const xxxConfig: FinishingAchievementConfig = { ... }
321
+ ```
322
+
323
+ **types.ts 要点**:
324
+ ```typescript
325
+ export interface XxxTemplateConfig {
326
+ api: Record<string, string>; // 各表格 API 端点
327
+ processCode: string; // 工序标识,用于查询参数
328
+ query?: { plan?: { items: BaseQueryItemDesc<any>[]; defaultParams?: Record<string, any> } };
329
+ columns?: { planColumns: TableColumnDesc<any>[]; detailColumns: TableColumnDesc<any>[] };
330
+ ui?: Partial<UiConfig>; // 可选 UI 覆盖(Tab 标题、区域标题等)
331
+ }
332
+ ```
333
+
334
+ **页面 data.ts 要点**(仅配置,不写逻辑):
335
+ ```typescript
336
+ import type { XxxTemplateConfig } from "@/components/template/XxxTemplate/types";
337
+
338
+ export const xxxConfig: XxxTemplateConfig = {
339
+ api: { planList: "/mmwr/...", materialList: "/mmwr/..." },
340
+ processCode: "PW",
341
+ query: { plan: { items: [...], defaultParams: { firstProcess: "D", subBacklogCode: "PW" } } }
342
+ };
343
+ ```
344
+
345
+ ### D-3 独立编排页面结构(参考 mmwr-steel-stripping-operations)
346
+
347
+ **data.ts 要点**(多个 createPage 工厂函数):
348
+ ```typescript
349
+ // 上表
350
+ export function createEntryPage() {
351
+ return new (class extends AbstractPageQueryHook {
352
+ constructor() { super({ url: { list: API_CONFIG.planList }, page: { current: 1, size: 10 } }); }
353
+ queryDef() { return [...]; }
354
+ toolbarDef() { return []; }
355
+ columnsDef() { return [...]; }
356
+ })();
357
+ }
358
+
359
+ // 下表(主从联动)
360
+ export function createEntryBottomPage(rejectForm: any) {
361
+ const Page = new (class extends AbstractPageQueryHook {
362
+ constructor() { super({ url: { list: API_CONFIG.detailList } }); }
363
+ queryDef() { return []; }
364
+ toolbarDef() { return [...]; }
365
+ columnsDef() { return [...]; }
366
+ // 关键:由上表行驱动查询
367
+ async selectByPlan(planRow: any) {
368
+ this.queryParam.value.loNo = planRow.loNo;
369
+ this.queryParam.value.lotNo = planRow.lotNo;
370
+ await this.select();
371
+ }
372
+ })();
373
+ return Page;
374
+ }
375
+ ```
376
+
377
+ **index.vue 要点**:
378
+ ```vue
379
+ <template>
380
+ <div class="app-container app-page-container [page-class]">
381
+ <el-tabs v-model="activeTab" type="border-card">
382
+ <el-tab-pane label="录入" name="entry">
383
+ <jh-drag-row :top-height="420">
384
+ <template #top>
385
+ <BaseQuery :form="..." :items="..." @select="..." @reset="..." />
386
+ <BaseTable ref="..." :data="..." :columns="..." highlight-current-row @current-change="handleRowClick" />
387
+ <jh-pagination ... />
388
+ </template>
389
+ <template #bottom>
390
+ <BaseToolbar v-if="selectedRow" :items="..." />
391
+ <el-empty v-if="!selectedRow" description="请先在上方列表中选择一行数据" />
392
+ <BaseTable v-else ref="..." :data="..." :columns="..." />
393
+ <jh-pagination ... />
394
+ </template>
395
+ </jh-drag-row>
396
+ </el-tab-pane>
397
+ <el-tab-pane label="查询" name="query" lazy>
398
+ <!-- 标准 LIST 模式 -->
399
+ </el-tab-pane>
400
+ </el-tabs>
401
+ </div>
402
+ </template>
403
+
404
+ <script setup lang="ts">
405
+ import { createEntryPage, createEntryBottomPage, createQueryPage } from "./data";
406
+
407
+ const activeTab = ref("entry");
408
+ const selectedRow = ref(null);
409
+
410
+ const EntryPage = createEntryPage();
411
+ const { tableRef, page, queryParam, list, queryItems, columns, select } = EntryPage;
412
+
413
+ const BottomPage = createEntryBottomPage();
414
+ const { list: bottomList, columns: bottomColumns, select: bottomSelect, selectByPlan } = BottomPage;
415
+
416
+ const handleRowClick = (row: any) => {
417
+ selectedRow.value = row;
418
+ if (row) selectByPlan(row);
419
+ };
420
+
421
+ onMounted(() => select());
422
+ watch(activeTab, (tab) => { if (tab === "query") QueryPage.select(); });
423
+ </script>
424
+ ```
425
+
426
+ ### D-4 index.scss 要点
427
+
428
+ ```scss
429
+ .[page-class] {
430
+ .section-header { display: flex; align-items: center; gap: 6px; margin: 8px 0; }
431
+ .section-header .title-bar { width: 3px; height: 14px; background: var(--el-color-primary); border-radius: 1px; }
432
+ .section-header .section-title { font-size: 14px; font-weight: 600; margin: 0; }
433
+ .empty-tip { padding: 40px 0; }
434
+ .operation-area { padding: 8px 0; }
435
+ .operation-buttons { display: flex; gap: 8px; margin: 8px 0; }
436
+ .results-container { display: flex; gap: 16px; /* 左右分栏时 */ }
437
+ .results-container .section { flex: 1; min-width: 0; }
438
+ }
439
+ ```
440
+
441
441
  ---