@oj-module/cli 0.2.53 → 0.2.54

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 (96) hide show
  1. package/devkit/SKILL.md +2 -2
  2. package/devkit/manual.md +32 -25
  3. package/package.json +2 -2
  4. package/shell-dist/assets/runtime.js +7 -4
  5. package/shell-dist/index.html +3 -3
  6. package/shell-dist/versions.json +3 -3
  7. package/src/__tests__/templates-tenant-scope.test.ts +3 -2
  8. package/src/config.ts +9 -0
  9. package/src/contract/__tests__/client-path.test.ts +30 -0
  10. package/src/contract/__tests__/emit-client.test.ts +18 -0
  11. package/src/contract/__tests__/handler-scan.test.ts +23 -0
  12. package/src/contract/check.ts +5 -5
  13. package/src/contract/client-path.ts +30 -0
  14. package/src/contract/emit-client.ts +18 -4
  15. package/src/contract/handler-scan.ts +40 -3
  16. package/src/contract/run.ts +5 -3
  17. package/src/init.ts +19 -1
  18. package/templates/README.md +40 -44
  19. package/templates/init.include.yaml +12 -8
  20. package/templates/openapi.json +1393 -34
  21. package/templates/src/_platform/api/contacts/api.ts +29 -1
  22. package/templates/src/_platform/api/contacts/detail/api.ts +25 -1
  23. package/templates/src/_platform/api/solutions/api.ts +60 -1
  24. package/templates/src/_platform/web/__client/api.schemas.ts +99 -35
  25. package/templates/src/_platform/web/__client/api.ts +103 -59
  26. package/templates/src/app/web/entry.ts +77 -0
  27. package/templates/src/{home/web → app/web/home}/__client/api.ts +5 -2
  28. package/templates/src/app/web/home/routes.ts +41 -0
  29. package/templates/src/app/web/login/routes.ts +71 -0
  30. package/templates/src/{notification/web → app/web/notification}/__client/api.schemas.ts +14 -0
  31. package/templates/src/{notification/web → app/web/notification}/__client/api.ts +67 -20
  32. package/templates/src/app/web/notification/routes.ts +70 -0
  33. package/templates/src/{personal-center/web → app/web/personal-center}/__client/api.ts +5 -2
  34. package/templates/src/app/web/personal-center/routes.ts +49 -0
  35. package/templates/src/{system/web → app/web/system}/__client/api.schemas.ts +28 -22
  36. package/templates/src/{system/web → app/web/system}/__client/api.ts +103 -58
  37. package/templates/src/{system/web → app/web/system}/pages/users/index.tsx +3 -3
  38. package/templates/src/{system/web → app/web/system}/pages/wecom/index.tsx +12 -3
  39. package/templates/src/{system/web/entry.ts → app/web/system/routes.ts} +24 -35
  40. package/templates/src/auth/api/api.ts +59 -0
  41. package/templates/src/auth/api/ldap-login/api.ts +37 -1
  42. package/templates/src/auth/api/wecom/api.ts +23 -0
  43. package/templates/src/auth/web/__client/api.schemas.ts +133 -13
  44. package/templates/src/auth/web/__client/api.ts +129 -31
  45. package/templates/src/notification/api/api.ts +17 -0
  46. package/templates/src/notification/api/del/api.ts +2 -0
  47. package/templates/src/system/api/roles/api.ts +3 -0
  48. package/templates/src/system/api/roles/bindings/api.ts +3 -0
  49. package/templates/src/system/api/settings/api.ts +3 -0
  50. package/templates/src/system/api/users/forbidden/api.ts +3 -0
  51. package/templates/src/system/api/users/password/api.ts +3 -0
  52. package/templates/src/system/api/users/roles/api.ts +3 -0
  53. package/templates/src/web/api/get-async-routes/api.ts +16 -1
  54. package/templates/src/web/api/hello/api.ts +13 -1
  55. package/templates/src/web/api/user-info/api.ts +21 -1
  56. package/templates/src/web/web/__client/api.schemas.ts +28 -0
  57. package/templates/src/web/web/__client/api.ts +33 -9
  58. package/templates/web.config.ts +21 -15
  59. package/vendor/host-versions.json +3 -3
  60. package/templates/src/home/web/entry.ts +0 -49
  61. package/templates/src/login/web/entry.ts +0 -79
  62. package/templates/src/notification/web/entry.ts +0 -84
  63. package/templates/src/personal-center/web/entry.ts +0 -60
  64. /package/templates/src/{home/web → app/web/home}/__client/api.schemas.ts +0 -0
  65. /package/templates/src/{home/web → app/web/home}/locales/en-US.json +0 -0
  66. /package/templates/src/{home/web → app/web/home}/locales/zh-CN.json +0 -0
  67. /package/templates/src/{home/web → app/web/home}/pages/components/bar-chart.tsx +0 -0
  68. /package/templates/src/{home/web → app/web/home}/pages/components/card-list.tsx +0 -0
  69. /package/templates/src/{home/web → app/web/home}/pages/components/line-chart.tsx +0 -0
  70. /package/templates/src/{home/web → app/web/home}/pages/components/pie-chart.tsx +0 -0
  71. /package/templates/src/{home/web → app/web/home}/pages/index.tsx +0 -0
  72. /package/templates/src/{login/web → app/web/login}/auth-provider.ts +0 -0
  73. /package/templates/src/{login/web → app/web/login}/locales/en-US.json +0 -0
  74. /package/templates/src/{login/web → app/web/login}/locales/zh-CN.json +0 -0
  75. /package/templates/src/{login/web → app/web/login}/pages/change-password.tsx +0 -0
  76. /package/templates/src/{login/web → app/web/login}/pages/login.tsx +0 -0
  77. /package/templates/src/{login/web → app/web/login}/wecom.ts +0 -0
  78. /package/templates/src/{notification/web → app/web/notification}/locales/en-US.json +0 -0
  79. /package/templates/src/{notification/web → app/web/notification}/locales/zh-CN.json +0 -0
  80. /package/templates/src/{notification/web → app/web/notification}/pages/center/index.tsx +0 -0
  81. /package/templates/src/{personal-center/web → app/web/personal-center}/__client/api.schemas.ts +0 -0
  82. /package/templates/src/{personal-center/web → app/web/personal-center}/locales/en-US.json +0 -0
  83. /package/templates/src/{personal-center/web → app/web/personal-center}/locales/zh-CN.json +0 -0
  84. /package/templates/src/{personal-center/web → app/web/personal-center}/pages/my-profile/index.tsx +0 -0
  85. /package/templates/src/{system/web → app/web/system}/locales/en-US.json +0 -0
  86. /package/templates/src/{system/web → app/web/system}/locales/zh-CN.json +0 -0
  87. /package/templates/src/{system/web → app/web/system}/pages/departments/index.tsx +0 -0
  88. /package/templates/src/{system/web → app/web/system}/pages/departments/sync-bar.tsx +0 -0
  89. /package/templates/src/{system/web → app/web/system}/pages/departments/tree.ts +0 -0
  90. /package/templates/src/{system/web → app/web/system}/pages/errors.ts +0 -0
  91. /package/templates/src/{system/web → app/web/system}/pages/forbidden.tsx +0 -0
  92. /package/templates/src/{system/web → app/web/system}/pages/login-methods/index.tsx +0 -0
  93. /package/templates/src/{system/web → app/web/system}/pages/oplog/index.tsx +0 -0
  94. /package/templates/src/{system/web → app/web/system}/pages/roles/index.tsx +0 -0
  95. /package/templates/src/{system/web → app/web/system}/pages/settings/index.tsx +0 -0
  96. /package/templates/src/{system/web → app/web/system}/pages/users/roles.tsx +0 -0
