@manohub/app-kit 0.2.8 → 0.2.9

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.
package/CONTRACT.md CHANGED
@@ -19,6 +19,9 @@ export const { mount, unmount } = createSubApp({
19
19
  // 用 vue-i18n 的应用走插件注入,不要用本包的 i18n 选项(那是 i18next 语义)
20
20
  extraPlugins: [i18n],
21
21
  rootPathAliases: ['/index.html'], // 见 §9「初始守卫与选择模式」
22
+ // 路由历史模式:缺省 'history'(micro-app baseroute 口径)。宿主不支持深路径 fallback 时改 'hash';
23
+ // 需要自定义 base 等细节时传 vue-router 的 RouterHistory 实例。见 §9 第 13 条
24
+ // history: 'hash',
22
25
  })
23
26
  ```
24
27
 
@@ -119,6 +122,10 @@ import { AppShell, AppPanel, AppTable, AppButton, notify } from '@manohub/app-ki
119
122
  </AppShell>
120
123
  ```
121
124
 
125
+ - `AppShell.Header` 的五个位:`title`(页标题)/ `subTitle`(**下一行的次要说明**,不传即不渲染)/
126
+ `icon`(不传用内建图标)/ `toolbar`(**页面级操作**位)/ `tabs`(页头下方页签区)。
127
+ `subTitle` 给的是「一句话说明**什么数据**」(对象名、统计口径),不是第二颗标题,也不是放操作的地方;
128
+ 跟随数据变化时直接传值即可(组件只渲染文本)。`title` 不传 = 整块走自定义页头出口,此时 `subTitle` 不生效。
122
129
  - 筛选字段 > 3 或跨区域时,在 `AppShell.Header` **之前**加 `<AppShell.Filter>`(页面级筛选的唯一位置)。
123
130
  - **分页归属跟「承载表格的容器」走**:表格直接挂在 `AppShell.Body` 下 → 分页放 `AppShell.Footer`(模板 A);
124
131
  表格在 `AppPanel` 里 → 分页放 `AppPanel.Footer`(模板 B)。
@@ -234,7 +241,7 @@ import { AppShell, AppPanel, AppTable, AppButton, notify } from '@manohub/app-ki
234
241
  </AppForm>
