@agile-team/wl-skills-kit 2.3.0 → 2.3.1

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 (93) hide show
  1. package/CHANGELOG.md +33 -63
  2. package/README.md +15 -148
  3. package/bin/wl-skills.js +2 -100
  4. package/files/.github/guides/README.md +13 -13
  5. package/files/.github/guides/architecture.md +555 -576
  6. package/files/.github/guides/usage.md +176 -176
  7. package/files/.github/reports/README.md +65 -65
  8. package/files/.github/reports/SYS_DICT_INFO.md +50 -50
  9. package/files/.github/reports/SYS_MENU_INFO.md +247 -247
  10. package/files/.github/reports/SYS_PERMISSION_INFO.md +20 -20
  11. 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
  12. 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
  13. package/files/.github/skills/_compat/README.md +108 -108
  14. package/files/.github/skills/_compat/headers/agents.txt +8 -8
  15. package/files/.github/skills/_compat/headers/claude-code.txt +7 -7
  16. package/files/.github/skills/_compat/headers/cline.txt +7 -7
  17. package/files/.github/skills/_compat/headers/cursor-mdc.txt +16 -16
  18. package/files/.github/skills/_compat/headers/cursor-rules.txt +7 -7
  19. package/files/.github/skills/_compat/headers/github-copilot.txt +1 -1
  20. package/files/.github/skills/_compat/headers/kiro.txt +10 -10
  21. package/files/.github/skills/_compat/headers/trae.txt +11 -11
  22. package/files/.github/skills/_compat/headers/windsurf.txt +7 -7
  23. package/files/.github/skills/_registry.md +81 -81
  24. package/files/.github/skills/core/api-contract/SKILL.md +344 -344
  25. package/files/.github/skills/core/api-contract/USAGE.md +110 -110
  26. package/files/.github/skills/core/convention-audit/SKILL.md +189 -189
  27. package/files/.github/skills/core/convention-audit/USAGE.md +99 -99
  28. package/files/.github/skills/core/page-codegen/SKILL.md +973 -973
  29. package/files/.github/skills/core/page-codegen/USAGE.md +102 -102
  30. package/files/.github/skills/core/page-codegen/templates/_index.md +46 -46
  31. package/files/.github/skills/core/page-codegen/templates/domains/_CONTRIBUTING.md +107 -107
  32. package/files/.github/skills/core/page-codegen/templates/domains/produce/TPL-OPERATION-STATION.md +442 -442
  33. package/files/.github/skills/core/page-codegen/templates/domains/sale/README.md +26 -26
  34. package/files/.github/skills/core/page-codegen/templates/universal/TPL-CHANGE-HISTORY.md +276 -276
  35. package/files/.github/skills/core/page-codegen/templates/universal/TPL-DETAIL-TABS.md +1145 -1145
  36. package/files/.github/skills/core/page-codegen/templates/universal/TPL-DRIVEN.md +124 -124
  37. package/files/.github/skills/core/page-codegen/templates/universal/TPL-FORM-ROUTE.md +436 -436
  38. package/files/.github/skills/core/page-codegen/templates/universal/TPL-LIST.md +191 -191
  39. package/files/.github/skills/core/page-codegen/templates/universal/TPL-MASTER-DETAIL.md +148 -148
  40. package/files/.github/skills/core/page-codegen/templates/universal/TPL-RECORD-FORM.md +376 -376
  41. package/files/.github/skills/core/page-codegen/templates/universal/TPL-TREE-LIST.md +186 -186
  42. package/files/.github/skills/core/prototype-scan/SKILL.md +498 -498
  43. package/files/.github/skills/core/prototype-scan/USAGE.md +95 -95
  44. package/files/.github/skills/core/template-extract/SKILL.md +139 -139
  45. package/files/.github/skills/core/template-extract/USAGE.md +93 -93
  46. package/files/.github/skills/domain/README.md +51 -51
  47. package/files/.github/skills/sync/menu-sync/SKILL.md +263 -263
  48. package/files/.github/skills/sync/menu-sync/USAGE.md +104 -104
  49. package/files/.github/skills/sync/menu-sync/env/env.local.json +7 -7
  50. package/files/.github/skills/sync/menu-sync/env/guide.md +99 -99
  51. package/files/.github/skills/sync/permission-sync/SKILL.draft.md +91 -91
  52. package/files/.github/standards/01-toolchain.md +57 -57
  53. package/files/.github/standards/02-code-structure.md +111 -111
  54. package/files/.github/standards/03-comments.md +53 -53
  55. package/files/.github/standards/04-coding-basics.md +33 -33
  56. package/files/.github/standards/05-logging.md +38 -38
  57. package/files/.github/standards/06-security.md +44 -44
  58. package/files/.github/standards/07-config.md +52 -52
  59. package/files/.github/standards/08-git.md +60 -60
  60. package/files/.github/standards/09-typescript.md +71 -71
  61. package/files/.github/standards/10-pinia.md +57 -57
  62. package/files/.github/standards/11-form-validation.md +81 -81
  63. package/files/.github/standards/12-base-table.md +153 -153
  64. package/files/.github/standards/13-platform-components.md +123 -123
  65. package/files/.github/standards/index.md +89 -89
  66. package/files/demo/produce/aiflow/mmwr-customer-apply-add/api.md +1 -1
  67. package/files/demo/produce/aiflow/mmwr-customer-apply-change-history/data.ts +196 -196
  68. package/files/demo/produce/aiflow/mmwr-customer-apply-change-history/index.scss +150 -150
  69. package/files/demo/produce/aiflow/mmwr-customer-apply-change-history/index.vue +79 -79
  70. package/files/docs/jh-date-range.md +257 -257
  71. package/files/docs/jh-date.md +222 -222
  72. package/files/docs/jh-dept-picker.md +190 -190
  73. package/files/docs/jh-drag-row.md +590 -590
  74. package/files/docs/jh-file-upload.md +216 -216
  75. package/files/docs/jh-picker.md +218 -218
  76. package/files/docs/jh-select.md +148 -148
  77. package/files/docs/jh-text.md +248 -248
  78. package/files/docs/jh-user-picker.md +197 -197
  79. package/files/docs/request.md +24 -9
  80. package/files/src/components/global/C_RightToolbar/data.ts +228 -0
  81. package/files/src/components/global/C_RightToolbar/index.scss +44 -0
  82. package/files/src/components/global/C_RightToolbar/index.vue +34 -336
  83. package/files/src/components/global/C_Splitter/index.scss +61 -0
  84. package/files/src/components/global/C_Splitter/index.vue +2 -64
  85. package/files/src/components/global/C_SvgIcon/index.scss +15 -0
  86. package/files/src/components/global/C_SvgIcon/index.vue +20 -50
  87. package/files/src/components/global/C_TagStatus/index.scss +20 -0
  88. package/files/src/components/global/C_TagStatus/index.vue +1 -22
  89. package/files/src/components/global/C_Tree/data.ts +61 -0
  90. package/files/src/components/global/C_Tree/index.vue +12 -53
  91. package/files/src/components/local/c_listModal/index.scss +4 -0
  92. package/files/src/components/local/c_listModal/index.vue +1 -1
  93. package/package.json +5 -9