@@ -0,0 +1,77 @@
1
+ import type { ModuleContext, ModuleI18n } from "@oj-module/runtime";
2
+ import { defineModule } from "@oj-module/runtime";
3
+
4
+ import { routes as homeRoutes } from "./home/routes";
5
+ import { onInit as loginOnInit, routes as loginRoutes } from "./login/routes";
6
+ import { onInit as notificationOnInit, routes as notificationRoutes } from "./notification/routes";
7
+ import { onInit as personalCenterOnInit, routes as personalCenterRoutes } from "./personal-center/routes";
8
+ import { onInit as systemOnInit, routes as systemRoutes } from "./system/routes";
9
+
10
+ /**
11
+ * 应用模块(多根聚合):一个 entry 承载六个业务根,各根独立成一级菜单。
12
+ *
13
+ * 为什么聚合:这六个根(首页/登录/个人中心/通知/系统管理)是脚手架的框架内置页面,
14
+ * 同版本发布、同生命周期、缺一不可,拆成六个模块只会把工程目录、web.config 登记、
15
+ * modules.json 与首屏模块入口请求都放大六倍。聚合后仍是「一目录一模块」,但模块内
16
+ * 按根分目录(`<root>/routes.ts` + `pages/` + `__client/`),页面相对导入不变。
17
+ *
18
+ * 结构约定(多根模块):
19
+ * - 顶层路由首段去重 > 1 → runtime 判为多根(`isMultiRoot`),各根按 entry 里的
20
+ * **绝对路径**原样注册,不被 `routePrefix` 改写——URL 与菜单 key 都不变;
21
+ * - 菜单顺序仍由各根 `handle.order` 决定(≥0 主区升序、<0 底部固定区),与聚合前
22
+ * 逐项一致;改顺序只改 order,不要依赖 routes 数组下标(缺省 order 会按数组下标兜底);
23
+ * - 门禁挂根路由:`/system` 的 `handle.roles` 等价于原先的模块级 requiredRoles。
24
+ *
25
+ * i18n 用 `i18nNamespaces` 沿用各根**原命名空间**(home / login / personal-center /
26
+ * notification / system),因此 `t("system:menu.users")`、`useTranslation("system")`
27
+ * 与菜单标题 `<ns>:menu.<key>` 全部零改动。
28
+ */
29
+ const i18nNamespaces: Record<string, ModuleI18n> = {
30
+ "home": {
31
+ "zh-CN": () => import("./home/locales/zh-CN.json"),
32
+ "en-US": () => import("./home/locales/en-US.json"),
33
+ },
34
+ "login": {
35
+ "zh-CN": () => import("./login/locales/zh-CN.json"),
36
+ "en-US": () => import("./login/locales/en-US.json"),
37
+ },
38
+ "personal-center": {
39
+ "zh-CN": () => import("./personal-center/locales/zh-CN.json"),
40
+ "en-US": () => import("./personal-center/locales/en-US.json"),
41
+ },
42
+ "notification": {
43
+ "zh-CN": () => import("./notification/locales/zh-CN.json"),
44
+ "en-US": () => import("./notification/locales/en-US.json"),
45
+ },
46
+ "system": {
47
+ "zh-CN": () => import("./system/locales/zh-CN.json"),
48
+ "en-US": () => import("./system/locales/en-US.json"),
49
+ },
50
+ };
51
+
52
+ export default defineModule({
53
+ name: "app",
54
+ description: "应用模块:框架内置页面(首页/登录/个人中心/通知/系统管理),多根聚合",
55
+ version: "0.1.0",
56
+ peerRuntime: ">=0.0.0",
57
+ routes: [
58
+ ...homeRoutes,
59
+ ...loginRoutes,
60
+ ...personalCenterRoutes,
61
+ ...notificationRoutes,
62
+ ...systemRoutes,
63
+ ],
64
+ i18nNamespaces,
65
+ lifecycle: {
66
+ /**
67
+ * 各根的 onInit 按原模块加载顺序串行——login 必须最先(认证 provider 要在任何
68
+ * 登录动作前就位),其余是前缀登记/API provider 注入,顺序无相互依赖。
69
+ */
70
+ async onInit(ctx: ModuleContext) {
71
+ await loginOnInit(ctx);
72
+ await personalCenterOnInit(ctx);
73
+ await notificationOnInit(ctx);
74
+ await systemOnInit(ctx);
75
+ },
76
+ },
77
+ });
@@ -17,6 +17,9 @@ export const API_PREFIX = "/home";
17
17
  /** 认领身份前缀(契约 apiPrefix,字面等于模块目录名):跨模块认领护栏对比基准 */