235
242
  ```
236
243
 
237
- - `label` 是字段名;`required` 让星号自动落在 label 文本**左侧**;`error` 非空即进入错误态;
244
+ - `label` 是字段名;`required` 让星号自动落在 label 文本**之后**(`服务名称*`);`error` 非空即进入错误态;
238
245
  `hint` 是常驻说明。错误与说明排在控件**下方**(文档流),不会遮挡下一行。
239
246
  - `label` 与 `required` 都不给时**不渲染 label 列**,控件列占满整行 —— 整块内容(如列编辑区)
240
247
  与纯说明行用这个形态,不要另起一套 div 排版。
@@ -679,6 +686,10 @@ const list = useClientPagination(() => filteredRows.value)
679
686
  `component-audit.mjs`(组件使用:禁底层直连 / 禁底层风格写法)、
680
687
  `structure-audit.mjs`(页面结构:骨架必用 / 禁自绘页头面板头 / 字段数上限)。
681
688
  **排查单条**时才直接跑它(`pnpm exec appkit lint:style`),不必挂成脚本。
689
+ - **「路由页」按目录判,与目录约定对齐**(S1):`src/views|pages/` 下的 `.tsx` 才按路由页要求套 `AppShell`,
690
+ 其中 **`…/components/` 子树内的文件不算路由页** —— 页面内的可复用件(弹窗 / 抽屉 / 表单块 / 子表格)
691
+ 放同页面的 `components/` 子目录,用 `AppPanel` / `AppForm` / `AppDialog` 承载即可。
692
+ 嵌套的页面目录(`views/<页>/<页>.tsx`)**仍然是路由页**,所以目录别只往深了挪、要把件放进 `components/`。
682
693
  - `--changed` 只跑改动文件(pre-commit 用;非 git 环境不裁剪,宁可多查)。
683
694
  - 每条违规都会给出 `file:line` + 片段 + **唯一改法**(`correction.summary` / `.example`)与 **`doc` 锚点**
684
695
  (指回本文件对应章节)。**照 `correction` 改,不要自己另想一套。**
@@ -780,7 +791,8 @@ pnpm --filter @manohub/app-kit test:unit # vitest(契约单测)+ 护栏
780
791
  8. **`AppForm`(表单行)为自建件**:底层对应件 `FDynamicFormGroup` 是**动态表单(元数据驱动)体系**的分组件 ——
781
792
  控件要经 `editor` 描述对象交给它渲染、标签宽度与字段限宽写死在 farris 表单类里、校验信息是**绝对定位浮层**。
782
793
  与本包三条口径冲突(控件由消费方直接写 / 只收朴素业务值 / 错误走文档流),故按 §7 自建;
783
- 只沿用 farris 的两条既有约定:label 右对齐、必填星号在 label 文本左侧。
794
+ 沿用 farris 的一条约定:label 缺省右对齐(`labelAlign` 可切左);
795
+ **必填星号固定在 label 文本之后**(`服务名称*`)—— 这一条与 farris 的「星号在文本左侧」**有意不同**。
784
796
  9. **`AppSteps` 复刻了底层步骤条的节点结构**:底层 `FStep` 的默认模板**只渲染 `title`**
785
797
  (`description` / `icon` / `class` / `status` 四个字段压根不被读取),第二行副标题只能自己给 `stepTemplate`;
786
798
  本包按底层默认结构复刻 DOM 并沿用其类名(底层配色与连接线样式照旧生效)。
@@ -810,3 +822,9 @@ pnpm --filter @manohub/app-kit test:unit # vitest(契约单测)+ 护栏
810
822
  | `Invalid prop: … prop "popupOffsetX"/"popupOffsetY". Expected Object, got Number 0`(来自 `FButtonEdit`,链路里能看到 `FComboList`) | 上游 `button/index.esm.js` 写的是 `{ type: Object, default: 0 }` | **无法在应用侧消除**(`FComboList` 不转发这两个 prop)。每渲染一个 `AppSelect` 会刷 2 条,属上游缺陷,忽略即可 |
811
823
  | `inject() can only be used inside setup()` | farris 内部与 `@gsp-sys/utils` 在组件上下文外调用 `inject`(app-kit 自己的 `inject` 只在 `AppForm.Item` 的 `setup` 内,`modalService` 走 `app.runWithContext`) | 平台/上游行为,与本包无关 |
812
824
  | `Extraneous non-props attributes (customClass) …`(来自 `FModal`) | 早期 `AppDialog` 曾把 class 当 `customClass` 转发,而 `FModal` 运行时没有该 prop、根节点又是 Teleport ⇒ 类名被丢弃还刷告警 | 已移除转发,**不再出现**;消费方要钩子请包在内容元素上 |
825
+ 13. **路由历史模式可配,缺省仍是 `history`**:`createSubApp` 默认 `createWebHistory()`(micro-app 以 baseroute 隔离子应用路由,
826
+ 这也是平台既有子应用的口径)。宿主**不支持深路径 fallback**、或门户不允许子应用改写地址栏时,传 `history: 'hash'` ——
827
+ 路由落进 hash(URL 形如 `<子应用产物路径>#/x`),既不要求宿主 rewrite,也不占用宿主的 pathname 空间。
828
+ 需要自定义 base 等细节(如 `createWebHistory('/subapp/x/')`)时,直接传 vue-router 的 `RouterHistory` 实例,本包原样使用、
829
+ 不干预 base 与滚动行为。两种模式下 §9 第 2 条的初始守卫口径不变(按 `to.path` 判,hash 模式下的 path 不含 `#`)。
830
+ 用法示例见 §1。
package/README.md CHANGED
@@ -75,6 +75,8 @@ export const { mount, unmount } = createSubApp({
75
75
  // extraPlugins: [i18n],
76
76
  // 以 `index.html?xxx=1` 直开的入口(iframe / 选择模式)必须登记,否则首屏守卫会清空 query
77
77
  // rootPathAliases: ['/index.html'],
78
+ // 宿主不支持深路径 fallback 时改 hash;需要自定义 base 时传 RouterHistory 实例
79
+ // history: 'hash',
78
80
  })