@@ -1,1145 +1,1145 @@
1
- # DETAIL_TABS:详情Tab+子表页
2
-
3
- > 见 SKILL.md 主文件(约束 + 按钮规则 + Mock 规范等共用规则)。
4
-
5
- > 适用场景:编辑/维护页面,上半区为多 Tab 表单(基本信息/客户信息/其他信息),下半区为子项表格。
6
- > 布局核心:`C_Splitter direction="vertical"` 垂直分割上下区域。
7
- > **参考标杆**:`src/views/sale/demo/add-demo/`、`src/views/sale/demo/domestic-trade-order-mainten/`
8
-
9
- #### index.vue
10
-
11
- ```vue
12
- <template>
13
- <div class="app-container app-page-container">
14
- <C_Splitter direction="vertical">
15
- <!-- 上:表单区 -->
16
- <el-card shadow="never" class="form-card">
17
- <!-- 页头工具栏 -->
18
- <div class="page-header">
19
- <div class="page-header__left">
20
- <span class="page-header__title">[主档维护]</span>
21
- </div>
22
- <div class="page-header__right">
23
- <el-button type="primary" @click="handleSave">保存</el-button>
24
- <el-button @click="handleCancel">取消</el-button>
25
- </div>
26
- </div>
27
-
28
- <!-- Tab 表单区 -->
29
- <el-tabs v-model="activeTab" class="form-tabs">
30
- <el-tab-pane label="基本信息" name="basic">
31
- <el-form
32
- ref="formRef"
33
- :model="form"
34
- :rules="rules"
35
- label-width="100px"
36
- >
37
- <el-row :gutter="20">
38
- <el-col :span="6" v-for="item in basicFields" :key="item.name">
39
- <el-form-item :label="item.label" :prop="item.name">
40
- <!-- 按 item.type 渲染对应控件 -->
41
- <el-input
42
- v-if="!item.type || item.type === 'input'"
43
- v-model="form[item.name]"
44
- :placeholder="item.placeholder || '请输入'"
45
- />
46
- <jh-select
47
- v-else-if="item.type === 'select'"
48
- v-model="form[item.name]"
49
- :items="item.options"
50
- label=""
51
- style="width: 100%"
52
- />
53
- <jh-date
54
- v-else-if="item.type === 'date'"
55
- v-model="form[item.name]"
56
- label=""
57
- style="width: 100%"
58
- />
59
- </el-form-item>
60
- </el-col>
61
- </el-row>
62
- </el-form>
63
- </el-tab-pane>
64
- <el-tab-pane label="[其他Tab名]" name="other">
65
- <!-- 同理 -->
66
- </el-tab-pane>
67
- </el-tabs>
68
- </el-card>
69
-
70
- <!-- 下:子项表格区 -->
71
- <el-card shadow="never" class="items-card">
72
- <div class="items-section">
73
- <div class="items-section__header">
74
- <span class="items-section__title">
75
- <el-icon><ArrowDown /></el-icon>
76
- 子项信息
77
- </span>
78
- <el-button type="primary" size="small" @click="addItem">
79
- 新增行
80
- </el-button>
81
- </div>
82
- <BaseTable
83
- ref="itemTableRef"
84
- :data="itemList"
85
- :columns="itemColumns"
86
- showToolbar
87
- />
88
- <jh-pagination
89
- v-show="itemPage.total > 0"
90
- :total="itemPage.total"
91
- v-model:currentPage="itemPage.current"
92
- v-model:pageSize="itemPage.size"
93
- @current-change="loadItems"
94
- @size-change="loadItems"
95
- />
96
- </div>
97
- </el-card>
98
- </C_Splitter>
99
- </div>
100
- </template>
101
-
102
- <script setup lang="ts">
103
- import { onMounted } from "vue";
104
- import C_Splitter from "@/components/global/C_Splitter/index.vue";
105
- import {
106
- form,
107
- rules,
108
- activeTab,
109
- basicFields,
110
- itemList,
111
- itemColumns,
112
- itemPage,
113
- formRef,
114
- itemTableRef,
115
- handleSave,
116
- handleCancel,
117
- addItem,
118
- loadItems,
119
- initPage,
120
- } from "./data";
121
-
122
- onMounted(() => initPage());
123
- </script>
124
-
125
- <style scoped lang="scss">
126
- @import "./index.scss";
127
- </style>
128
- ```
129
-
130
- #### data.ts
131
-
132
- ```typescript
133
- import { ref, reactive } from "vue";
134
- import { getAction, postAction } from "@jhlc/common-core/src/api/action";
135
- import { ElMessage } from "element-plus";
136
- import type { FormInstance, FormRules } from "element-plus";
137
- import type { TableColumnDesc } from "@/types/page";
138
- import envConfig from "@jhlc/common-core/src/store/env-config";
139
-
140
- export const API_CONFIG = {
141
- getById: "/[服务缩写]/[主资源]/getById",
142
- save: "/[服务缩写]/[主资源]/save",
143
- update: "/[服务缩写]/[主资源]/update",
144
- itemList: "/[服务缩写]/[子资源]/list",
145
- itemSave: "/[服务缩写]/[子资源]/save",
146
- itemRemove: "/[服务缩写]/[子资源]/remove",
147
- } as const;
148
-
149
- // ===== 表单状态 =====
150
- export const formRef = ref<FormInstance>();
151
- export const activeTab = ref("basic");
152
- export const form = reactive({
153
- id: "",
154
- // [主表字段...]
155
- });
156
-
157
- export const rules: FormRules = {
158
- // [fieldKey]: [{ required: true, message: "请输入[字段名]", trigger: "blur" }],
159
- };
160
-
161
- // ===== 表单字段配置(驱动 template 动态渲染) =====
162
- export const basicFields = [
163
- {
164
- name: "orderNo",
165
- label: "订单号",
166
- placeholder: "系统自动生成",
167
- disabled: true,
168
- },
169
- { name: "customerName", label: "客户名称", required: true },
170
- {
171
- name: "orderType",
172
- label: "订单类型",
173
- type: "select",
174
- options: [
175
- { label: "期货合同", value: "期货合同" },
176
- { label: "现货合同", value: "现货合同" },
177
- ],
178
- },
179
- { name: "createDate", label: "创建日期", type: "date" },
180
- ];
181
-
182
- // ===== 子项表格 =====
183
- export const itemTableRef = ref();
184
- export const itemList = ref<any[]>([]);
185
- export const itemPage = reactive({ current: 1, size: 10, total: 0 });
186
-
187
- export const itemColumns: TableColumnDesc<any>[] = [
188
- { type: "index", width: 55 },
189
- { label: "[子项字段]", name: "[fieldName]", minWidth: 120 },
190
- {
191
- label: "操作",
192
- width: 100,
193
- fixed: "right",
194
- operations: [
195
- {
196
- name: "remove",
197
- label: "删除",
198
- onClick: (row: any) => removeItem(row),
199
- },
200
- ],
201
- },
202
- ];
203
-
204
- // ===== 数据加载 =====
205
- export async function loadItems() {
206
- const res = await getAction(API_CONFIG.itemList, {
207
- mainId: form.id,
208
- current: itemPage.current,
209
- size: itemPage.size,
210
- });
211
- const data = res.result || res.data;
212
- itemList.value = data?.records || data?.list || [];
213
- itemPage.total = data?.total || 0;
214
- }
215
-
216
- export function addItem() {
217
- itemList.value.push({
218
- id: `temp_${Date.now()}`,
219
- // [子项默认值...]
220
- });
221
- }
222
-
223
- async function removeItem(row: any) {
224
- if (String(row.id).startsWith("temp_")) {
225
- // 未保存的临时行直接移除
226
- itemList.value = itemList.value.filter((r) => r.id !== row.id);
227
- return;
228
- }
229
- await postAction(API_CONFIG.itemRemove, { ids: [row.id] });
230
- ElMessage.success("删除成功");
231
- loadItems();
232
- }
233
-
234
- // ===== 表单操作 =====
235
- export async function handleSave() {
236
- await formRef.value?.validate();
237
- const api = form.id ? API_CONFIG.update : API_CONFIG.save;
238
- await postAction(api, { ...form, items: itemList.value });
239
- ElMessage.success("保存成功");
240
- }
241
-
242
- export function handleCancel() {
243
- const router = envConfig()?.router;
244
- if (router) {
245
- history.back();
246
- }
247
- }
248
-
249
- // ===== 页面初始化 =====
250
- export async function initPage() {
251
- // 从 URL query 获取 id(编辑模式)
252
- const urlParams = new URLSearchParams(window.location.search);
253
- const id = urlParams.get("id");
254
- if (id) {
255
- const res = await getAction(API_CONFIG.getById, { id });
256
- const data = res.result || res.data || res;
257
- Object.assign(form, data);
258
- loadItems();
259
- }
260
- }
261
- ```
262
-
263
- #### index.scss
264
-
265
- ```scss
266
- .app-page-container {
267
- overflow-y: auto;
268
-
269
- :deep(.my-splitter-container) {
270
- height: 100%;
271
- }
272
-
273
- .form-card {
274
- .page-header {
275
- display: flex;
276
- justify-content: space-between;
277
- align-items: center;
278
- margin-bottom: 16px;
279
-
280
- &__title {
281
- font-size: 16px;
282
- font-weight: bold;
283
- }
284
- }
285
-
286
- .form-tabs {
287
- :deep(.el-tabs__header) {
288
- margin-bottom: 16px;
289
- }
290
- }
291
-
292
- // 统一表单控件宽度
293
- :deep(.el-select),
294
- :deep(.jh-select),
295
- :deep(.jh-date),
296
- :deep(.el-input-number) {
297
- width: 100%;
298
- }
299
- }
300
-
301
- .items-card {
302
- .items-section {
303
- &__header {
304
- display: flex;
305
- justify-content: space-between;
306
- align-items: center;
307
- margin-bottom: 12px;
308
- }
309
-
310
- &__title {
311
- font-weight: bold;
312
- display: flex;
313
- align-items: center;
314
- gap: 4px;
315
- }
316
- }
317
- }
318
- }
319
- ```
320
-
321
- ---
322
-
323
- ### 弹窗模板
324
-
325
- 仅在“极个性弹窗”场景生成(c_modal 无法满足时),放在页面 `components/editModal.vue`:
326
-
327
- 通用新增/编辑弹窗应优先使用 `src/components/local/c_modal/` 局部公共组件。
328
-
329
- ```vue
330
- <template>
331
- <el-dialog
332
- v-model="visible"
333
- :title="title"
334
- width="680px"
335
- :close-on-click-modal="false"
336
- @close="handleClose"
337
- >
338
- <el-form
339
- ref="formRef"
340
- :model="form"
341
- :rules="rules"
342
- label-width="100px"
343
- :disabled="mode === 'view'"
344
- >
345
- <el-row :gutter="20">
346
- <el-col :span="12">
347
- <el-form-item label="[字段名]" prop="[fieldKey]">
348
- <el-input v-model="form.[fieldKey]" placeholder="请输入[字段名]" />
349
- </el-form-item>
350
- </el-col>
351
- <el-col :span="12">
352
- <el-form-item label="[状态]" prop="[statusField]">
353
- <jh-select v-model="form.[statusField]" dict="[dictCode]" />
354
- </el-form-item>
355
- </el-col>
356
- </el-row>
357
- </el-form>
358
- <template #footer>
359
- <el-button @click="handleClose">{{
360
- mode === "view" ? "关闭" : "取消"
361
- }}</el-button>
362
- <el-button
363
- v-if="mode !== 'view'"
364
- type="primary"
365
- :loading="loading"
366
- @click="handleSubmit"
367
- >确定</el-button
368
- >
369
- </template>
370
- </el-dialog>
371
- </template>
372
-
373
- <script setup lang="ts">
374
- import { ref, reactive } from "vue";
375
- import { getAction, postAction } from "@jhlc/common-core/src/api/action";
376
- import { API_CONFIG } from "../data";
377
- import type { FormInstance, FormRules } from "element-plus";
378
-
379
- const emit = defineEmits<{ (e: "ok"): void }>();
380
-
381
- const visible = ref(false);
382
- const mode = ref<"add" | "edit" | "view">("add");
383
- const loading = ref(false);
384
- const formRef = ref<FormInstance>();
385
-
386
- const title = computed(() =>
387
- mode.value === "add"
388
- ? "新增[实体名]"
389
- : mode.value === "edit"
390
- ? "编辑[实体名]"
391
- : "查看[实体名]",
392
- );
393
-
394
- const initialForm = () => ({
395
- id: "",
396
- // [表单字段初始值]
397
- });
398
-
399
- const form = reactive(initialForm());
400
-
401
- const rules: FormRules = {
402
- // [必填字段校验]
403
- // [fieldKey]: [{ required: true, message: "请输入[字段名]", trigger: "blur" }],
404
- };
405
-
406
- async function open(id?: string, viewMode?: "edit" | "view") {
407
- if (id) {
408
- mode.value = viewMode || "edit";
409
- const res = await getAction(API_CONFIG.getById, { id });
410
- const data = res.result || res.data || res;
411
- Object.assign(form, initialForm(), data);
412
- } else {
413
- mode.value = "add";
414
- Object.assign(form, initialForm());
415
- }
416
- visible.value = true;
417
- }
418
-
419
- function handleClose() {
420
- formRef.value?.resetFields();
421
- visible.value = false;
422
- }
423
-
424
- async function handleSubmit() {
425
- await formRef.value?.validate();
426
- loading.value = true;
427
- try {
428
- const api = mode.value === "edit" ? API_CONFIG.update : API_CONFIG.save;
429
- await postAction(api, { ...form });
430
- ElMessage.success(mode.value === "edit" ? "编辑成功" : "新增成功");
431
- handleClose();
432
- emit("ok");
433
- } finally {
434
- loading.value = false;
435
- }
436
- }
437
-
438
- defineExpose({ open });
439
- </script>
440
- ```
441
-
442
- ---
443
-
444
- ## 有弹窗时的 index.vue 调整
445
-
446
- ```vue
447
- <template>
448
- <div class="app-container app-page-container">
449
- <BaseQuery
450
- :form="queryParam"
451
- :items="queryItems"
452
- @select="select"
453
- @reset="select"
454
- />
455
- <BaseToolbar :items="toolbars" />
456
- <BaseTable ref="tableRef" :data="list" :columns="columns" showToolbar />
457
- <jh-pagination
458
- v-show="page.total && page.total > 0"
459
- :total="page.total || 0"
460
- v-model:currentPage="page.current"
461
- v-model:pageSize="page.size"
462
- @current-change="select"
463
- @size-change="select"
464
- />
465
- </div>
466
- <!-- 弹窗放在根 div 之外 -->
467
- <AddModal ref="addModalRef" @ok="select" />
468
- </template>
469
-
470
- <script setup lang="ts">
471
- import { createPage } from "./data";
472
- import AddModal from "./components/addModal.vue";
473
-
474
- const addModalRef = ref();
475
- const Page = createPage(addModalRef);
476
- const {
477
- tableRef,
478
- page,
479
- queryParam,
480
- list,
481
- queryItems,
482
- columns,
483
- toolbars,
484
- select,
485
- } = Page;
486
-
487
- onMounted(() => select());
488
- </script>
489
-
490
- <style scoped lang="scss">
491
- @import "./index.scss";
492
- </style>
493
- ```
494
-
495
- ---
496
-
497
- ## 查询项组件配置参考
498
-
499
- | 交互类型 | queryDef 配置 |
500
- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
501
- | 文本输入 | `{ name, label, placeholder }` |
502
- | 字典下拉 | `{ name, label, logicType: BusLogicDataType.dict, logicValue: "dictCode" }` |
503
- | 单日期 | `component: () => ({ tag: "jh-date", type: "date" })` |
504
- | 月份 | `component: () => ({ tag: "jh-date", type: "month", showFormat: "YYYY-MM", format: "YYYYMM" })` |
505
- | 日期范围 | `{ startName, endName, component: () => ({ tag: "jh-date", type: "daterange", rangeSeparator: "至", showFormat: "YYYY-MM-DD", valueFormat: "YYYY-MM-DD" }) }` |
506
- | 用户选择 | `component: () => ({ tag: "jh-user-picker" })` |
507
- | 部门选择 | `component: () => ({ tag: "jh-dept-picker" })` |
508
-
509
- 详细组件 API:`docs/jh-date.md`、`docs/jh-select.md`、`docs/jh-user-picker.md`、`docs/jh-dept-picker.md`
510
- 页面 Hook 最佳实践:`docs/page-query-hook-best-practices.md`
511
- HTTP 方法参考:`docs/request.md`
512
-
513
- ---
514
-
515
- ## 视图切换模式(View Switch)
516
-
517
- > 当原型中出现"管理视角 / 使用视角"等切换按钮,页面同一列表在不同视角下展示不同列时,需使用 `el-tabs` 实现视图切换。
518
- > 参考:`mmwr-steel-stripping-operations`(剔钢实绩)的 `el-tabs` 用法。
519
-
520
- ### 识别规则
521
-
522
- - 原型中出现"管理视角"/"使用视角"或类似视图切换按钮
523
- - 两种视角下表格列数不同(管理视角列少,使用视角列多含业务明细)
524
- - 数据源相同(list 接口同一个),仅 columns 不同
525
-
526
- ### page-spec 扩展
527
-
528
- 在 `features` 中增加视图切换描述:
529
-
530
- ```json
531
- "features": {
532
- "viewSwitch": true,
533
- "viewItems": [
534
- { "label": "管理视角", "value": "management" },
535
- { "label": "使用视角", "value": "usage" }
536
- ]
537
- }
538
- ```
539
-
540
- ### data.ts 模板
541
-
542
- > 关键:每个视角的列定义独立导出为函数,不在 createPage 内做列切换逻辑。
543
- > el-tabs 天然处理了切换,不需要手动封装 handleViewChange。
544
-
545
- ```typescript
546
- let _editModalRef: any = null;
547
-
548
- /** 管理视角列定义 */
549
- export function managementColumns(): TableColumnDesc<any>[] {
550
- return [
551
- { type: "selection" },
552
- { type: "index" },
553
- // ... 管理视角列(按原型顺序)
554
- { label: "操作", width: 100, fixed: "right", operations: [...] }
555
- ];
556
- }
557
-
558
- /** 使用视角列定义(含业务明细字段) */
559
- export function usageColumns(): TableColumnDesc<any>[] {
560
- return [
561
- { type: "selection" },
562
- { type: "index" },
563
- // ... 使用视角列(按原型顺序,通常比管理视角多出业务字段)
564
- { label: "操作", width: 100, fixed: "right", operations: [...] }
565
- ];
566
- }
567
-
568
- let Page: any = null;
569
-
570
- export function createPage(editModalRef?: any) {
571
- _editModalRef = editModalRef;
572
-
573
- let Page_inst = new (class extends AbstractPageQueryHook {
574
- constructor() {
575
- super({ url: { list: API_CONFIG.list, remove: API_CONFIG.remove } });
576
- }
577
- queryDef() { return [...]; }
578
- toolbarDef() { return [...]; }
579
- columnsDef() { return managementColumns(); } // 默认视角(基类需要)
580
- })();
581
-
582
- Page = Page_inst;
583
- return (Page_inst as any).create() as any;
584
- }
585
- ```
586
-
587
- ### index.vue 模板(视图切换部分)
588
-
589
- > 使用 `el-tabs` 组件,每个 `el-tab-pane` 内放独立的 `BaseTable`,通过 `v-if` 保证同一时刻只渲染一个表格。
590
- > `ref="tableRef"` 在两个表格上相同,`v-if` 确保只有活跃的表格持有该 ref,toolbar 操作(如 getSelectionRows)自动作用于当前视角的表格。
591
-
592
- ```vue
593
- <template>
594
- <div class="app-container app-page-container">
595
- <BaseQuery
596
- :form="queryParam"
597
- :items="queryItems"
598
- @select="select"
599
- @reset="select"
600
- />
601
- <BaseToolbar :items="toolbars" />
602
- <el-tabs v-model="activeView">
603
- <el-tab-pane label="管理视角" name="management">
604
- <BaseTable
605
- v-if="activeView === 'management'"
606
- ref="tableRef"
607
- :data="list"
608
- :columns="mgmtCols"
609
- showToolbar
610
- />
611
- </el-tab-pane>
612
- <el-tab-pane label="使用视角" name="usage">
613
- <BaseTable
614
- v-if="activeView === 'usage'"
615
- ref="tableRef"
616
- :data="list"
617
- :columns="useCols"
618
- showToolbar
619
- />
620
- </el-tab-pane>
621
- </el-tabs>
622
- <jh-pagination ... />
623
- <c_formModal ref="editModalRef" v-bind="modalConfig" @ok="select" />
624
- </div>
625
- </template>
626
-
627
- <script setup lang="ts">
628
- import {
629
- createPage,
630
- modalConfig,
631
- managementColumns,
632
- usageColumns,
633
- } from "./data";
634
- import c_formModal from "@/components/local/c_formModal/index.vue";
635
-
636
- const editModalRef = ref();
637
- const Page = createPage(editModalRef);
638
- const { tableRef, page, queryParam, list, queryItems, toolbars, select } = Page;
639
-
640
- const activeView = ref("management");
641
- const mgmtCols = managementColumns();
642
- const useCols = usageColumns();
643
-
644
- onMounted(() => select());
645
- </script>
646
- ```
647
-
648
- **要点**:
649
-
650
- - ❌ 不使用 `el-radio-group` + 手动 `handleViewChange` 切换 columns
651
- - ✅ 使用 `el-tabs` + 每个 pane 内放独立 BaseTable
652
- - ✅ 两个表格共享同一 `list` 数据源和 `tableRef`
653
- - ✅ `v-if` 保证同一时刻只有一个表格实例挂载到 `tableRef`
654
- - ✅ 列定义函数从 data.ts 导出,在 index.vue 中调用(调用时机在 createPage 之后,确保闭包引用正确)
655
-
656
- ### Mock 数据要求
657
-
658
- > **关键**:mock 生成的数据对象必须包含**所有视角**的全部字段,不可仅覆盖默认视角。
659
- > 切换视角后表格使用同一数据源,如果 mock 数据缺少某视角的字段,切换后会显示空列。
660
-
661
- ```typescript
662
- function genRecord() {
663
- return {
664
- // 管理视角字段
665
- customerCode: ...,
666
- customerName: ...,
667
- // 使用视角特有字段(管理视角不显示但数据中必须有)
668
- salesType: ...,
669
- customerNature: ...,
670
- businessPerson: ...,
671
- // ...所有视角的并集
672
- };
673
- }
674
- ```
675
-
676
- ---
677
-
678
- ## 复杂表单 → 独立路由页(非弹窗)
679
-
680
- > 当原型中"新增"/"编辑"打开的不是简单弹窗,而是一个**内容极多的独立页面**(多 Tab、多子表、状态信息区等),必须创建独立路由页而非使用 `c_formModal`。
681
-
682
- ### 识别规则
683
-
684
- - 原型中点击"新增申请"/"编辑"后跳转到**独立页面**(URL 变化,有返回按钮)
685
- - 页面内有**多个 Tab**(如基本信息、资质信息、地址信息、联系人信息、银行信息等)
686
- - 表单字段**超过 15 个**或包含**子表格**(如业务信息表)
687
- - 页面头部有**多个操作按钮**(保存并提交、保存、变更历史查询、取消等)
688
-
689
- ### 平台路由机制(必读)
690
-
691
- > **核心原则**:平台通过 `generateCurrentRoute(to)` 实现按需路由注册,
692
- > `hidden=true` 的菜单**仍然可路由**(已实测验证),只是不在侧边栏显示。
693
- > ❗ `registerMenu` 对 hidden 菜单只是 `return` 跳过处理,但**不从 children 数组中移除**。
694
- > 导致隐藏菜单仍被 `router.addRoute()` 注册,但 component 是原始字符串(非合法组件)。
695
- > 因此 `router.push()` 会报 **"Invalid route component"** 错误。
696
- > 必须使用 `location.href` 触发完整导航,让 `generateCurrentRoute` 用正确组件重新注册路由。
697
-
698
- 1. **路由路径格式** = `/{父菜单subModule}/{菜单路径camelCase}`,例如 `/aiflow/mmwrCustomerApplyAddForm`
699
- - 路径**不是**组件路径的 kebab-case 版本
700
- - 路径**不包含** `produce/production-mmwr` 等构建时前缀
701
- - 「菜单路径」字段(camelCase)才是真正的路由 path 段
702
- 2. **隐藏菜单 hidden: true**:独立表单/详情页**必须设为隐藏**,避免菜单栏多出无意义入口
703
- 3. **pages.ts 变更需重启** dev server(fullImportPlugin 只在首次 transform 时读取)
704
- 4. **local 组件必须显式 import**:`src/components/local/` 下的组件不会自动全局注册
705
-
706
- ### FORM_ROUTE 格式
707
-
708
- ```typescript
709
- // ✅ 正确:/{subModuleKey}/{menuPath_camelCase}
710
- const FORM_ROUTE = "/aiflow/mmwrCustomerApplyAddForm";
711
-
712
- // ❌ 错误:不要用组件路径的 kebab-case
713
- // const FORM_ROUTE = "/produce/production-mmwr/aiflow/mmwr-customer-apply-add-form";
714
- ```
715
-
716
- 如何确定 FORM_ROUTE:
717
-
718
- - `subModuleKey`:取自 pages.ts 中 `gProd("xxx", { subModuleKey: [...] })` 的 key
719
- - `menuPath`:取自后端菜单表的「菜单路径」字段(camelCase)
720
- - 不确定时,在浏览器点击侧边栏已有菜单项,观察 URL 格式
721
-
722
- ### 实现模式
723
-
724
- 1. **创建独立表单页**:`mmwr-xxx-form/` 目录(index.vue + data.ts + index.scss)
725
- 2. **注册为隐藏菜单**:pages.ts 注册 + SYS_MENU_INFO.md 中 `是否隐藏: 是`(平台支持隐藏菜单按需路由)
726
- 3. **列表页使用 `navigateToForm`**:通过 `envConfig().router.resolve()` 生成 URL,始终用 `location.href` 导航(不用 `router.push`)
727
-
728
- ```typescript
729
- import envConfig from "@jhlc/common-core/src/store/env-config";
730
-
731
- // ✅ 使用 /{subModule}/{menuPath_camelCase} 格式
732
- const FORM_ROUTE = "/aiflow/mmwrXxxForm";
733
-
734
- /** 导航到表单页(隐藏菜单路由必须完整导航,触发 generateCurrentRoute 正确注册组件) */
735
- function navigateToForm(query?: Record<string, string>) {
736
- const router = envConfig()?.router;
737
- if (!router) {
738
- ElMessage.error('路由未初始化,请刷新页面重试');
739
- return;
740
- }
741
- const target: any = { path: FORM_ROUTE };
742
- if (query) target.query = query;
743
- location.href = router.resolve(target).href;
744
- }
745
-
746
- export function createPage() {
747
- // ❗ 不需要传 router 参数,navigateToForm 通过 envConfig().router 获取
748
- // ...
749
- toolbarDef() {
750
- return [
751
- {
752
- name: "primary",
753
- label: "新增申请",
754
- onClick: () => navigateToForm()
755
- }
756
- ];
757
- }
758
- columnsDef() {
759
- return [
760
- // ...
761
- {
762
- label: "操作", fixed: "right",
763
- operations: [
764
- {
765
- name: "edit", label: "编辑",
766
- onClick: (row: any) => navigateToForm({ id: row.id })
767
- }
768
- ]
769
- }
770
- ];
771
- }
772
- }
773
- ```
774
-
775
- ### index.vue 模板(列表页改造)
776
-
777
- ```vue
778
- <script setup lang="ts">
779
- import { createPage } from "./data";
780
-
781
- const Page = createPage();
782
- // ... 不再引入 c_formModal,不再使用 useRouter
783
- </script>
784
- ```
785
-
786
- ### index.vue 模板(表单页)
787
-
788
- ```vue
789
- <template>
790
- <div class="app-container app-page-container" v-loading="loading">
791
- <div class="page-header">
792
- <span class="page-title">客户申请详情</span>
793
- <span class="page-tag page-tag--add">新增</span>
794
- <span class="page-tag page-tag--status">未审核</span>
795
- <el-checkbox v-model="onlyRequired" class="only-required-check"
796
- >只看必填项</el-checkbox
797
- >
798
- </div>
799
- <div class="page-toolbar">
800
- <el-button type="danger" @click="handleSaveAndChange"
801
- >保存并变更</el-button
802
- >
803
- <el-button type="warning" @click="handleSave">保存</el-button>
804
- <el-button @click="handleCancel">取消</el-button>
805
- </div>
806
- <!-- ⚠️ local 组件必须显式 import,onlyRequired 传递给子组件 -->
807
- <c_customerTabs ref="tabsRef" mode="add" :only-required="onlyRequired" />
808
- </div>
809
- </template>
810
-
811
- <script setup lang="ts">
812
- import { useRoute } from "vue-router";
813
- import c_customerTabs from "@/components/local/c_customerTabs/index.vue";
814
-
815
- const route = useRoute();
816
- const tabsRef = ref();
817
- const onlyRequired = ref(false);
818
-
819
- onMounted(() => {
820
- const id = route.query.id as string;
821
- if (id) loadDetail(id);
822
- });
823
- </script>
824
-
825
- <style scoped lang="scss">
826
- @import "./index.scss";
827
- </style>
828
- ```
829
-
830
- ---
831
-
832
- ## 表单页模式差异处理
833
-
834
- > 同一共享组件(如 c_customerTabs)在不同模式下(add/change/view)可能有**不同的列、不同的操作、不同的 Tab 顺序**。
835
-
836
- ### 识别方式
837
-
838
- 1. **对比多个原型截图**:同一表单在新增 vs 变更模式下的表格列、按钮、Tab 顺序往往不同
839
- 2. **关键差异点**:
840
- - 表格列:变更模式可能多出「使用组织」「产品线」「银承贴息」列,而新增模式有「免息天数」「月需求量」等
841
- - 操作列:变更模式有「编辑 + 删除」,新增模式仅「删除」
842
- - 新增行方式:所有非查看模式均在表格底部显示 `+新增行` 链接
843
- - Tab 顺序:不同模式可能交换某些 Tab 的前后位置
844
- - 状态信息区:所有模式均显示(disabled 只读),用于展示创建时间、创建人、修改时间等
845
-
846
- ### 实现方式
847
-
848
- ```vue
849
- <!-- 表格列:v-if 按 mode 控制 -->
850
- <el-table-column v-if="isChange" label="使用组织" prop="useOrg" />
851
- <el-table-column
852
- v-if="!isChange && !isView"
853
- label="免息天数"
854
- prop="interestFreeDays"
855
- />
856
-
857
- <!-- 操作列:变更模式多一个编辑按钮 -->
858
- <el-table-column v-if="!isView" label="操作" :width="isChange ? 120 : 80">
859
- <template #default="{ row, $index }">
860
- <el-button v-if="isChange" type="primary" link @click="editRow(row, $index)">编辑</el-button>
861
- <el-button type="danger" link @click="list.splice($index, 1)">删除</el-button>
862
- </template>
863
- </el-table-column>
864
-
865
- <!-- 底部新增行链接(变更模式) -->
866
- <div
867
- v-if="isChange && !isView"
868
- class="add-row-link"
869
- @click="addRow"
870
- >+新增行</div>
871
-
872
- <!-- Tab 顺序差异:用 v-if 控制渲染位置 -->
873
- <el-tab-pane
874
- v-if="isChange"
875
- label="地址信息"
876
- name="addressInfo"
877
- >...</el-tab-pane>
878
- <el-tab-pane label="联系人信息" name="contactInfo">...</el-tab-pane>
879
- <el-tab-pane
880
- v-if="!isChange"
881
- label="地址信息"
882
- name="addressInfo"
883
- >...</el-tab-pane>
884
-
885
- <!-- 状态信息区(所有模式均显示,disabled 只读展示) -->
886
- <div class="status-info-section">
887
- <el-form disabled label-width="100px">
888
- <el-row :gutter="20">
889
- <el-col :span="6"><el-form-item label="创建时间"><el-input v-model="statusInfo.createTime" /></el-form-item></el-col>
890
- <!-- ... -->
891
- </el-row>
892
- </el-form>
893
- </div>
894
- ```
895
-
896
- ### Mock 数据
897
-
898
- - **变更表单打开无 id** 时应加载 mock 数据展示 demo 效果
899
- - Mock 数据工厂放在共享组件的 `data.ts` 中(如 `createChangeMockData()`)
900
- - 表单页 data.ts 在 `onMounted` 中判断无 id 则调用 `loadMockData()`
901
-
902
- ---
903
-
904
- ## 生成后强制校验(必须执行,不可跳过)
905
-
906
- > **校验目的**:逐项 diff page-spec JSON 与生成代码,确保零遗漏、零乱序。
907
- > 校验不通过的项必须立即修复后再向用户报告。
908
-
909
- ### 第零轮:顺序保真度检查(最重要)
910
-
911
- > **核心原则:原型顺序 = 代码顺序。** 查询字段、按钮、表格列的排列顺序体现了业务逻辑和用户习惯,不可随意调换。
912
-
913
- ```
914
- spec.query 中字段顺序 === queryDef() return 数组中字段顺序?(逐个比对)
915
- spec.columns 中字段顺序 === columnsDef() return 数组中字段顺序?(selection/index 在最前,其余逐个比对)
916
- spec.toolbar 中按钮顺序 === toolbarDef() return 数组中按钮顺序?(逐个比对)
917
- spec.operations 中按钮顺序 === 操作列 operations 数组中按钮顺序?
918
- spec.features.tabItems 顺序 === 页面 Tab 组件中顺序?
919
- ```
920
-
921
- **任何顺序不一致 → 立即调整为与 spec(即原型)一致。**
922
-
923
- ### 第一轮:字段计数 diff
924
-
925
- 对照 page-spec JSON,逐项计数:
926
-
927
- ```
928
- spec.query.length === queryDef() return 数组长度?
929
- spec.columns.length === columnsDef() return 数组长度(不含 selection/index)?
930
- spec.toolbar.length === toolbarDef() return 数组长度?
931
- spec.operations.length === 操作列 operations 数组长度?
932
- ```
933
-
934
- **任何计数不等 → 停下来,找出缺失项补全。**
935
-
936
- ### 第二轮:字段名逐一比对
937
-
938
- ```
939
- spec.query 中每个 field → queryDef() 中存在 name === field 的项?
940
- spec.columns 中每个 field → columnsDef() 中存在 name === field 的项?
941
- spec.toolbar 中每个 label → toolbarDef() 中存在 label === label 的项?
942
- spec.operations 中每个 label → 操作列 operations 中存在?
943
- ```
944
-
945
- **任何字段找不到对应 → 立即补全。**
946
-
947
- ### 第三轮:子表交互完整性
948
-
949
- ```
950
- spec.subTables 中 editable === true 的子表:
951
- □ 模板中有新增按钮(v-if="mode !== 'view'")?
952
- □ 模板中有删除按钮/操作列?
953
- □ script 中有 addXxxRow() 方法?
954
-
955
- spec.subTables 中 inlineEdit === true 的子表:
956
- □ 单元格使用 el-input / jh-select 等可编辑组件?
957
- ```
958
-
959
- ### 第四轮:字典 & 特殊交互
960
-
961
- ```
962
- spec 中所有 type === "dict" 的字段:
963
- □ queryDef 对应项有 logicType: BusLogicDataType.dict + logicValue: dictCode?
964
- □ columnsDef 对应项有 logicType + logicValue?
965
-
966
- spec.features.tabSwitch === true:
967
- □ index.vue 中有 Tab 切换组件?
968
- □ data.ts 中有 Tab 切换处理逻辑?
969
- □ 不同 Tab 切换后的列定义(如有差异)是否各自与原型一致?
970
-
971
- spec.features.viewSwitch === true:
972
- □ index.vue 中有 el-tabs 视角切换组件?
973
- □ data.ts 中有 managementColumns()/usageColumns() 等多视角列定义?
974
- □ 每个视角的列数量、顺序与原型严格一致?
975
- □ el-tabs 各 pane 内 BaseTable 使用对应视角的 columns?
976
- □ mock 数据包含所有视角字段的并集(切换视角后不出现空列)?
977
-
978
- spec.features.hiddenMenu === true:
979
- □ SYS_MENU_INFO 标注为隐藏?
980
- ```
981
-
982
- ### 第五轮:文件完整性
983
-
984
- ```
985
- □ index.vue — 模板 + createPage() 解构 + onMounted
986
- □ data.ts — API_CONFIG + createPage()/useXxx() 工厂函数
987
- □ index.scss — 存在(可为空)
988
- □ api.md — 字段名与 data.ts 一致
989
- □ pages.ts 注册行 — 已提供
990
- □ style: @import "./index.scss"
991
- □ 外层 class: "app-container app-page-container"
992
- □ API: getAction/postAction(非 axios)
993
- ```
994
-
995
- ### 校验报告模板
996
-
997
- 校验完成后输出简要报告:
998
-
999
- ```
1000
- ✅ query: spec 12 项 = code 12 项,顺序一致
1001
- ✅ columns: spec 16 项 = code 16 项,顺序一致
1002
- ✅ toolbar: spec 7 项 = code 7 项,顺序一致,颜色正确
1003
- ✅ operations: spec 3 项 = code 3 项,顺序一致
1004
- ✅ tabs: spec 3 项 = code 3 项,顺序一致
1005
- ✅ subTables: businessInfo(editable) — 有新增/删除
1006
- ✅ dict 字段: 8 个全部配置 logicType
1007
- ✅ 文件完整性: 4/4
1008
- ✅ mock URL: 全部带 /dev-api 前缀,list 用 GET + query
1009
- ```
1010
-
1011
- 如有不通过项:
1012
-
1013
- ```
1014
- ❌ columns: spec 35 项 ≠ code 34 项 — 缺少 customerName 列 → 已补全
1015
- ❌ toolbar 顺序: spec [新增申请, 删除, 启用] ≠ code [新增, 启用, 删除] → 已调整
1016
- ❌ operations: spec [查看, 编辑, 删除] ≠ code [编辑, 删除] — 缺少"查看" → 已补全
1017
- ```
1018
-
1019
- ---
1020
-
1021
- ## 附加输出
1022
-
1023
- ### pages.ts 注册片段
1024
-
1025
- ```typescript
1026
- // 添加到 vite/plugins/shared/pages.ts 对应模块的数组中
1027
- ["[kebab-case-目录名]", "[页面中文名]"],
1028
- ```
1029
-
1030
- ### 菜单配置
1031
-
1032
- > 菜单配置统一生成到 `.github/reports/SYS_MENU_INFO.md`(集中式),生成规则见 SKILL.md 主文件的「SYS_MENU_INFO 生成规则」章节。
1033
-
1034
- ---
1035
-
1036
- ### Mock 数据文件(mock/[页面kebab-name].ts)
1037
-
1038
- 在项目根目录 `mock/` 下生成,`vite-plugin-mock`(`mockPath: "./mock"`)自动加载,**无需手动注册**。
1039
-
1040
- **要求**:
1041
-
1042
- - URL 和字段与 api.md 完全一致
1043
- - 使用 `MockMethod[]` 类型 + `mockjs` 生成数据
1044
- - 分页查询返回 `{ code, msg, data: { records, total, size, current } }` 结构
1045
- - 字典字段的值从 api.md 字典表中取
1046
-
1047
- **模板**:
1048
-
1049
- ```typescript
1050
- import type { MockMethod } from "vite-plugin-mock";
1051
- import Mock from "mockjs";
1052
-
1053
- const Random = Mock.Random;
1054
-
1055
- // 字典选项:从 api.md 字典表复制
1056
- const DICT = {
1057
- status_code: ["值1", "值2"],
1058
- };
1059
-
1060
- // 单条记录生成器:字段对齐 api.md Response
1061
- function genRecord() {
1062
- return {
1063
- id: Random.id(),
1064
- fieldName: Random.cword(2, 4),
1065
- statusField: Random.pick(DICT.status_code),
1066
- createTime: Random.datetime("yyyy-MM-dd HH:mm:ss"),
1067
- };
1068
- }
1069
-
1070
- const dataPool = Array.from({ length: 50 }, genRecord);
1071
-
1072
- const mockApi: MockMethod[] = [
1073
- {
1074
- // ⚠️ URL 必须带 /dev-api 前缀(axios baseURL = /dev-api)
1075
- url: "/dev-api/[服务缩写]/[资源名]/list",
1076
- // ⚠️ AbstractPageQueryHook 默认 requestMethod = GET,所以 list 必须用 get
1077
- method: "get",
1078
- response: ({ query }: any) => {
1079
- const current = Number(query?.current) || 1;
1080
- const size = Number(query?.size) || 20;
1081
- const start = (current - 1) * size;
1082
- return {
1083
- code: 200,
1084
- msg: "操作成功",
1085
- data: {
1086
- records: dataPool.slice(start, start + size),
1087
- total: dataPool.length,
1088
- size,
1089
- current,
1090
- },
1091
- };
1092
- },
1093
- },
1094
- {
1095
- url: "/dev-api/[服务缩写]/[资源名]/getById",
1096
- method: "get",
1097
- response: ({ query }: any) => ({
1098
- code: 200,
1099
- msg: "操作成功",
1100
- data: dataPool.find((d) => d.id === query.id) || dataPool[0],
1101
- }),
1102
- },
1103
- {
1104
- url: "/dev-api/[服务缩写]/[资源名]/remove",
1105
- method: "delete",
1106
- response: ({ query, body }: any) => {
1107
- const id = query?.id || body?.id;
1108
- const ids = id ? [id] : query?.ids?.split(",") || body?.ids || [];
1109
- ids.forEach((rid: string) => {
1110
- const idx = dataPool.findIndex((d) => d.id === rid);
1111
- if (idx > -1) dataPool.splice(idx, 1);
1112
- });
1113
- return { code: 200, msg: "删除成功", data: null };
1114
- },
1115
- },
1116
- {
1117
- url: "/dev-api/[服务缩写]/[资源名]/save",
1118
- method: "post",
1119
- response: ({ body }: any) => {
1120
- const newRecord = { ...genRecord(), ...body, id: Random.id() };
1121
- dataPool.unshift(newRecord);
1122
- return { code: 200, msg: "保存成功", data: { id: newRecord.id } };
1123
- },
1124
- },
1125
- {
1126
- url: "/dev-api/[服务缩写]/[资源名]/update",
1127
- method: "post",
1128
- response: ({ body }: any) => {
1129
- const idx = dataPool.findIndex((d) => d.id === body?.id);
1130
- if (idx > -1) Object.assign(dataPool[idx], body);
1131
- return { code: 200, msg: "更新成功", data: null };
1132
- },
1133
- },
1134
- ];
1135
-
1136
- export default mockApi;
1137
- ```
1138
-
1139
- > **Mock URL 前缀规则**:项目 axios `baseURL` = `/dev-api`(由 `VUE_APP_BASE_API` 配置),
1140
- > `vite-plugin-mock` 用 `pathToRegexp` 严格匹配浏览器实际请求路径,
1141
- > 所以 mock URL 必须带 `/dev-api` 前缀才能拦截成功。
1142
- >
1143
- > 参考实现:`mock/customer-archive.ts`、`mock/temp-customer-archive.ts`、`mock/customer-apply.ts`
1144
-
1145
- ---
1
+ # DETAIL_TABS:详情Tab+子表页
2
+
3
+ > 见 SKILL.md 主文件(约束 + 按钮规则 + Mock 规范等共用规则)。
4
+
5
+ > 适用场景:编辑/维护页面,上半区为多 Tab 表单(基本信息/客户信息/其他信息),下半区为子项表格。
6
+ > 布局核心:`C_Splitter direction="vertical"` 垂直分割上下区域。
7
+ > **参考标杆**:`src/views/sale/demo/add-demo/`、`src/views/sale/demo/domestic-trade-order-mainten/`
8
+
9
+ #### index.vue
10
+
11
+ ```vue
12
+ <template>
13
+ <div class="app-container app-page-container">
14
+ <C_Splitter direction="vertical">
15
+ <!-- 上:表单区 -->
16
+ <el-card shadow="never" class="form-card">
17
+ <!-- 页头工具栏 -->
18
+ <div class="page-header">
19
+ <div class="page-header__left">
20
+ <span class="page-header__title">[主档维护]</span>
21
+ </div>
22
+ <div class="page-header__right">
23
+ <el-button type="primary" @click="handleSave">保存</el-button>
24
+ <el-button @click="handleCancel">取消</el-button>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- Tab 表单区 -->
29
+ <el-tabs v-model="activeTab" class="form-tabs">
30
+ <el-tab-pane label="基本信息" name="basic">
31
+ <el-form
32
+ ref="formRef"
33
+ :model="form"
34
+ :rules="rules"
35
+ label-width="100px"
36
+ >
37
+ <el-row :gutter="20">
38
+ <el-col :span="6" v-for="item in basicFields" :key="item.name">
39
+ <el-form-item :label="item.label" :prop="item.name">
40
+ <!-- 按 item.type 渲染对应控件 -->
41
+ <el-input
42
+ v-if="!item.type || item.type === 'input'"
43
+ v-model="form[item.name]"
44
+ :placeholder="item.placeholder || '请输入'"
45
+ />
46
+ <jh-select
47
+ v-else-if="item.type === 'select'"
48
+ v-model="form[item.name]"
49
+ :items="item.options"
50
+ label=""
51
+ style="width: 100%"
52
+ />
53
+ <jh-date
54
+ v-else-if="item.type === 'date'"
55
+ v-model="form[item.name]"
56
+ label=""
57
+ style="width: 100%"
58
+ />
59
+ </el-form-item>
60
+ </el-col>
61
+ </el-row>
62
+ </el-form>
63
+ </el-tab-pane>
64
+ <el-tab-pane label="[其他Tab名]" name="other">
65
+ <!-- 同理 -->
66
+ </el-tab-pane>
67
+ </el-tabs>
68
+ </el-card>
69
+
70
+ <!-- 下:子项表格区 -->
71
+ <el-card shadow="never" class="items-card">
72
+ <div class="items-section">
73
+ <div class="items-section__header">
74
+ <span class="items-section__title">
75
+ <el-icon><ArrowDown /></el-icon>
76
+ 子项信息
77
+ </span>
78
+ <el-button type="primary" size="small" @click="addItem">
79
+ 新增行
80
+ </el-button>
81
+ </div>
82
+ <BaseTable
83
+ ref="itemTableRef"
84
+ :data="itemList"
85
+ :columns="itemColumns"
86
+ showToolbar
87
+ />
88
+ <jh-pagination
89
+ v-show="itemPage.total > 0"
90
+ :total="itemPage.total"
91
+ v-model:currentPage="itemPage.current"
92
+ v-model:pageSize="itemPage.size"
93
+ @current-change="loadItems"
94
+ @size-change="loadItems"
95
+ />
96
+ </div>
97
+ </el-card>
98
+ </C_Splitter>
99
+ </div>
100
+ </template>
101
+
102
+ <script setup lang="ts">
103
+ import { onMounted } from "vue";
104
+ import C_Splitter from "@/components/global/C_Splitter/index.vue";
105
+ import {
106
+ form,
107
+ rules,
108
+ activeTab,
109
+ basicFields,
110
+ itemList,
111
+ itemColumns,
112
+ itemPage,
113
+ formRef,
114
+ itemTableRef,
115
+ handleSave,
116
+ handleCancel,
117
+ addItem,
118
+ loadItems,
119
+ initPage,
120
+ } from "./data";
121
+
122
+ onMounted(() => initPage());
123
+ </script>
124
+
125
+ <style scoped lang="scss">
126
+ @import "./index.scss";
127
+ </style>
128
+ ```
129
+
130
+ #### data.ts
131
+
132
+ ```typescript
133
+ import { ref, reactive } from "vue";
134
+ import { getAction, postAction } from "@jhlc/common-core/src/api/action";
135
+ import { ElMessage } from "element-plus";
136
+ import type { FormInstance, FormRules } from "element-plus";
137
+ import type { TableColumnDesc } from "@/types/page";
138
+ import envConfig from "@jhlc/common-core/src/store/env-config";
139
+
140
+ export const API_CONFIG = {
141
+ getById: "/[服务缩写]/[主资源]/getById",
142
+ save: "/[服务缩写]/[主资源]/save",
143
+ update: "/[服务缩写]/[主资源]/update",
144
+ itemList: "/[服务缩写]/[子资源]/list",
145
+ itemSave: "/[服务缩写]/[子资源]/save",
146
+ itemRemove: "/[服务缩写]/[子资源]/remove",
147
+ } as const;
148
+
149
+ // ===== 表单状态 =====
150
+ export const formRef = ref<FormInstance>();
151
+ export const activeTab = ref("basic");
152
+ export const form = reactive({
153
+ id: "",
154
+ // [主表字段...]
155
+ });
156
+
157
+ export const rules: FormRules = {
158
+ // [fieldKey]: [{ required: true, message: "请输入[字段名]", trigger: "blur" }],
159
+ };
160
+
161
+ // ===== 表单字段配置(驱动 template 动态渲染) =====
162
+ export const basicFields = [
163
+ {
164
+ name: "orderNo",
165
+ label: "订单号",
166
+ placeholder: "系统自动生成",
167
+ disabled: true,
168
+ },
169
+ { name: "customerName", label: "客户名称", required: true },
170
+ {
171
+ name: "orderType",
172
+ label: "订单类型",
173
+ type: "select",
174
+ options: [
175
+ { label: "期货合同", value: "期货合同" },
176
+ { label: "现货合同", value: "现货合同" },
177
+ ],
178
+ },
179
+ { name: "createDate", label: "创建日期", type: "date" },
180
+ ];
181
+
182
+ // ===== 子项表格 =====
183
+ export const itemTableRef = ref();
184
+ export const itemList = ref<any[]>([]);
185
+ export const itemPage = reactive({ current: 1, size: 10, total: 0 });
186
+
187
+ export const itemColumns: TableColumnDesc<any>[] = [
188
+ { type: "index", width: 55 },
189
+ { label: "[子项字段]", name: "[fieldName]", minWidth: 120 },
190
+ {
191
+ label: "操作",
192
+ width: 100,
193
+ fixed: "right",
194
+ operations: [
195
+ {
196
+ name: "remove",
197
+ label: "删除",
198
+ onClick: (row: any) => removeItem(row),
199
+ },
200
+ ],
201
+ },
202
+ ];
203
+
204
+ // ===== 数据加载 =====
205
+ export async function loadItems() {
206
+ const res = await getAction(API_CONFIG.itemList, {
207
+ mainId: form.id,
208
+ current: itemPage.current,
209
+ size: itemPage.size,
210
+ });
211
+ const data = res.result || res.data;
212
+ itemList.value = data?.records || data?.list || [];
213
+ itemPage.total = data?.total || 0;
214
+ }
215
+
216
+ export function addItem() {
217
+ itemList.value.push({
218
+ id: `temp_${Date.now()}`,
219
+ // [子项默认值...]
220
+ });
221
+ }
222
+
223
+ async function removeItem(row: any) {
224
+ if (String(row.id).startsWith("temp_")) {
225
+ // 未保存的临时行直接移除
226
+ itemList.value = itemList.value.filter((r) => r.id !== row.id);
227
+ return;
228
+ }
229
+ await postAction(API_CONFIG.itemRemove, { ids: [row.id] });
230
+ ElMessage.success("删除成功");
231
+ loadItems();
232
+ }
233
+
234
+ // ===== 表单操作 =====
235
+ export async function handleSave() {
236
+ await formRef.value?.validate();
237
+ const api = form.id ? API_CONFIG.update : API_CONFIG.save;
238
+ await postAction(api, { ...form, items: itemList.value });
239
+ ElMessage.success("保存成功");
240
+ }
241
+
242
+ export function handleCancel() {
243
+ const router = envConfig()?.router;
244
+ if (router) {
245
+ history.back();
246
+ }
247
+ }
248
+
249
+ // ===== 页面初始化 =====
250
+ export async function initPage() {
251
+ // 从 URL query 获取 id(编辑模式)
252
+ const urlParams = new URLSearchParams(window.location.search);
253
+ const id = urlParams.get("id");
254
+ if (id) {
255
+ const res = await getAction(API_CONFIG.getById, { id });
256
+ const data = res.result || res.data || res;
257
+ Object.assign(form, data);
258
+ loadItems();
259
+ }
260
+ }
261
+ ```
262
+
263
+ #### index.scss
264
+
265
+ ```scss
266
+ .app-page-container {
267
+ overflow-y: auto;
268
+
269
+ :deep(.my-splitter-container) {
270
+ height: 100%;
271
+ }
272
+
273
+ .form-card {
274
+ .page-header {
275
+ display: flex;
276
+ justify-content: space-between;
277
+ align-items: center;
278
+ margin-bottom: 16px;
279
+
280
+ &__title {
281
+ font-size: 16px;
282
+ font-weight: bold;
283
+ }
284
+ }
285
+
286
+ .form-tabs {
287
+ :deep(.el-tabs__header) {
288
+ margin-bottom: 16px;
289
+ }
290
+ }
291
+
292
+ // 统一表单控件宽度
293
+ :deep(.el-select),
294
+ :deep(.jh-select),
295
+ :deep(.jh-date),
296
+ :deep(.el-input-number) {
297
+ width: 100%;
298
+ }
299
+ }
300
+
301
+ .items-card {
302
+ .items-section {
303
+ &__header {
304
+ display: flex;
305
+ justify-content: space-between;
306
+ align-items: center;
307
+ margin-bottom: 12px;
308
+ }
309
+
310
+ &__title {
311
+ font-weight: bold;
312
+ display: flex;
313
+ align-items: center;
314
+ gap: 4px;
315
+ }
316
+ }
317
+ }
318
+ }
319
+ ```
320
+
321
+ ---
322
+
323
+ ### 弹窗模板
324
+
325
+ 仅在“极个性弹窗”场景生成(c_modal 无法满足时),放在页面 `components/editModal.vue`:
326
+
327
+ 通用新增/编辑弹窗应优先使用 `src/components/local/c_modal/` 局部公共组件。
328
+
329
+ ```vue
330
+ <template>
331
+ <el-dialog
332
+ v-model="visible"
333
+ :title="title"
334
+ width="680px"
335
+ :close-on-click-modal="false"
336
+ @close="handleClose"
337
+ >
338
+ <el-form
339
+ ref="formRef"
340
+ :model="form"
341
+ :rules="rules"
342
+ label-width="100px"
343
+ :disabled="mode === 'view'"
344
+ >
345
+ <el-row :gutter="20">
346
+ <el-col :span="12">
347
+ <el-form-item label="[字段名]" prop="[fieldKey]">
348
+ <el-input v-model="form.[fieldKey]" placeholder="请输入[字段名]" />
349
+ </el-form-item>
350
+ </el-col>
351
+ <el-col :span="12">
352
+ <el-form-item label="[状态]" prop="[statusField]">
353
+ <jh-select v-model="form.[statusField]" dict="[dictCode]" />
354
+ </el-form-item>
355
+ </el-col>
356
+ </el-row>
357
+ </el-form>
358
+ <template #footer>
359
+ <el-button @click="handleClose">{{
360
+ mode === "view" ? "关闭" : "取消"
361
+ }}</el-button>
362
+ <el-button
363
+ v-if="mode !== 'view'"
364
+ type="primary"
365
+ :loading="loading"
366
+ @click="handleSubmit"
367
+ >确定</el-button
368
+ >
369
+ </template>
370
+ </el-dialog>
371
+ </template>
372
+
373
+ <script setup lang="ts">
374
+ import { ref, reactive } from "vue";
375
+ import { getAction, postAction } from "@jhlc/common-core/src/api/action";
376
+ import { API_CONFIG } from "../data";
377
+ import type { FormInstance, FormRules } from "element-plus";
378
+
379
+ const emit = defineEmits<{ (e: "ok"): void }>();
380
+
381
+ const visible = ref(false);
382
+ const mode = ref<"add" | "edit" | "view">("add");
383
+ const loading = ref(false);
384
+ const formRef = ref<FormInstance>();
385
+
386
+ const title = computed(() =>
387
+ mode.value === "add"
388
+ ? "新增[实体名]"
389
+ : mode.value === "edit"
390
+ ? "编辑[实体名]"
391
+ : "查看[实体名]",
392
+ );
393
+
394
+ const initialForm = () => ({
395
+ id: "",
396
+ // [表单字段初始值]
397
+ });
398
+
399
+ const form = reactive(initialForm());
400
+
401
+ const rules: FormRules = {
402
+ // [必填字段校验]
403
+ // [fieldKey]: [{ required: true, message: "请输入[字段名]", trigger: "blur" }],
404
+ };
405
+
406
+ async function open(id?: string, viewMode?: "edit" | "view") {
407
+ if (id) {
408
+ mode.value = viewMode || "edit";
409
+ const res = await getAction(API_CONFIG.getById, { id });
410
+ const data = res.result || res.data || res;
411
+ Object.assign(form, initialForm(), data);
412
+ } else {
413
+ mode.value = "add";
414
+ Object.assign(form, initialForm());
415
+ }
416
+ visible.value = true;
417
+ }
418
+
419
+ function handleClose() {
420
+ formRef.value?.resetFields();
421
+ visible.value = false;
422
+ }
423
+
424
+ async function handleSubmit() {
425
+ await formRef.value?.validate();
426
+ loading.value = true;
427
+ try {
428
+ const api = mode.value === "edit" ? API_CONFIG.update : API_CONFIG.save;
429
+ await postAction(api, { ...form });
430
+ ElMessage.success(mode.value === "edit" ? "编辑成功" : "新增成功");
431
+ handleClose();
432
+ emit("ok");
433
+ } finally {
434
+ loading.value = false;
435
+ }
436
+ }
437
+
438
+ defineExpose({ open });
439
+ </script>
440
+ ```
441
+
442
+ ---
443
+
444
+ ## 有弹窗时的 index.vue 调整
445
+
446
+ ```vue
447
+ <template>
448
+ <div class="app-container app-page-container">
449
+ <BaseQuery
450
+ :form="queryParam"
451
+ :items="queryItems"
452
+ @select="select"
453
+ @reset="select"
454
+ />
455
+ <BaseToolbar :items="toolbars" />
456
+ <BaseTable ref="tableRef" :data="list" :columns="columns" showToolbar />
457
+ <jh-pagination
458
+ v-show="page.total && page.total > 0"
459
+ :total="page.total || 0"
460
+ v-model:currentPage="page.current"
461
+ v-model:pageSize="page.size"
462
+ @current-change="select"
463
+ @size-change="select"
464
+ />
465
+ </div>
466
+ <!-- 弹窗放在根 div 之外 -->
467
+ <AddModal ref="addModalRef" @ok="select" />
468
+ </template>
469
+
470
+ <script setup lang="ts">
471
+ import { createPage } from "./data";
472
+ import AddModal from "./components/addModal.vue";
473
+
474
+ const addModalRef = ref();
475
+ const Page = createPage(addModalRef);
476
+ const {
477
+ tableRef,
478
+ page,
479
+ queryParam,
480
+ list,
481
+ queryItems,
482
+ columns,
483
+ toolbars,
484
+ select,
485
+ } = Page;
486
+
487
+ onMounted(() => select());
488
+ </script>
489
+
490
+ <style scoped lang="scss">
491
+ @import "./index.scss";
492
+ </style>
493
+ ```
494
+
495
+ ---
496
+
497
+ ## 查询项组件配置参考
498
+
499
+ | 交互类型 | queryDef 配置 |
500
+ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
501
+ | 文本输入 | `{ name, label, placeholder }` |
502
+ | 字典下拉 | `{ name, label, logicType: BusLogicDataType.dict, logicValue: "dictCode" }` |
503
+ | 单日期 | `component: () => ({ tag: "jh-date", type: "date" })` |
504
+ | 月份 | `component: () => ({ tag: "jh-date", type: "month", showFormat: "YYYY-MM", format: "YYYYMM" })` |
505
+ | 日期范围 | `{ startName, endName, component: () => ({ tag: "jh-date", type: "daterange", rangeSeparator: "至", showFormat: "YYYY-MM-DD", valueFormat: "YYYY-MM-DD" }) }` |
506
+ | 用户选择 | `component: () => ({ tag: "jh-user-picker" })` |
507
+ | 部门选择 | `component: () => ({ tag: "jh-dept-picker" })` |
508
+
509
+ 详细组件 API:`docs/jh-date.md`、`docs/jh-select.md`、`docs/jh-user-picker.md`、`docs/jh-dept-picker.md`
510
+ 页面 Hook 最佳实践:`docs/page-query-hook-best-practices.md`
511
+ HTTP 方法参考:`docs/request.md`
512
+
513
+ ---
514
+
515
+ ## 视图切换模式(View Switch)
516
+
517
+ > 当原型中出现"管理视角 / 使用视角"等切换按钮,页面同一列表在不同视角下展示不同列时,需使用 `el-tabs` 实现视图切换。
518
+ > 参考:`mmwr-steel-stripping-operations`(剔钢实绩)的 `el-tabs` 用法。
519
+
520
+ ### 识别规则
521
+
522
+ - 原型中出现"管理视角"/"使用视角"或类似视图切换按钮
523
+ - 两种视角下表格列数不同(管理视角列少,使用视角列多含业务明细)
524
+ - 数据源相同(list 接口同一个),仅 columns 不同
525
+
526
+ ### page-spec 扩展
527
+
528
+ 在 `features` 中增加视图切换描述:
529
+
530
+ ```json
531
+ "features": {
532
+ "viewSwitch": true,
533
+ "viewItems": [
534
+ { "label": "管理视角", "value": "management" },
535
+ { "label": "使用视角", "value": "usage" }
536
+ ]
537
+ }
538
+ ```
539
+
540
+ ### data.ts 模板
541
+
542
+ > 关键:每个视角的列定义独立导出为函数,不在 createPage 内做列切换逻辑。
543
+ > el-tabs 天然处理了切换,不需要手动封装 handleViewChange。
544
+
545
+ ```typescript
546
+ let _editModalRef: any = null;
547
+
548
+ /** 管理视角列定义 */
549
+ export function managementColumns(): TableColumnDesc<any>[] {
550
+ return [
551
+ { type: "selection" },
552
+ { type: "index" },
553
+ // ... 管理视角列(按原型顺序)
554
+ { label: "操作", width: 100, fixed: "right", operations: [...] }
555
+ ];
556
+ }
557
+
558
+ /** 使用视角列定义(含业务明细字段) */
559
+ export function usageColumns(): TableColumnDesc<any>[] {
560
+ return [
561
+ { type: "selection" },
562
+ { type: "index" },
563
+ // ... 使用视角列(按原型顺序,通常比管理视角多出业务字段)
564
+ { label: "操作", width: 100, fixed: "right", operations: [...] }
565
+ ];
566
+ }
567
+
568
+ let Page: any = null;
569
+
570
+ export function createPage(editModalRef?: any) {
571
+ _editModalRef = editModalRef;
572
+
573
+ let Page_inst = new (class extends AbstractPageQueryHook {
574
+ constructor() {
575
+ super({ url: { list: API_CONFIG.list, remove: API_CONFIG.remove } });
576
+ }
577
+ queryDef() { return [...]; }
578
+ toolbarDef() { return [...]; }
579
+ columnsDef() { return managementColumns(); } // 默认视角(基类需要)
580
+ })();
581
+
582
+ Page = Page_inst;
583
+ return (Page_inst as any).create() as any;
584
+ }
585
+ ```
586
+
587
+ ### index.vue 模板(视图切换部分)
588
+
589
+ > 使用 `el-tabs` 组件,每个 `el-tab-pane` 内放独立的 `BaseTable`,通过 `v-if` 保证同一时刻只渲染一个表格。
590
+ > `ref="tableRef"` 在两个表格上相同,`v-if` 确保只有活跃的表格持有该 ref,toolbar 操作(如 getSelectionRows)自动作用于当前视角的表格。
591
+
592
+ ```vue
593
+ <template>
594
+ <div class="app-container app-page-container">
595
+ <BaseQuery
596
+ :form="queryParam"
597
+ :items="queryItems"
598
+ @select="select"
599
+ @reset="select"
600
+ />
601
+ <BaseToolbar :items="toolbars" />
602
+ <el-tabs v-model="activeView">
603
+ <el-tab-pane label="管理视角" name="management">
604
+ <BaseTable
605
+ v-if="activeView === 'management'"
606
+ ref="tableRef"
607
+ :data="list"
608
+ :columns="mgmtCols"
609
+ showToolbar
610
+ />
611
+ </el-tab-pane>
612
+ <el-tab-pane label="使用视角" name="usage">
613
+ <BaseTable
614
+ v-if="activeView === 'usage'"
615
+ ref="tableRef"
616
+ :data="list"
617
+ :columns="useCols"
618
+ showToolbar
619
+ />
620
+ </el-tab-pane>
621
+ </el-tabs>
622
+ <jh-pagination ... />
623
+ <c_formModal ref="editModalRef" v-bind="modalConfig" @ok="select" />
624
+ </div>
625
+ </template>
626
+
627
+ <script setup lang="ts">
628
+ import {
629
+ createPage,
630
+ modalConfig,
631
+ managementColumns,
632
+ usageColumns,
633
+ } from "./data";
634
+ import c_formModal from "@/components/local/c_formModal/index.vue";
635
+
636
+ const editModalRef = ref();
637
+ const Page = createPage(editModalRef);
638
+ const { tableRef, page, queryParam, list, queryItems, toolbars, select } = Page;
639
+
640
+ const activeView = ref("management");
641
+ const mgmtCols = managementColumns();
642
+ const useCols = usageColumns();
643
+
644
+ onMounted(() => select());
645
+ </script>
646
+ ```
647
+
648
+ **要点**:
649
+
650
+ - ❌ 不使用 `el-radio-group` + 手动 `handleViewChange` 切换 columns
651
+ - ✅ 使用 `el-tabs` + 每个 pane 内放独立 BaseTable
652
+ - ✅ 两个表格共享同一 `list` 数据源和 `tableRef`
653
+ - ✅ `v-if` 保证同一时刻只有一个表格实例挂载到 `tableRef`
654
+ - ✅ 列定义函数从 data.ts 导出,在 index.vue 中调用(调用时机在 createPage 之后,确保闭包引用正确)
655
+
656
+ ### Mock 数据要求
657
+
658
+ > **关键**:mock 生成的数据对象必须包含**所有视角**的全部字段,不可仅覆盖默认视角。
659
+ > 切换视角后表格使用同一数据源,如果 mock 数据缺少某视角的字段,切换后会显示空列。
660
+
661
+ ```typescript
662
+ function genRecord() {
663
+ return {
664
+ // 管理视角字段
665
+ customerCode: ...,
666
+ customerName: ...,
667
+ // 使用视角特有字段(管理视角不显示但数据中必须有)
668
+ salesType: ...,
669
+ customerNature: ...,
670
+ businessPerson: ...,
671
+ // ...所有视角的并集
672
+ };
673
+ }
674
+ ```
675
+
676
+ ---
677
+
678
+ ## 复杂表单 → 独立路由页(非弹窗)
679
+
680
+ > 当原型中"新增"/"编辑"打开的不是简单弹窗,而是一个**内容极多的独立页面**(多 Tab、多子表、状态信息区等),必须创建独立路由页而非使用 `c_formModal`。
681
+
682
+ ### 识别规则
683
+
684
+ - 原型中点击"新增申请"/"编辑"后跳转到**独立页面**(URL 变化,有返回按钮)
685
+ - 页面内有**多个 Tab**(如基本信息、资质信息、地址信息、联系人信息、银行信息等)
686
+ - 表单字段**超过 15 个**或包含**子表格**(如业务信息表)
687
+ - 页面头部有**多个操作按钮**(保存并提交、保存、变更历史查询、取消等)
688
+
689
+ ### 平台路由机制(必读)
690
+
691
+ > **核心原则**:平台通过 `generateCurrentRoute(to)` 实现按需路由注册,
692
+ > `hidden=true` 的菜单**仍然可路由**(已实测验证),只是不在侧边栏显示。
693
+ > ❗ `registerMenu` 对 hidden 菜单只是 `return` 跳过处理,但**不从 children 数组中移除**。
694
+ > 导致隐藏菜单仍被 `router.addRoute()` 注册,但 component 是原始字符串(非合法组件)。
695
+ > 因此 `router.push()` 会报 **"Invalid route component"** 错误。
696
+ > 必须使用 `location.href` 触发完整导航,让 `generateCurrentRoute` 用正确组件重新注册路由。
697
+
698
+ 1. **路由路径格式** = `/{父菜单subModule}/{菜单路径camelCase}`,例如 `/aiflow/mmwrCustomerApplyAddForm`
699
+ - 路径**不是**组件路径的 kebab-case 版本
700
+ - 路径**不包含** `produce/production-mmwr` 等构建时前缀
701
+ - 「菜单路径」字段(camelCase)才是真正的路由 path 段
702
+ 2. **隐藏菜单 hidden: true**:独立表单/详情页**必须设为隐藏**,避免菜单栏多出无意义入口
703
+ 3. **pages.ts 变更需重启** dev server(fullImportPlugin 只在首次 transform 时读取)
704
+ 4. **local 组件必须显式 import**:`src/components/local/` 下的组件不会自动全局注册
705
+
706
+ ### FORM_ROUTE 格式
707
+
708
+ ```typescript
709
+ // ✅ 正确:/{subModuleKey}/{menuPath_camelCase}
710
+ const FORM_ROUTE = "/aiflow/mmwrCustomerApplyAddForm";
711
+
712
+ // ❌ 错误:不要用组件路径的 kebab-case
713
+ // const FORM_ROUTE = "/produce/production-mmwr/aiflow/mmwr-customer-apply-add-form";
714
+ ```
715
+
716
+ 如何确定 FORM_ROUTE:
717
+
718
+ - `subModuleKey`:取自 pages.ts 中 `gProd("xxx", { subModuleKey: [...] })` 的 key
719
+ - `menuPath`:取自后端菜单表的「菜单路径」字段(camelCase)
720
+ - 不确定时,在浏览器点击侧边栏已有菜单项,观察 URL 格式
721
+
722
+ ### 实现模式
723
+
724
+ 1. **创建独立表单页**:`mmwr-xxx-form/` 目录(index.vue + data.ts + index.scss)
725
+ 2. **注册为隐藏菜单**:pages.ts 注册 + SYS_MENU_INFO.md 中 `是否隐藏: 是`(平台支持隐藏菜单按需路由)
726
+ 3. **列表页使用 `navigateToForm`**:通过 `envConfig().router.resolve()` 生成 URL,始终用 `location.href` 导航(不用 `router.push`)
727
+
728
+ ```typescript
729
+ import envConfig from "@jhlc/common-core/src/store/env-config";
730
+
731
+ // ✅ 使用 /{subModule}/{menuPath_camelCase} 格式
732
+ const FORM_ROUTE = "/aiflow/mmwrXxxForm";
733
+
734
+ /** 导航到表单页(隐藏菜单路由必须完整导航,触发 generateCurrentRoute 正确注册组件) */
735
+ function navigateToForm(query?: Record<string, string>) {
736
+ const router = envConfig()?.router;
737
+ if (!router) {
738
+ ElMessage.error('路由未初始化,请刷新页面重试');
739
+ return;
740
+ }
741
+ const target: any = { path: FORM_ROUTE };
742
+ if (query) target.query = query;
743
+ location.href = router.resolve(target).href;
744
+ }
745
+
746
+ export function createPage() {
747
+ // ❗ 不需要传 router 参数,navigateToForm 通过 envConfig().router 获取
748
+ // ...
749
+ toolbarDef() {
750
+ return [
751
+ {
752
+ name: "primary",
753
+ label: "新增申请",
754
+ onClick: () => navigateToForm()
755
+ }
756
+ ];
757
+ }
758
+ columnsDef() {
759
+ return [
760
+ // ...
761
+ {
762
+ label: "操作", fixed: "right",
763
+ operations: [
764
+ {
765
+ name: "edit", label: "编辑",
766
+ onClick: (row: any) => navigateToForm({ id: row.id })
767
+ }
768
+ ]
769
+ }
770
+ ];
771
+ }
772
+ }
773
+ ```
774
+
775
+ ### index.vue 模板(列表页改造)
776
+
777
+ ```vue
778
+ <script setup lang="ts">
779
+ import { createPage } from "./data";
780
+
781
+ const Page = createPage();
782
+ // ... 不再引入 c_formModal,不再使用 useRouter
783
+ </script>
784
+ ```
785
+
786
+ ### index.vue 模板(表单页)
787
+
788
+ ```vue
789
+ <template>
790
+ <div class="app-container app-page-container" v-loading="loading">
791
+ <div class="page-header">
792
+ <span class="page-title">客户申请详情</span>
793
+ <span class="page-tag page-tag--add">新增</span>
794
+ <span class="page-tag page-tag--status">未审核</span>
795
+ <el-checkbox v-model="onlyRequired" class="only-required-check"
796
+ >只看必填项</el-checkbox
797
+ >
798
+ </div>
799
+ <div class="page-toolbar">
800
+ <el-button type="danger" @click="handleSaveAndChange"
801
+ >保存并变更</el-button
802
+ >
803
+ <el-button type="warning" @click="handleSave">保存</el-button>
804
+ <el-button @click="handleCancel">取消</el-button>
805
+ </div>
806
+ <!-- ⚠️ local 组件必须显式 import,onlyRequired 传递给子组件 -->
807
+ <c_customerTabs ref="tabsRef" mode="add" :only-required="onlyRequired" />
808
+ </div>
809
+ </template>
810
+
811
+ <script setup lang="ts">
812
+ import { useRoute } from "vue-router";
813
+ import c_customerTabs from "@/components/local/c_customerTabs/index.vue";
814
+
815
+ const route = useRoute();
816
+ const tabsRef = ref();
817
+ const onlyRequired = ref(false);
818
+
819
+ onMounted(() => {
820
+ const id = route.query.id as string;
821
+ if (id) loadDetail(id);
822
+ });
823
+ </script>
824
+
825
+ <style scoped lang="scss">
826
+ @import "./index.scss";
827
+ </style>
828
+ ```
829
+
830
+ ---
831
+
832
+ ## 表单页模式差异处理
833
+
834
+ > 同一共享组件(如 c_customerTabs)在不同模式下(add/change/view)可能有**不同的列、不同的操作、不同的 Tab 顺序**。
835
+
836
+ ### 识别方式
837
+
838
+ 1. **对比多个原型截图**:同一表单在新增 vs 变更模式下的表格列、按钮、Tab 顺序往往不同
839
+ 2. **关键差异点**:
840
+ - 表格列:变更模式可能多出「使用组织」「产品线」「银承贴息」列,而新增模式有「免息天数」「月需求量」等
841
+ - 操作列:变更模式有「编辑 + 删除」,新增模式仅「删除」
842
+ - 新增行方式:所有非查看模式均在表格底部显示 `+新增行` 链接
843
+ - Tab 顺序:不同模式可能交换某些 Tab 的前后位置
844
+ - 状态信息区:所有模式均显示(disabled 只读),用于展示创建时间、创建人、修改时间等
845
+
846
+ ### 实现方式
847
+
848
+ ```vue
849
+ <!-- 表格列:v-if 按 mode 控制 -->
850
+ <el-table-column v-if="isChange" label="使用组织" prop="useOrg" />
851
+ <el-table-column
852
+ v-if="!isChange && !isView"
853
+ label="免息天数"
854
+ prop="interestFreeDays"
855
+ />
856
+
857
+ <!-- 操作列:变更模式多一个编辑按钮 -->
858
+ <el-table-column v-if="!isView" label="操作" :width="isChange ? 120 : 80">
859
+ <template #default="{ row, $index }">
860
+ <el-button v-if="isChange" type="primary" link @click="editRow(row, $index)">编辑</el-button>
861
+ <el-button type="danger" link @click="list.splice($index, 1)">删除</el-button>
862
+ </template>
863
+ </el-table-column>
864
+
865
+ <!-- 底部新增行链接(变更模式) -->
866
+ <div
867
+ v-if="isChange && !isView"
868
+ class="add-row-link"
869
+ @click="addRow"
870
+ >+新增行</div>
871
+
872
+ <!-- Tab 顺序差异:用 v-if 控制渲染位置 -->
873
+ <el-tab-pane
874
+ v-if="isChange"
875
+ label="地址信息"
876
+ name="addressInfo"
877
+ >...</el-tab-pane>
878
+ <el-tab-pane label="联系人信息" name="contactInfo">...</el-tab-pane>
879
+ <el-tab-pane
880
+ v-if="!isChange"
881
+ label="地址信息"
882
+ name="addressInfo"
883
+ >...</el-tab-pane>
884
+
885
+ <!-- 状态信息区(所有模式均显示,disabled 只读展示) -->
886
+ <div class="status-info-section">
887
+ <el-form disabled label-width="100px">
888
+ <el-row :gutter="20">
889
+ <el-col :span="6"><el-form-item label="创建时间"><el-input v-model="statusInfo.createTime" /></el-form-item></el-col>
890
+ <!-- ... -->
891
+ </el-row>
892
+ </el-form>
893
+ </div>
894
+ ```
895
+
896
+ ### Mock 数据
897
+
898
+ - **变更表单打开无 id** 时应加载 mock 数据展示 demo 效果
899
+ - Mock 数据工厂放在共享组件的 `data.ts` 中(如 `createChangeMockData()`)
900
+ - 表单页 data.ts 在 `onMounted` 中判断无 id 则调用 `loadMockData()`
901
+
902
+ ---
903
+
904
+ ## 生成后强制校验(必须执行,不可跳过)
905
+
906
+ > **校验目的**:逐项 diff page-spec JSON 与生成代码,确保零遗漏、零乱序。
907
+ > 校验不通过的项必须立即修复后再向用户报告。
908
+
909
+ ### 第零轮:顺序保真度检查(最重要)
910
+
911
+ > **核心原则:原型顺序 = 代码顺序。** 查询字段、按钮、表格列的排列顺序体现了业务逻辑和用户习惯,不可随意调换。
912
+
913
+ ```
914
+ spec.query 中字段顺序 === queryDef() return 数组中字段顺序?(逐个比对)
915
+ spec.columns 中字段顺序 === columnsDef() return 数组中字段顺序?(selection/index 在最前,其余逐个比对)
916
+ spec.toolbar 中按钮顺序 === toolbarDef() return 数组中按钮顺序?(逐个比对)
917
+ spec.operations 中按钮顺序 === 操作列 operations 数组中按钮顺序?
918
+ spec.features.tabItems 顺序 === 页面 Tab 组件中顺序?
919
+ ```
920
+
921
+ **任何顺序不一致 → 立即调整为与 spec(即原型)一致。**
922
+
923
+ ### 第一轮:字段计数 diff
924
+
925
+ 对照 page-spec JSON,逐项计数:
926
+
927
+ ```
928
+ spec.query.length === queryDef() return 数组长度?
929
+ spec.columns.length === columnsDef() return 数组长度(不含 selection/index)?
930
+ spec.toolbar.length === toolbarDef() return 数组长度?
931
+ spec.operations.length === 操作列 operations 数组长度?
932
+ ```
933
+
934
+ **任何计数不等 → 停下来,找出缺失项补全。**
935
+
936
+ ### 第二轮:字段名逐一比对
937
+
938
+ ```
939
+ spec.query 中每个 field → queryDef() 中存在 name === field 的项?
940
+ spec.columns 中每个 field → columnsDef() 中存在 name === field 的项?
941
+ spec.toolbar 中每个 label → toolbarDef() 中存在 label === label 的项?
942
+ spec.operations 中每个 label → 操作列 operations 中存在?
943
+ ```
944
+
945
+ **任何字段找不到对应 → 立即补全。**
946
+
947
+ ### 第三轮:子表交互完整性
948
+
949
+ ```
950
+ spec.subTables 中 editable === true 的子表:
951
+ □ 模板中有新增按钮(v-if="mode !== 'view'")?
952
+ □ 模板中有删除按钮/操作列?
953
+ □ script 中有 addXxxRow() 方法?
954
+
955
+ spec.subTables 中 inlineEdit === true 的子表:
956
+ □ 单元格使用 el-input / jh-select 等可编辑组件?
957
+ ```
958
+
959
+ ### 第四轮:字典 & 特殊交互
960
+
961
+ ```
962
+ spec 中所有 type === "dict" 的字段:
963
+ □ queryDef 对应项有 logicType: BusLogicDataType.dict + logicValue: dictCode?
964
+ □ columnsDef 对应项有 logicType + logicValue?
965
+
966
+ spec.features.tabSwitch === true:
967
+ □ index.vue 中有 Tab 切换组件?
968
+ □ data.ts 中有 Tab 切换处理逻辑?
969
+ □ 不同 Tab 切换后的列定义(如有差异)是否各自与原型一致?
970
+
971
+ spec.features.viewSwitch === true:
972
+ □ index.vue 中有 el-tabs 视角切换组件?
973
+ □ data.ts 中有 managementColumns()/usageColumns() 等多视角列定义?
974
+ □ 每个视角的列数量、顺序与原型严格一致?
975
+ □ el-tabs 各 pane 内 BaseTable 使用对应视角的 columns?
976
+ □ mock 数据包含所有视角字段的并集(切换视角后不出现空列)?
977
+
978
+ spec.features.hiddenMenu === true:
979
+ □ SYS_MENU_INFO 标注为隐藏?
980
+ ```
981
+
982
+ ### 第五轮:文件完整性
983
+
984
+ ```
985
+ □ index.vue — 模板 + createPage() 解构 + onMounted
986
+ □ data.ts — API_CONFIG + createPage()/useXxx() 工厂函数
987
+ □ index.scss — 存在(可为空)
988
+ □ api.md — 字段名与 data.ts 一致
989
+ □ pages.ts 注册行 — 已提供
990
+ □ style: @import "./index.scss"
991
+ □ 外层 class: "app-container app-page-container"
992
+ □ API: getAction/postAction(非 axios)
993
+ ```
994
+
995
+ ### 校验报告模板
996
+
997
+ 校验完成后输出简要报告:
998
+
999
+ ```
1000
+ ✅ query: spec 12 项 = code 12 项,顺序一致
1001
+ ✅ columns: spec 16 项 = code 16 项,顺序一致
1002
+ ✅ toolbar: spec 7 项 = code 7 项,顺序一致,颜色正确
1003
+ ✅ operations: spec 3 项 = code 3 项,顺序一致
1004
+ ✅ tabs: spec 3 项 = code 3 项,顺序一致
1005
+ ✅ subTables: businessInfo(editable) — 有新增/删除
1006
+ ✅ dict 字段: 8 个全部配置 logicType
1007
+ ✅ 文件完整性: 4/4
1008
+ ✅ mock URL: 全部带 /dev-api 前缀,list 用 GET + query
1009
+ ```
1010
+
1011
+ 如有不通过项:
1012
+
1013
+ ```
1014
+ ❌ columns: spec 35 项 ≠ code 34 项 — 缺少 customerName 列 → 已补全
1015
+ ❌ toolbar 顺序: spec [新增申请, 删除, 启用] ≠ code [新增, 启用, 删除] → 已调整
1016
+ ❌ operations: spec [查看, 编辑, 删除] ≠ code [编辑, 删除] — 缺少"查看" → 已补全
1017
+ ```
1018
+
1019
+ ---
1020
+
1021
+ ## 附加输出
1022
+
1023
+ ### pages.ts 注册片段
1024
+
1025
+ ```typescript
1026
+ // 添加到 vite/plugins/shared/pages.ts 对应模块的数组中
1027
+ ["[kebab-case-目录名]", "[页面中文名]"],
1028
+ ```
1029
+
1030
+ ### 菜单配置
1031
+
1032
+ > 菜单配置统一生成到 `.github/reports/SYS_MENU_INFO.md`(集中式),生成规则见 SKILL.md 主文件的「SYS_MENU_INFO 生成规则」章节。
1033
+
1034
+ ---
1035
+
1036
+ ### Mock 数据文件(mock/[页面kebab-name].ts)
1037
+
1038
+ 在项目根目录 `mock/` 下生成,`vite-plugin-mock`(`mockPath: "./mock"`)自动加载,**无需手动注册**。
1039
+
1040
+ **要求**:
1041
+
1042
+ - URL 和字段与 api.md 完全一致
1043
+ - 使用 `MockMethod[]` 类型 + `mockjs` 生成数据
1044
+ - 分页查询返回 `{ code, msg, data: { records, total, size, current } }` 结构
1045
+ - 字典字段的值从 api.md 字典表中取
1046
+
1047
+ **模板**:
1048
+
1049
+ ```typescript
1050
+ import type { MockMethod } from "vite-plugin-mock";
1051
+ import Mock from "mockjs";
1052
+
1053
+ const Random = Mock.Random;
1054
+
1055
+ // 字典选项:从 api.md 字典表复制
1056
+ const DICT = {
1057
+ status_code: ["值1", "值2"],
1058
+ };
1059
+
1060
+ // 单条记录生成器:字段对齐 api.md Response
1061
+ function genRecord() {
1062
+ return {
1063
+ id: Random.id(),
1064
+ fieldName: Random.cword(2, 4),
1065
+ statusField: Random.pick(DICT.status_code),
1066
+ createTime: Random.datetime("yyyy-MM-dd HH:mm:ss"),
1067
+ };
1068
+ }
1069
+
1070
+ const dataPool = Array.from({ length: 50 }, genRecord);
1071
+
1072
+ const mockApi: MockMethod[] = [
1073
+ {
1074
+ // ⚠️ URL 必须带 /dev-api 前缀(axios baseURL = /dev-api)
1075
+ url: "/dev-api/[服务缩写]/[资源名]/list",
1076
+ // ⚠️ AbstractPageQueryHook 默认 requestMethod = GET,所以 list 必须用 get
1077
+ method: "get",
1078
+ response: ({ query }: any) => {
1079
+ const current = Number(query?.current) || 1;
1080
+ const size = Number(query?.size) || 20;
1081
+ const start = (current - 1) * size;
1082
+ return {
1083
+ code: 2000,
1084
+ message: "操作成功",
1085
+ data: {
1086
+ records: dataPool.slice(start, start + size),
1087
+ total: dataPool.length,
1088
+ size,
1089
+ current,
1090
+ },
1091
+ };
1092
+ },
1093
+ },
1094
+ {
1095
+ url: "/dev-api/[服务缩写]/[资源名]/getById",
1096
+ method: "get",
1097
+ response: ({ query }: any) => ({
1098
+ code: 2000,
1099
+ message: "操作成功",
1100
+ data: dataPool.find((d) => d.id === query.id) || dataPool[0],
1101
+ }),
1102
+ },
1103
+ {
1104
+ url: "/dev-api/[服务缩写]/[资源名]/remove",
1105
+ method: "delete",
1106
+ response: ({ query, body }: any) => {
1107
+ const id = query?.id || body?.id;
1108
+ const ids = id ? [id] : query?.ids?.split(",") || body?.ids || [];
1109
+ ids.forEach((rid: string) => {
1110
+ const idx = dataPool.findIndex((d) => d.id === rid);
1111
+ if (idx > -1) dataPool.splice(idx, 1);
1112
+ });
1113
+ return { code: 2000, message: "删除成功", data: null };
1114
+ },
1115
+ },
1116
+ {
1117
+ url: "/dev-api/[服务缩写]/[资源名]/save",
1118
+ method: "post",
1119
+ response: ({ body }: any) => {
1120
+ const newRecord = { ...genRecord(), ...body, id: Random.id() };
1121
+ dataPool.unshift(newRecord);
1122
+ return { code: 2000, message: "保存成功", data: { id: newRecord.id } };
1123
+ },
1124
+ },
1125
+ {
1126
+ url: "/dev-api/[服务缩写]/[资源名]/update",
1127
+ method: "post",
1128
+ response: ({ body }: any) => {
1129
+ const idx = dataPool.findIndex((d) => d.id === body?.id);
1130
+ if (idx > -1) Object.assign(dataPool[idx], body);
1131
+ return { code: 2000, message: "更新成功", data: null };
1132
+ },
1133
+ },
1134
+ ];
1135
+
1136
+ export default mockApi;
1137
+ ```
1138
+
1139
+ > **Mock URL 前缀规则**:项目 axios `baseURL` = `/dev-api`(由 `VUE_APP_BASE_API` 配置),
1140
+ > `vite-plugin-mock` 用 `pathToRegexp` 严格匹配浏览器实际请求路径,
1141
+ > 所以 mock URL 必须带 `/dev-api` 前缀才能拦截成功。
1142
+ >
1143
+ > 参考实现:`mock/customer-archive.ts`、`mock/temp-customer-archive.ts`、`mock/customer-apply.ts`
1144
+
1145
+ ---