18
18
  export const MODULE_PREFIX = "/home";
19
19
 
20
+ /** 认领方模块(web.config clientOwners 指定):跨模块认领护栏对比基准 */
21
+ export const MODULE_OWNER = "/app";
22
+
20
23
  /**
21
24
  * 模块 entry 的 onInit 里一行创建(构造即 install):完成 apiPrefix 登记 +
22
25
  * scoped request 绑定,返回绑好前缀的端点函数集合。一模块一 client;
@@ -28,8 +31,8 @@ export function createHomeClient(ctx: ModuleContext) {
28
31
  // 单槽、卸载互相耦合;跨模块需求应由本模块创建后经 provider 暴露。
29
32
  // 注意对比基准是 MODULE_PREFIX(目录名)而非 API_PREFIX——D-M8 映射时线上前缀
30
33
  // 属于别的命名空间,认领身份仍按目录名。
31
- if (import.meta.env.DEV && ctx.module.name !== MODULE_PREFIX.slice(1)) {
32
- console.warn(`[ojm-api] 模块 "${ctx.module.name}" 正在认领 ${MODULE_PREFIX} 的 client——跨模块认领会共享 request 单槽、卸载互相耦合;应由 "${MODULE_PREFIX.slice(1)}" 模块在自身 onInit 创建,跨模块需求经 provider 暴露。`);
34
+ if (import.meta.env.DEV && ctx.module.name !== MODULE_OWNER.slice(1)) {
35
+ console.warn(`[ojm-api] 模块 "${ctx.module.name}" 正在认领 ${MODULE_PREFIX} 的 client——跨模块认领会共享 request 单槽、卸载互相耦合;应由 "${MODULE_OWNER.slice(1)}" 模块在自身 onInit 创建,跨模块需求经 provider 暴露。`);
33
36
  }
34
37
  ctx.register.apiPrefix(API_PREFIX);
35
38
  bindRequest(ctx.utils.request);
@@ -0,0 +1,41 @@
1
+ import type { AppRouteRecordRaw } from "@oj-module/runtime";
2
+
3
+ import { HomeOutlined } from "@ant-design/icons";
4
+
5
+ import { createElement, lazy } from "react";
6
+
7
+ const Home = lazy(() => import("./pages/index"));
8
+
9
+ /**
10
+ * 首页根 `/home`。
11
+ *
12
+ * 为什么必须有:shell 预构建产物把 `VITE_BASE_HOME_PATH` 定为 `/home`
13
+ * (登录成功回跳、logo 点击、tabbar 首页、403/404 回首页都指向它)。
14
+ * 工程缺 `/home` 路由时这些跳转会落到错误边界。
15
+ *
16
+ * 结构对齐 playground-oj/web/src/home;页面用统计卡片 + 折线/柱/饼图
17
+ * 演示宿主共享依赖矩阵(echarts / echarts-for-react / react-countup / dayjs /
18
+ * antd / @ant-design/icons / react-i18next,均由宿主 importmap 提供单例)。
19
+ * 图表数据为静态演示值——真实业务走模块契约 + `ojm api` 生成的 client。
20
+ */
21
+ export const routes: AppRouteRecordRaw[] = [
22
+ {
23
+ path: "/home",
24
+ handle: {
25
+ layout: "container",
26
+ order: 1,
27
+ title: "home:menu.home",
28
+ icon: createElement(HomeOutlined),
29
+ },
30
+ children: [
31
+ {
32
+ index: true,
33
+ Component: Home,
34
+ handle: {
35
+ title: "home:menu.home",
36
+ icon: createElement(HomeOutlined),
37
+ },
38
+ },
39
+ ],
40
+ },
41
+ ];
@@ -0,0 +1,71 @@
1
+ import type { AppRouteRecordRaw, ModuleContext } from "@oj-module/runtime";
2
+
3
+ import { useAuthStore, useTenantStore } from "@oj-module/runtime";
4
+ import { lazy } from "react";
5
+
6
+ import { authProvider } from "./auth-provider";
7
+
8
+ /**
9
+ * 登录根 `/login`(+ 强制改密 `/change-password`)。
10
+ *
11
+ * shell 宿主只消费模块路由(`getRoutes()`),**不挂 runtime 的内置 baseRoutes**,
12
+ * 因此缺少该根时 `/login` 无路由可跳(登出/回跳登录会落空)。本根补上路由:
13
+ * 登录链路仍回落到 runtime 默认链(`fetchLogin` → 后端内置 `auth/login` +
14
+ * 守卫 `getUserInfo` → `web/user-info`),本根只负责「内容区」。
15
+ *
16
+ * `handle.login: true` 让框架识别为登录页(`resolveLoginRoute` 校验路径必须是
17
+ * `/login`);`handle.layout: "fullscreen"` 由框架注入全屏外壳。
18
+ */
19
+ export const routes: AppRouteRecordRaw[] = [
20
+ {
21
+ path: "/login",
22
+ handle: {
23
+ layout: "fullscreen",
24
+ login: true,
25
+ hideInMenu: true,
26
+ title: "login:page.title",
27
+ },
28
+ children: [
29
+ {
30
+ index: true,
31
+ Component: lazy(() => import("./pages/login")),
32
+ handle: { title: "login:page.title" },
33
+ },
34
+ ],
35
+ },
36
+ {
37
+ // 首登/重置后强制改密(must_change 悬挂期唯一可用写端);
38
+ // 管理面 403 msg=password change required 时也引导至此
39
+ path: "/change-password",
40
+ handle: {
41
+ layout: "fullscreen",
42
+ hideInMenu: true,
43
+ title: "login:page.changePwd.title",
44
+ },
45
+ children: [
46
+ {
47
+ index: true,
48
+ Component: lazy(() => import("./pages/change-password")),
49
+ handle: { title: "login:page.changePwd.title", hideInMenu: true },
50
+ },
51
+ ],
52
+ },
53
+ ];
54
+
55
+ /**
56
+ * 认证 provider:仅接管「企微票据换会话」,其余方式原样委托内置链(见 auth-provider.ts)。
57
+ * 注册时机必须在任何登录动作之前——onInit 先于路由渲染。
58
+ */
59
+ export async function onInit(ctx: ModuleContext) {
60
+ ctx.register.authProvider(authProvider);
61
+ // 登出必须清租户:否则下一个用户会带着上一个用户的租户头(跨租户请求 fail-closed 403,
62
+ // 但单租户部署会静默落到别人的 default 视图)。token 由非空转空即视为登出。
63
+ //
64
+ // 租户上下文的请求头由**运行时**注入(`runtime/store/tenant.ts`),本根不注册
65
+ // headerProvider——宿主 shell 的 hydrateUser / solution-routes 都在任何模块 onInit
66
+ // 之前就发请求,模块注册必然晚一步(2026-10-30 冷启动 e2e 实测两个 400)。
67
+ useAuthStore.subscribe((state, prev) => {
68
+ if (prev.token && !state.token)
69
+ useTenantStore.getState().clear();
70
+ });
71
+ }
@@ -21,6 +21,13 @@ export const schemas = {
21
21
  body: z.object({
22
22
  "id": z.string()
23
23
  }),
24
+ data: z.null(),
25
+ },
26
+ "notificationNotificationsReadAllPost": {
27
+ data: z.null(),
28
+ },
29
+ "notificationNotificationsClearPost": {
30
+ data: z.null(),
24
31
  },