79
81
  ```
80
82
 
@@ -42,7 +42,10 @@ export interface AppFormItemProps {
42
42
  * —— 用于无字段名的整块内容(如列编辑区)与纯说明行。
43
43
  */
44
44
  label?: string;
45
- /** 必填标记:星号渲染在 label 文本**左侧**(沿 farris 表单字段的同一约定;label 整体右对齐) */
45
+ /**
46
+ * 必填标记:星号渲染在 label 文本**之后**(`服务名称*`)—— 星号是字段名的一部分,
47
+ * 跟着字段名走;与 farris 动态表单「星号在左」的写法**有意不同**(本仓口径)。
48
+ */
46
49
  required?: boolean;
47
50
  /** 校验错误文案:非空即进入 invalid 态,文案排布在控件**下方**(不遮挡后续行) */
48
51
  error?: string;
@@ -86,7 +89,8 @@ export interface AppFormItemProps {
86
89
  * (inline 6rem 右对齐、字段 max-width 26.625rem),校验信息走**绝对定位的浮层提示条**。
87
90
  * 与本仓三条口径冲突:① 控件由消费方直接写(不构造底层配置对象);② 数据入参只收朴素业务值;
88
91
  * ③ 错误与说明按文档流排布(浮层在弹窗里会压住下一行的控件)。故按「缺件处置流程」自建,
89
- * 沿 farris 的既有约定:必填星号固定在 label 文本左侧;**label 对齐是维度**(`labelAlign`,
92
+ * 必填星号固定在 label 文本**之后**(`服务名称*`,有意偏离 farris 的「星号在左」);
93
+ * **label 对齐是维度**(`labelAlign`,
90
94
  * 默认 `right`(与 farris / 主流水平表单一致),`left` 时所有 label 起点一致)。
91
95
  *
92
96
  * ## 宽表单怎么处理(两条杠杆,可叠加)
@@ -86,11 +86,11 @@ const AppFormItem = /* @__PURE__ */ defineComponent({
86
86
  "style": styles
87
87
  }, [showLabel ? createVNode("label", {
88
88
  "class": "ak-form-item__label"
89
- }, [props.required ? createVNode("span", {
90
- "class": "ak-form-item__required"
91
- }, [createTextVNode("*")]) : null, createVNode("span", {
89
+ }, [createVNode("span", {
92
90
  "class": "ak-form-item__label-text"
93
- }, [props.label])]) : null, createVNode("div", {
91
+ }, [props.label]), props.required ? createVNode("span", {
92
+ "class": "ak-form-item__required"
93
+ }, [createTextVNode("*")]) : null]) : null, createVNode("div", {
94
94
  "class": "ak-form-item__control",
95
95
  "style": controlWidth ? {
96
96
  maxWidth: `${controlWidth}px`
@@ -1,9 +1,18 @@
1
1
  import { QueryClient } from '@tanstack/vue-query';
2
2
  import { type App as VueApp, type Component, type Plugin } from 'vue';
3
- import { type RouteRecordRaw } from 'vue-router';
3
+ import { type RouterHistory, type RouteRecordRaw } from 'vue-router';
4
+ /**
5
+ * 路由历史模式:
6
+ * - `'history'`(缺省):正常 history,micro-app 以 baseroute 隔离子应用路由;
7
+ * - `'hash'`:宿主**不支持深路径 fallback**(刷新深路径 404)、或门户不允许子应用改写地址栏时用;
8
+ * - 传 `RouterHistory` 实例(如 `createWebHistory('/subapp/x/')`):自行定制 base 等细节。
9
+ */
10
+ export type CreateSubAppHistory = 'history' | 'hash' | RouterHistory;
4
11
  export interface CreateSubAppOptions {
5
12
  rootComponent: Component;
6
13
  routes: RouteRecordRaw[];
14
+ /** 路由历史模式(缺省 `'history'`)。宿主是否支持深路径 fallback 见 `CONTRACT.md` §9 第 13 条 */
15
+ history?: CreateSubAppHistory;
7
16
  i18n?: {
8
17
  resources: Record<string, Record<string, unknown>>;
9
18
  fallbackLng?: string;
@@ -32,6 +41,9 @@ export interface CreateSubAppOptions {
32
41
  * farris 插件(app.use(Farris))注册在本工厂内部 —— farris 只允许存在于 app-kit,
33
42
  * 应用侧不得 import '@farris/ui-vue'(oxlint 护栏)。
34
43
  *
44
+ * 路由历史模式由 `history` 决定:缺省 `'history'`(micro-app 以 baseroute 隔离),
45
+ * 宿主不支持深路径 fallback 时传 `'hash'`,需要自定义 base 等细节时直接传 `RouterHistory` 实例。
46
+ *
35
47
  * @example
36
48
  * export const { mount, unmount } = createSubApp({
37
49
  * rootComponent: Root,
@@ -2,7 +2,7 @@ import Farris from "@farris/ui-vue";
2
2
  import { VueQueryPlugin } from "@tanstack/vue-query";
3
3
  import { createPinia } from "pinia";
4
4
  import { defineComponent, ref, provide, h, createApp } from "vue";
5
- import { createRouter, createWebHistory } from "vue-router";
5
+ import { createRouter, createWebHistory, createWebHashHistory } from "vue-router";
6
6
  import { installInitialGuard } from "./initial-guard.js";
7
7
  import { createQueryClient } from "./create-query-client.js";
8
8
  import { setupI18n } from "../providers/setup-i18n.js";
@@ -11,6 +11,7 @@ function createSubApp(options) {
11
11
  const {
12
12
  rootComponent,
13
13
  routes,
14
+ history: historyMode = "history",
14
15
  i18n,
15
16
  queryClient,
16
17
  extraPlugins = [],
@@ -26,7 +27,7 @@ function createSubApp(options) {
26
27
  let removeGlobalDataListener = null;
27
28
  let i18nInstance = null;
28
29
  const mount = () => {
29
- router = createRouter({ history: createWebHistory(), routes });
30
+ router = createRouter({ history: resolveRouterHistory(historyMode), routes });
30
31
  const ContainerRoot = defineComponent({
31
32
  name: "AppKitRoot",
32
33
  setup() {
@@ -95,6 +96,11 @@ function createSubApp(options) {
95
96
  }
96
97
  return { mount, unmount };
97
98
  }
99
+ function resolveRouterHistory(value) {
100
+ if (value === "history") return createWebHistory();
101
+ if (value === "hash") return createWebHashHistory();
102
+ return value;
103
+ }
98
104
  function exposeToHost(mount, unmount) {
99
105
  if (typeof window === "undefined") return;
100
106
  const w = window;
@@ -1,4 +1,4 @@
1
- export { createSubApp, type CreateSubAppOptions } from './create-sub-app';
1
+ export { createSubApp, type CreateSubAppHistory, type CreateSubAppOptions } from './create-sub-app';
2
2
  export { installInitialGuard } from './initial-guard';
3
3
  export { createQueryClient } from './create-query-client';
4
4
  export { setupI18n, i18n, type SetupI18nOptions } from '../providers/setup-i18n';
@@ -2,6 +2,11 @@ import { type PropType, type SlotsType, type VNodeChild } from 'vue';
2
2
  export interface AppShellHeaderProps {
3
3
  /** 传入 title 走内置布局;不传则纯 slot 直出(自定义页头出口) */
4
4
  title?: string;
5
+ /**
6
+ * 页头副标题:标题**下一行**的次要说明(对象名、统计口径等),随 `title` 一起进内置布局。
7
+ * 不传即不渲染(页头条高度不变);`title` 不传时本项不生效(整块走自定义页头出口)。
8
+ */
9
+ subTitle?: string;
5
10
  /**
6
11
  * 标题左侧图标:**svg 节点或返回节点的函数**皆可。缺省渲染设计稿默认图标
7
12
  * (MH后台-0911 node 0:3904:26×26 圆角方块 #2660FF)。
@@ -55,6 +60,10 @@ export declare const AppShell: {
55
60
  type: StringConstructor;
56
61
  default: undefined;
57
62
  };
63
+ subTitle: {
64
+ type: StringConstructor;
65
+ default: undefined;
66
+ };
58
67
  icon: {
59
68
  type: PropType<any>;
60
69
  default: undefined;
@@ -74,6 +83,10 @@ export declare const AppShell: {
74
83
  type: StringConstructor;
75
84
  default: undefined;
76
85
  };
86
+ subTitle: {
87
+ type: StringConstructor;
88
+ default: undefined;
89
+ };
77
90
  icon: {
78
91
  type: PropType<any>;
79
92
  default: undefined;
@@ -88,6 +101,7 @@ export declare const AppShell: {
88
101
  };
89
102
  }>> & Readonly<{}>, {
90
103
  title: string;
104
+ subTitle: string;
91
105
  icon: any;
92
106
  toolbar: any;
93
107
  tabs: any;
@@ -36,6 +36,10 @@ const AppShellHeader = /* @__PURE__ */ defineComponent({
36
36
  type: String,
37
37
  default: void 0
38
38
  },
39
+ subTitle: {
40
+ type: String,
41
+ default: void 0
42
+ },
39
43
  icon: {
40
44
  type: [Function, Object, Array, String],
41
45
  default: void 0
@@ -81,7 +85,9 @@ const AppShellHeader = /* @__PURE__ */ defineComponent({
81
85
  "class": "ak-shell-header-icon"
82
86
  }, [renderIcon()]), createVNode("h1", {
83
87
  "class": "ak-shell-header-title"
84
- }, [props.title])])]), toolbar ? createVNode("div", {
88
+ }, [props.title])]), props.subTitle ? createVNode("div", {
89
+ "class": "ak-shell-header-subtitle"
90
+ }, [props.subTitle]) : null]), toolbar ? createVNode("div", {
85
91
  "class": "ak-shell-header-toolbar"
86
92
  }, [toolbar]) : null]), tabs ? createVNode("div", {
87
93
  "class": "ak-shell-header-tabs"
@@ -78,6 +78,19 @@
78
78
  color: var(--ui-base-content);
79
79
  }
80
80
 
81
+ /* 页头副标题(Header 的 subTitle):标题下一行的次要说明,取 caption 档字号与次要色。
82
+ 间距只用 margin-top 4:页头条的上下留白由 .ak-shell-header 的 padding 提供,
83
+ 这里只负责标题行与副标题行之间的距离 —— 有副标题时条目高度自然变高(56 → 80),
84
+ 不再另调 padding(避免「有/无副标题」两套页头规格)。
85
+ 工具条与文本块**底线对齐**是 .ak-shell-header-inner 的既有口径(flex-end),
86
+ 有副标题时即与副标题行对齐,不在本项里改。 */
87
+ .ak-shell-header-subtitle {
88
+ margin-top: var(--ui-space-1);
89
+ font-size: var(--ui-font-caption);
90
+ line-height: var(--ui-line-caption);
91
+ color: var(--ui-base-content-muted);
92
+ }
93
+
81
94
  /* 筛选区块(Header 上方的第一子块):query-solution / 筛选条。
82
95
  无边框;padding-bottom 恒为 0 —— 与 Header 的间距由 Header 自身的 top padding
83
96
  (14px)提供,避免两块 padding 叠加成双倍间距 */
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 合规样本里的 `components/` 子树文件:没有 AppShell 也应该全绿。
3
+ * 一旦 S1 的判定退回「路径里有 views/ 就算路由页」,本样本会变红 —— 这是回归锁。
4
+ */
5
+ export const PickerDialog = () => <div class="other-picker" data-probe="clean-picker" />
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 嵌套页面目录下的真路由页(S1 必须命中):
3
+ * 反向锁定「只认 views 直属文件」这种收窄写法 —— 那样写会让这类页面静默漏报。
4
+ */
5
+ export const InnerPage = () => <section data-probe="inner" />
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 页面内的可复用件(弹窗):按约定放同页面的 `components/` 子目录 → **不受 S1 约束**(不应出现在报告里)。
3
+ * 它没有 AppShell 是正确写法(弹窗用 AppDialog / AppPanel 承载)。
4
+ */
5
+ export const PickerDialog = () => <div data-probe="picker" />
@@ -150,6 +150,29 @@ test('S4:原生按钮、「无 href 的锚点」、跨行写法的原生输入
150
150
  }
151
151
  })
152
152
 
153
+ test('S1:路由页判定按目录约定 —— 嵌套页面目录仍算,components/ 子树不算', () => {
154
+ const root = makeConsumer('violations-src')
155
+ try {
156
+ const { out } = runAudit('structure-audit.mjs', root)
157
+ const hits = out.split('\n').filter((line) => line.includes('structure/no-shell'))
158
+ assert.ok(
159
+ hits.some((line) => line.includes('no-shell-page.tsx')),
160
+ `views 直属的路由页应命中,实际:${hits.join(' | ')}`,
161
+ )
162
+ assert.ok(
163
+ hits.some((line) => line.includes('nested-dir/inner-page.tsx')),
164
+ `嵌套页面目录下的路由页不能被漏掉(那正是「只认 views 直属文件」的收窄写法),实际:${hits.join(' | ')}`,
165
+ )
166
+ // 弹窗 / 抽屉这类可复用件放同页面的 components/ 子目录 → 不是路由页,不该套 AppShell(契约 §4.3 / §4.10)
167
+ assert.ok(
168
+ !out.includes('picker-dialog.tsx'),
169
+ `components/ 子树内的可复用件不该报 structure/no-shell,实际输出:\n${out}`,
170
+ )
171
+ } finally {
172
+ rmSync(root, { recursive: true, force: true })
173
+ }
174
+ })
175
+
153
176
  test('合规样本:三条护栏必须全绿(不得误报)', () => {
154
177
  const root = makeConsumer('clean-src')
155
178
  try {
package/lint/shared.mjs CHANGED
@@ -304,7 +304,8 @@ export const CORRECTIONS = {
304
304
  doc: 'CONTRACT.md#3-统一-prop-词表',
305
305
  },
306
306
  'structure/no-shell': {
307
- summary: '路由级页面必须用 AppShell 承载结构。',
307
+ summary:
308
+ '路由级页面必须用 AppShell 承载结构(页面内的可复用件放同页面的 components/ 子目录,不属于路由页、不受本规则约束)。',
308
309
  example: '<AppShell><AppShell.Header title="…" /><AppShell.Body mode="table">…</AppShell.Body></AppShell>',
309
310
  doc: 'CONTRACT.md#41-三种页面模板',
310
311
  },
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * structure-audit —— 页面结构护栏(S1–S7 + 自绘竖直边框检查)。
4
4
  *
5
- * S1 路由级页面必须用 AppShell
5
+ * S1 路由级页面必须用 AppShell(views|pages 下的 tsx;`components/` 子树内的可复用件除外)
6
6
  * S2 AppShell.Header 必须传 title(或登记为自定义页头白名单)
7
7
  * S3 AppShell.Body 必须显式声明 mode
8
8
  * S4 禁原生 button/input/select/textarea 带 class 承载外观
@@ -32,8 +32,22 @@ const cwd = args.cwd
32
32
  const config = loadConfig(cwd)
33
33
  const changed = args.changed ? changedFiles(cwd) : null
34
34
 
35
- /** 路由级页面的判定:位于 views/pages 目录下的 tsx(弹窗组件在 components 下,不受 S1 约束)。 */
35
+ /**
36
+ * 路由级页面的判定:**`views|pages` 目录下的 tsx,但 `components/` 子树不算**。
37
+ *
38
+ * 页面内的可复用件(弹窗 / 抽屉 / 表单块 / 子表格)按通行约定放在**同页面的 `components/` 子目录**下,
39
+ * 它们该由 `AppPanel` / `AppForm` / `AppDialog` 承载、不套 `AppShell`(契约 §4.3 / §4.7 / §4.10)。
40
+ *
41
+ * 早期实现只判「路径里有没有 `views|pages/`」,于是 `views/<页>/components/*.tsx` 全被当成路由页报 S1
42
+ * —— 契约要求的目录写法反而被判违规(真实误报:某应用 13 个 tsx 全中,其中 10 个是弹窗/表单/抽屉)。
43
+ * 这里按**目录段**排除,判据与契约的目录约定对齐。
44
+ *
45
+ * 注意:**嵌套的页面目录(`views/<页>/<页>.tsx`)仍然是路由页**,不要收窄成「只认 views 的直属文件」,
46
+ * 那会让嵌套页面目录下的真路由页静默漏报。
47
+ */
36
48
  const ROUTE_PAGE_RE = /(?:^|\/)(?:views|pages)\//