25
32
  "notificationPushPost": {
26
33
  body: z.object({
@@ -30,6 +37,9 @@ export const schemas = {
30
37
  "link": z.string().optional(),
31
38
  "avatar": z.string().optional(),
32
39
  "category": z.enum(["notice", "pm", "mention"]).optional()
40
+ }),
41
+ data: z.object({
42
+ "id": z.string()
33
43
  }),
34
44
  },
35
45
  "notificationPushBroadcastPost": {
@@ -39,6 +49,9 @@ export const schemas = {
39
49
  "link": z.string().optional(),
40
50
  "avatar": z.string().optional(),
41
51
  "category": z.enum(["notice", "pm", "mention"]).optional()
52
+ }),
53
+ data: z.object({
54
+ "id": z.string()
42
55
  }),
43
56
  },
44
57
  "notificationDelPost": {
@@ -46,5 +59,6 @@ export const schemas = {
46
59
  "id": z.string(),
47
60
  "userId": z.union([z.string(), z.number()]).optional()
48
61
  }),
62
+ data: z.null(),
49
63
  },
50
64
  };
@@ -17,6 +17,9 @@ export const API_PREFIX = "/notification";
17
17
  /** 认领身份前缀(契约 apiPrefix,字面等于模块目录名):跨模块认领护栏对比基准 */
18
18
  export const MODULE_PREFIX = "/notification";
19
19
 
20
+ /** 认领方模块(web.config clientOwners 指定):跨模块认领护栏对比基准 */
21
+ export const MODULE_OWNER = "/app";
22
+
20
23
  /**
21
24
  * 模块 entry 的 onInit 里一行创建(构造即 install):完成 apiPrefix 登记 +
22
25
  * scoped request 绑定,返回绑好前缀的端点函数集合。一模块一 client;
@@ -28,8 +31,8 @@ export function createNotificationClient(ctx: ModuleContext) {
28
31
  // 单槽、卸载互相耦合;跨模块需求应由本模块创建后经 provider 暴露。
29
32
  // 注意对比基准是 MODULE_PREFIX(目录名)而非 API_PREFIX——D-M8 映射时线上前缀
30
33
  // 属于别的命名空间,认领身份仍按目录名。
31
- if (import.meta.env.DEV && ctx.module.name !== MODULE_PREFIX.slice(1)) {
32
- console.warn(`[ojm-api] 模块 "${ctx.module.name}" 正在认领 ${MODULE_PREFIX} 的 client——跨模块认领会共享 request 单槽、卸载互相耦合;应由 "${MODULE_PREFIX.slice(1)}" 模块在自身 onInit 创建,跨模块需求经 provider 暴露。`);
34
+ if (import.meta.env.DEV && ctx.module.name !== MODULE_OWNER.slice(1)) {
35
+ console.warn(`[ojm-api] 模块 "${ctx.module.name}" 正在认领 ${MODULE_PREFIX} 的 client——跨模块认领会共享 request 单槽、卸载互相耦合;应由 "${MODULE_OWNER.slice(1)}" 模块在自身 onInit 创建,跨模块需求经 provider 暴露。`);
33
36
  }
34
37
  ctx.register.apiPrefix(API_PREFIX);
35
38
  bindRequest(ctx.utils.request);
@@ -88,15 +91,22 @@ export async function notificationNotificationsGet(): Promise<NotificationNotifi
88
91
  }
89
92
 
90
93
  export type NotificationNotificationsReadPostBody = z.input<(typeof schemas)["notificationNotificationsReadPost"]["body"]>;
94
+ export type NotificationNotificationsReadPostData = z.infer<(typeof schemas)["notificationNotificationsReadPost"]["data"]>;
91
95
 
92
- export async function notificationNotificationsReadPost(body: NotificationNotificationsReadPostBody): Promise<unknown> {
96
+ export async function notificationNotificationsReadPost(body: NotificationNotificationsReadPostBody): Promise<NotificationNotificationsReadPostData> {
93
97
  const client = ensureReq();
94
98
  try {
95
- const env = await client.post(`notification/notifications/read`, { json: body }).json<OjEnvelope<unknown>>();
99
+ const env = await client.post(`notification/notifications/read`, { json: body }).json<OjEnvelope<NotificationNotificationsReadPostData>>();
96
100
  // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
97
101
  if (typeof env.code === "number" && env.code !== 0)
98
102
  throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
99
- const data = env.data as unknown;
103
+ const data = env.data as NotificationNotificationsReadPostData;
104
+ if (import.meta.env.DEV) {
105
+ const { schemas } = await import("./api.schemas");
106
+ const r = schemas["notificationNotificationsReadPost"].data.safeParse(data);
107
+ if (!r.success)
108
+ throw new ContractApiError(-1, `[契约违例] notificationNotificationsReadPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
109
+ }
100
110
  return data;
101
111
  }
102
112
  catch (e) {
@@ -104,14 +114,22 @@ export async function notificationNotificationsReadPost(body: NotificationNotifi
104
114
  }
105
115
  }
106
116
 
107
- export async function notificationNotificationsReadAllPost(): Promise<unknown> {
117
+ export type NotificationNotificationsReadAllPostData = z.infer<(typeof schemas)["notificationNotificationsReadAllPost"]["data"]>;
118
+
119
+ export async function notificationNotificationsReadAllPost(): Promise<NotificationNotificationsReadAllPostData> {
108
120
  const client = ensureReq();
109
121
  try {
110
- const env = await client.post(`notification/notifications/read-all`).json<OjEnvelope<unknown>>();
122
+ const env = await client.post(`notification/notifications/read-all`).json<OjEnvelope<NotificationNotificationsReadAllPostData>>();
111
123
  // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
112
124
  if (typeof env.code === "number" && env.code !== 0)
113
125
  throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
114
- const data = env.data as unknown;
126
+ const data = env.data as NotificationNotificationsReadAllPostData;
127
+ if (import.meta.env.DEV) {
128
+ const { schemas } = await import("./api.schemas");
129
+ const r = schemas["notificationNotificationsReadAllPost"].data.safeParse(data);
130
+ if (!r.success)
131
+ throw new ContractApiError(-1, `[契约违例] notificationNotificationsReadAllPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
132
+ }
115
133
  return data;
116
134
  }
117
135
  catch (e) {
@@ -119,14 +137,22 @@ export async function notificationNotificationsReadAllPost(): Promise<unknown> {
119
137
  }
120
138
  }
121
139
 
122
- export async function notificationNotificationsClearPost(): Promise<unknown> {
140
+ export type NotificationNotificationsClearPostData = z.infer<(typeof schemas)["notificationNotificationsClearPost"]["data"]>;
141
+
142
+ export async function notificationNotificationsClearPost(): Promise<NotificationNotificationsClearPostData> {
123
143
  const client = ensureReq();
124
144
  try {
125
- const env = await client.post(`notification/notifications/clear`).json<OjEnvelope<unknown>>();
145
+ const env = await client.post(`notification/notifications/clear`).json<OjEnvelope<NotificationNotificationsClearPostData>>();
126
146
  // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
127
147
  if (typeof env.code === "number" && env.code !== 0)
128
148
  throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
129
- const data = env.data as unknown;
149
+ const data = env.data as NotificationNotificationsClearPostData;
150
+ if (import.meta.env.DEV) {
151
+ const { schemas } = await import("./api.schemas");
152
+ const r = schemas["notificationNotificationsClearPost"].data.safeParse(data);
153
+ if (!r.success)
154
+ throw new ContractApiError(-1, `[契约违例] notificationNotificationsClearPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
155
+ }
130
156
  return data;
131
157
  }
132
158
  catch (e) {
@@ -135,15 +161,22 @@ export async function notificationNotificationsClearPost(): Promise<unknown> {
135
161
  }
136
162
 
137
163
  export type NotificationPushPostBody = z.input<(typeof schemas)["notificationPushPost"]["body"]>;
164
+ export type NotificationPushPostData = z.infer<(typeof schemas)["notificationPushPost"]["data"]>;
138
165
 
139
- export async function notificationPushPost(body: NotificationPushPostBody): Promise<unknown> {
166
+ export async function notificationPushPost(body: NotificationPushPostBody): Promise<NotificationPushPostData> {
140
167
  const client = ensureReq();
141
168
  try {
142
- const env = await client.post(`notification/push`, { json: body }).json<OjEnvelope<unknown>>();
169
+ const env = await client.post(`notification/push`, { json: body }).json<OjEnvelope<NotificationPushPostData>>();
143
170
  // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
144
171
  if (typeof env.code === "number" && env.code !== 0)
145
172
  throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
146
- const data = env.data as unknown;
173
+ const data = env.data as NotificationPushPostData;
174
+ if (import.meta.env.DEV) {
175
+ const { schemas } = await import("./api.schemas");
176
+ const r = schemas["notificationPushPost"].data.safeParse(data);
177
+ if (!r.success)
178
+ throw new ContractApiError(-1, `[契约违例] notificationPushPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
179
+ }
147
180
  return data;
148
181
  }
149
182
  catch (e) {
@@ -152,15 +185,22 @@ export async function notificationPushPost(body: NotificationPushPostBody): Prom
152
185
  }
153
186
 
154
187
  export type NotificationPushBroadcastPostBody = z.input<(typeof schemas)["notificationPushBroadcastPost"]["body"]>;
188
+ export type NotificationPushBroadcastPostData = z.infer<(typeof schemas)["notificationPushBroadcastPost"]["data"]>;
155
189
 
156
- export async function notificationPushBroadcastPost(body: NotificationPushBroadcastPostBody): Promise<unknown> {
190
+ export async function notificationPushBroadcastPost(body: NotificationPushBroadcastPostBody): Promise<NotificationPushBroadcastPostData> {
157
191
  const client = ensureReq();
158
192
  try {
159
- const env = await client.post(`notification/push-broadcast`, { json: body }).json<OjEnvelope<unknown>>();
193
+ const env = await client.post(`notification/push-broadcast`, { json: body }).json<OjEnvelope<NotificationPushBroadcastPostData>>();
160
194
  // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
161
195
  if (typeof env.code === "number" && env.code !== 0)
162
196
  throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
163
- const data = env.data as unknown;
197
+ const data = env.data as NotificationPushBroadcastPostData;
198
+ if (import.meta.env.DEV) {
199
+ const { schemas } = await import("./api.schemas");
200
+ const r = schemas["notificationPushBroadcastPost"].data.safeParse(data);
201
+ if (!r.success)
202
+ throw new ContractApiError(-1, `[契约违例] notificationPushBroadcastPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
203
+ }
164
204
  return data;
165
205
  }
166
206
  catch (e) {
@@ -169,15 +209,22 @@ export async function notificationPushBroadcastPost(body: NotificationPushBroadc
169
209
  }
170
210
 
171
211
  export type NotificationDelPostBody = z.input<(typeof schemas)["notificationDelPost"]["body"]>;
212
+ export type NotificationDelPostData = z.infer<(typeof schemas)["notificationDelPost"]["data"]>;
172
213
 
173
- export async function notificationDelPost(body: NotificationDelPostBody): Promise<unknown> {
214
+ export async function notificationDelPost(body: NotificationDelPostBody): Promise<NotificationDelPostData> {
174
215
  const client = ensureReq();
175
216
  try {
176
- const env = await client.post(`notification/del`, { json: body }).json<OjEnvelope<unknown>>();
217
+ const env = await client.post(`notification/del`, { json: body }).json<OjEnvelope<NotificationDelPostData>>();
177
218
  // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
178
219
  if (typeof env.code === "number" && env.code !== 0)
179
220
  throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
180
- const data = env.data as unknown;
221
+ const data = env.data as NotificationDelPostData;
222
+ if (import.meta.env.DEV) {
223
+ const { schemas } = await import("./api.schemas");
224
+ const r = schemas["notificationDelPost"].data.safeParse(data);
225
+ if (!r.success)
226
+ throw new ContractApiError(-1, `[契约违例] notificationDelPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
227
+ }
181
228
  return data;
182
229
  }
183
230
  catch (e) {
@@ -0,0 +1,70 @@
1
+ import type { AppRouteRecordRaw, ModuleContext, NotificationsApiProvider } from "@oj-module/runtime";
2
+
3
+ import { BellOutlined } from "@ant-design/icons";
4
+
5
+ import { createElement, lazy } from "react";
6
+
7
+ import { createNotificationClient } from "./__client/api";
8
+
9
+ const NotificationCenter = lazy(() => import("./pages/center"));
10
+
11
+ /**
12
+ * 通知中心页(N3):全量消息 + 类型 Tab,铃铛弹层「查看全部」跳转至此。
13
+ * hideInMenu——入口在铃铛,不占侧边导航。
14
+ * 布局挂父路由 handle(resolveRouteLayouts 只给「无 Component 有 children」的
15
+ * 父路由注入 layout,页面组件写父级上布局会被忽略、裸渲染无 chrome)。
16
+ */
17
+ export const routes: AppRouteRecordRaw[] = [
18
+ {
19
+ path: "/notification-center",
20
+ handle: {
21
+ layout: "container",
22
+ title: "notification:menu.notificationCenter",
23
+ icon: createElement(BellOutlined),
24
+ hideInMenu: true,
25
+ },
26
+ children: [
27
+ {
28
+ index: true,
29
+ Component: NotificationCenter,
30
+ handle: {
31
+ title: "notification:menu.notificationCenter",
32
+ icon: createElement(BellOutlined),
33
+ },
34
+ },
35
+ ],
36
+ },
37
+ ];
38
+
39
+ /**
40
+ * 通知聚合的唯一前端消费面(G5 模板形态)。
41
+ *
42
+ * onInit 全量接管通知 API(D9 + G4):把通知读/写收敛到本模块 `/notification`
43
+ * 前缀下的 oj 后端,通知铃的互动(单条已读/全部已读/清空)由此生效;发射端
44
+ * 三方法(push/pushBroadcast/del)使本模块同时作为跨模块通知原语的接收端
45
+ * (前端 facade 委托至此)。
46
+ */
47
+ export async function onInit(ctx: ModuleContext) {
48
+ // 构造即接线:createNotificationClient 登记契约前缀 /notification
49
+ // (唯一真源在 __client/api.ts 的 API_PREFIX)+ 绑定圈好前缀的 scoped
50
+ // request(越界/穿越发请求前即拒绝,前缀收敛 D11),返回的 client
51
+ // 方法都自动带 /api/notification/ 前缀。
52
+ const notificationClient = createNotificationClient(ctx);
53
+ const provider: NotificationsApiProvider = {
54
+ // 端点函数名由 oj operationId 派生(camelCase 子路径 + 方法),
55
+ // 见 __client/api.ts;provider 方法名(fetchNotifications/markRead…)不变。
56
+ fetchNotifications: () => notificationClient.notificationNotificationsGet(),
57
+ // id 为命名空间串("u:<id>" / "b:<id>"),原样透传
58
+ markRead: id => notificationClient.notificationNotificationsReadPost({ id: String(id) }).then(() => {}),
59
+ markAllRead: () => notificationClient.notificationNotificationsReadAllPost().then(() => {}),
60
+ clearAll: () => notificationClient.notificationNotificationsClearPost().then(() => {}),
61
+ push: input => notificationClient
62
+ .notificationPushPost({ userId: input.userId, title: input.title, message: input.message, link: input.link, avatar: input.avatar, category: input.category })
63
+ .then(r => String((r as { id: string }).id)),
64
+ pushBroadcast: input => notificationClient
65
+ .notificationPushBroadcastPost({ title: input.title, message: input.message, link: input.link, avatar: input.avatar, category: input.category })
66
+ .then(r => String((r as { id: string }).id)),
67
+ del: (id, userId) => notificationClient.notificationDelPost({ id: String(id), userId }).then(() => {}),
68
+ };
69
+ ctx.register.notificationsApi(provider);
70
+ }
@@ -17,6 +17,9 @@ export const API_PREFIX = "/personal-center";
17
17
  /** 认领身份前缀(契约 apiPrefix,字面等于模块目录名):跨模块认领护栏对比基准 */