49
+ /** 路由页目录内的可复用件目录:S1 不查这里面的文件(`components-x` 这类同前缀目录不误伤) */
50
+ const PAGE_PARTIAL_DIR_RE = /(?:^|\/)components\//
37
51
  /** 表单控件(S7 计数用)。 */
38
52
  const FORM_CONTROLS = /<(AppInput|AppSearchBox|AppSelect|AppDatePicker|AppTextarea|AppNumber)\b/g
39
53
  /** 自绘页头/面板头类名(S6)。 */
@@ -85,8 +99,9 @@ for (const app of config.apps) {
85
99
  const hasShell = /<AppShell\b/.test(source)
86
100
  if (/<AppShell\.Split\b/.test(source)) usesSplit = true
87
101
 
88
- // S1:路由级页面必须用 AppShell
89
- if (ROUTE_PAGE_RE.test(rel) && file.endsWith('.tsx') && !hasShell) {
102
+ // S1:路由级页面必须用 AppShell(`views|pages` 下、但不在 `components/` 子树内)
103
+ const isRoutePage = ROUTE_PAGE_RE.test(rel) && !PAGE_PARTIAL_DIR_RE.test(rel)
104
+ if (isRoutePage && file.endsWith('.tsx') && !hasShell) {
90
105
  violations.push(violation(app.name, file, 'structure/no-shell', 1, '路由级页面必须使用 AppShell 承载结构', rel))
91
106
  }
92
107
  // S2 / S3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manohub/app-kit",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "子应用统一骨架层:入口编排(createSubApp)、布局契约(AppShell)、页面组件、原子件、服务、样式底座。farris 被收敛在本包内部,对外只暴露标准 API。",
@@ -63,7 +63,7 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@farris/ui-vue": "^1.8.4",
66
- "@manohub/icon": "^0.2.8"
66
+ "@manohub/icon": "^0.2.9"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@tanstack/vue-query": "catalog:",
@@ -54,6 +54,8 @@ export const { mount, unmount } = createSubApp({
54
54
  // extraPlugins: [i18n],
55
55
  // 以 `index.html?xxx=1` 直开的入口(iframe / 选择模式)必须登记,否则首屏守卫会清空 query
56
56
  // rootPathAliases: ['/index.html'],
57
+ // 宿主不支持深路径 fallback(刷新深路径 404)时改 hash;需要自定义 base 时传 RouterHistory 实例
58
+ // history: 'hash',
57
59
  })
58
60
  ```
59
61
 
@@ -62,6 +64,7 @@ export const { mount, unmount } = createSubApp({
62
64
  | 选项 | 用途 |
63
65
  |---|---|
64
66
  | `rootComponent` / `routes` | 根组件与路由表(路由由应用维护,骨架层只负责装载与守卫) |
67
+ | `history` | 路由历史模式:缺省 `'history'`(micro-app 以 baseroute 隔离);宿主不支持深路径 fallback(刷新深路径 404)或不允许子应用改写地址栏时传 `'hash'`;要自定义 base 等细节则直接传 vue-router 的 `RouterHistory` 实例 |
65
68
  | `extraPlugins` | 额外插件(vue-i18n、埋点等);**i18next 不用它** |
66
69
  | `i18n` | i18next 语义:`{ resources: { en: { translation: … }, zh: … }, fallbackLng? }`,随包做宿主语言切换 |
67
70
  | `queryClient` | 已有 `@tanstack/vue-query` 实例时传入;不传则骨架层自建(不要传第二份) |
@@ -146,6 +149,7 @@ pnpm exec appkit install --dry-run # 只预览
146
149
  | 类型检查报「无法解析 `*.css`」 | 消费方 `types` 未包含 `vite/client`,或引入了其它包的 CSS 声明 | 补齐 `types: ["vite/client"]` |
147
150
  | 弹层/下拉落点偏移 | 门户容器不是文档原点 | 应用侧只写变量(`--ibp-popup-shift-*`),位移规则由包内桥接层负责 |
148
151
  | 页面被跳到根路径、query 丢了 | 直开入口未登记 `rootPathAliases` | 见 §3 注释与契约 §9 |
152
+ | 刷新深路径 404(或子应用路由与宿主地址栏互相污染) | 宿主没有 SPA fallback、或不允子应用改写地址栏 | 入口传 `history: 'hash'`,路由改落 hash(见 §3 选项表与契约 §9 第 13 条) |
149
153
  | 装到的版本低于 0.1.1 | 0.1.0 已作废 | 升级到 0.1.1+ |
150
154
 
151
155
  ## 9. 新应用从零搭建(最小骨架)
@@ -194,6 +198,7 @@ pnpm add vue vue-router pinia @tanstack/vue-query i18next i18next-vue i18next-br
194
198
  |---|---|
195
199
  | 子应用产物路径与挂载方式(如 `/subapp/<group>/<app>/index.html`) | 宿主按约定路径加载,写错等于打不开 |
196
200
  | 是否有 `index.html?xxx=1` 这类直开入口(iframe / 选择模式) | 有就必须登记 `rootPathAliases`,否则首屏守卫会清空 query(CONTRACT.md §9.2) |
201
+ | **是否支持深路径 fallback**(`/subapp/.../<深路径>` 回落到 `index.html`) | 支持则入口用缺省的 `history`;不支持就得用 `history: 'hash'`,否则刷新即 404(契约 §9 第 13 条) |
197
202
  | dev server 端口与代理规则 | 端口冲突与跨域问题都出在这里 |
198
203
  | 宿主下发的语言键与初始路由约定 | 语言切换与首屏落点由宿主导 |
199
204
 
@@ -16,7 +16,7 @@
16
16
  | 旧写法 | 新写法 | 注意事项 |
17
17
  |---|---|---|
18
18
  | 自绘根容器(`display:flex; flex-direction:column` + `height:100vh` + padding) | `AppShell` + `AppShell.Body`(`mode="plain"\|"scroll"\|"table"`) | **禁 `100vh`**:注入宿主容器后会溢出;高度一律 `100%` |
19
- | 自绘页头(标题 + 关闭 + 操作按钮) | `AppShell.Header title=… toolbar={…}` | 页头规格(高度、间距、按钮形态)归组件;选择模式这类极简页可以不给页头,只留 Body |
19
+ | 自绘页头(标题 + **副标题** + 关闭 + 操作按钮) | `AppShell.Header title=… subTitle=… toolbar={…}` | 页头规格(高度、间距、副标题行距、按钮形态)归组件;**副标题走 `subTitle`**(跟随业务值直接传文本),不要自绘标题块;选择模式这类极简页可以不给页头,只留 Body |
20
20
  | 自绘面板头 / 工具条 / 搜索位 | `AppPanel toolbar={…} actions={…}` 或 `AppPanel.Header` | `toolbar` 是区域筛选(≤3 字段)、`actions` 是区域操作,**两位不可互换**;弹窗内可以不给 title |
21
21
  | 自绘表格外框 / 卡片(描边 + 圆角 + 表头) | `AppTable framed` | 卡片外观由内容件承担;`AppPanel` 本身无框 |
22
22
  | 自绘双栏 + 手写 1px 竖直分隔线 | `AppShell.Split sidebar={{ width: 208, content: () => … }}` | 中间分隔线由 Split 统一给;**业务侧不得再写 `border-left/right`**;`width` 是内容宽 |
@@ -110,7 +110,8 @@
110
110
 
111
111
  | 旧写法 | 新写法 | 注意事项 |
112
112
  |---|---|---|
113
- | 手写 `createApp` / `mount` / `window.mount` / 微前端协议 / pinia / 路由守卫 / 宿主语言监听 | `createSubApp({ rootComponent, routes, extraPlugins, rootPathAliases, onReady })` | 工厂内部已完成容器包裹、pinia、路由、守卫、协议、非微前端自动挂载;应用侧**不得重复书写**。vue-i18n 走 `extraPlugins`(骨架层内置的 i18n 选项是另一套语义,不要用) |
113
+ | 手写 `createApp` / `mount` / `window.mount` / 微前端协议 / pinia / 路由守卫 / 宿主语言监听 | `createSubApp({ rootComponent, routes, extraPlugins, rootPathAliases, history, onReady })` | 工厂内部已完成容器包裹、pinia、路由、守卫、协议、非微前端自动挂载;应用侧**不得重复书写**。vue-i18n 走 `extraPlugins`(骨架层内置的 i18n 选项是另一套语义,不要用) |
114
+ | `createWebHashHistory()` / 自定义 base 的 `createWebHistory(base)` | `createSubApp({ history: 'hash' })`,或直接把 `RouterHistory` 实例传给 `history` | 历史模式归工厂统一管理:缺省 `history`(micro-app baseroute 口径),宿主不支持深路径 fallback 时才改 hash(契约 §9 第 13 条) |
114
115
  | 手写样式导入顺序 | 固定三行:`reset.css` → `styles.css` → `./app.css` | 顺序即契约,不增不减 |
115
116
  | 应用侧各自声明底层组件库 / 图标包依赖 | 只声明骨架层包;其余由骨架层的依赖带入 | 应用侧不得安装或 `import` 底层组件库 |
116
117
  | 自建 lint 脚本多条 | 只挂 `lint` / `lint:changed` 两条 | 三条护栏由统一入口并发跑 |
@@ -163,6 +163,7 @@ export APPKIT_APP_DIR=apps/<x>
163
163
  | 缺口 | 影响 | 处理 |
164
164
  |---|---|---|
165
165
  | 结构护栏只扫 `.ts` / `.tsx`,不扫 `.vue` | 未纳入骨架的 `.vue` 页面不会报 `structure/no-shell` | 属已知范围,不要以为漏了;要覆盖需先有页面级豁免能力 |
166
+ | `structure/no-shell` 的「路由页」按目录判:`views|pages` 下、**`components/` 子树除外** | 弹窗 / 抽屉 / 表单块放对了目录就不会被报;放错位置(直接在 `views/` 下)仍会被当路由页 | 把这些件收进同页面的 `components/` 子目录(契约 §8);**不要**用「把页面挪深一层」来躲规则 |
166
167
  | 骨架层尚未提供的件 | 某些位置只能用现有件近似 | 走契约缺件处置流程,不在应用侧自绘;缺件清单与替代口径见契约 §7.1 |
167
168
  | 底层组件库版本跃迁带来的视觉差异 | 个别间距/圆角可能与改造前有细微不同 | 先确认是否骨架层规范使然;确属缺陷则记为骨架层待修项,不在应用侧写覆盖样式 |
168
169