18
18
  export const MODULE_PREFIX = "/personal-center";
19
19
 
20
+ /** 认领方模块(web.config clientOwners 指定):跨模块认领护栏对比基准 */
21
+ export const MODULE_OWNER = "/app";
22
+
20
23
  /**
21
24
  * 模块 entry 的 onInit 里一行创建(构造即 install):完成 apiPrefix 登记 +
22
25
  * scoped request 绑定,返回绑好前缀的端点函数集合。一模块一 client;
@@ -28,8 +31,8 @@ export function createPersonalCenterClient(ctx: ModuleContext) {
28
31
  // 单槽、卸载互相耦合;跨模块需求应由本模块创建后经 provider 暴露。
29
32
  // 注意对比基准是 MODULE_PREFIX(目录名)而非 API_PREFIX——D-M8 映射时线上前缀
30
33
  // 属于别的命名空间,认领身份仍按目录名。
31
- if (import.meta.env.DEV && ctx.module.name !== MODULE_PREFIX.slice(1)) {
32
- console.warn(`[ojm-api] 模块 "${ctx.module.name}" 正在认领 ${MODULE_PREFIX} 的 client——跨模块认领会共享 request 单槽、卸载互相耦合;应由 "${MODULE_PREFIX.slice(1)}" 模块在自身 onInit 创建,跨模块需求经 provider 暴露。`);
34
+ if (import.meta.env.DEV && ctx.module.name !== MODULE_OWNER.slice(1)) {
35
+ console.warn(`[ojm-api] 模块 "${ctx.module.name}" 正在认领 ${MODULE_PREFIX} 的 client——跨模块认领会共享 request 单槽、卸载互相耦合;应由 "${MODULE_OWNER.slice(1)}" 模块在自身 onInit 创建,跨模块需求经 provider 暴露。`);
33
36
  }
34
37
  ctx.register.apiPrefix(API_PREFIX);
35
38
  bindRequest(ctx.utils.request);
@@ -0,0 +1,49 @@
1
+ import type { AppRouteRecordRaw, ModuleContext, UploadApiProvider } from "@oj-module/runtime";
2
+ import { ProfileCardIcon, RiAccountCircleLine, useAuthStore } from "@oj-module/runtime";
3
+
4
+ import { createElement, lazy } from "react";
5
+
6
+ import { createPersonalCenterClient } from "./__client/api";
7
+
8
+ const MyProfile = lazy(() => import("./pages/my-profile"));
9
+
10
+ /** 个人中心根 `/personal-center`:runtime 用户菜单固定导航 `/personal-center/my-profile`,缺则点击落空。 */
11
+ export const routes: AppRouteRecordRaw[] = [
12
+ {
13
+ path: "/personal-center",
14
+ handle: {
15
+ layout: "container",
16
+ order: 110,
17
+ title: "personal-center:menu.personalCenter",
18
+ icon: createElement(RiAccountCircleLine),
19
+ },
20
+ children: [
21
+ {
22
+ path: "/personal-center/my-profile",
23
+ Component: MyProfile,
24
+ handle: {
25
+ title: "personal-center:menu.profile",
26
+ icon: createElement(ProfileCardIcon),
27
+ },
28
+ },
29
+ ],
30
+ },
31
+ ];
32
+
33
+ /**
34
+ * P4-3:接管头像上传端点(D9)。antd Upload 直连 action,headers 在上传瞬间
35
+ * 取当前 token 注入 Bearer(token 存 runtime auth store,跨渲染期读 getState)。
36
+ */
37
+ export async function onInit(ctx: ModuleContext) {
38
+ // 构造即接线:登记契约前缀 /personal-center + 绑定 scoped request,
39
+ // 页面直接 import 的 personalCenterProfilePost 才有可用的请求通道
40
+ createPersonalCenterClient(ctx);
41
+ const provider: UploadApiProvider = {
42
+ action: "/api/personal-center/upload",
43
+ headers: (): Record<string, string> => {
44
+ const token = useAuthStore.getState().token;
45
+ return token ? { Authorization: `Bearer ${token}` } : {};
46
+ },
47
+ };
48
+ ctx.register.uploadApi(provider);
49
+ }