@oj-module/runtime 0.1.16 → 0.1.20

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.
@@ -0,0 +1,17 @@
1
+ import type { NoticeCategory, NoticeItem, PushBroadcastInput, PushInput } from "@oj-module/runtime/notify";
2
+ export type { NoticeCategory, NoticeItem, PushBroadcastInput, PushInput, };
3
+ /**
4
+ * 跨模块通知发射原语(前端侧 facade)。委托给已注册 notificationsApi provider 的
5
+ * 发射方法(经 HTTP 落到后端通知模块)。后端模块请改用 `api/src/_shared/notify`
6
+ * (直接 db 写入两表)——那是真正的服务端原语。
7
+ *
8
+ * 语义:原语不做任何业务判断(不判空间/角色/可见性),收件人过滤是发送方责任。
9
+ */
10
+ export declare const notify: {
11
+ /** 给指定用户推一条按人通知,返回命名空间 id "u:<id>" */
12
+ push(input: PushInput): Promise<string>;
13
+ /** 广播一条通知(全员可见,一行存储),返回命名空间 id "b:<id>" */
14
+ pushBroadcast(input: PushBroadcastInput): Promise<string>;
15
+ /** 删除通知:按人项校验归属(非本人删自己);广播项仅删本人回执 */
16
+ del(id: string, userId: string | number): Promise<void>;
17
+ };
@@ -1,15 +1,15 @@
1
1
  /**
2
- * @oj-module/runtime/contract —— API 契约 DSL(AC-D11)。
2
+ * @oj-module/runtime/contract —— API 契约运行时。
3
3
  *
4
- * 零浏览器依赖(Node codegen 与浏览器运行时双安全):
5
- * - defineApi:契约端点定义(含定义期校验)
6
- * - API_DEF:端点品牌标记(旧标记 `ram.api.def` 由 cli 侧自行识别,不在公共出口)
7
- * - z:zod re-export(契约 schema 书写;v4 钉版,pnpm catalog 单一来源)
8
- * - ContractApiError:契约制 client 统一错误类型
4
+ * 零浏览器依赖(Node codegen 与浏览器运行时双安全)。
5
+ *
6
+ * 历史:`defineApi` DSL 已在 oj v0.1.71 流水线中退役——单一事实源改为 oj
7
+ * `openapi.json`(`oj openapi` 产物)→ 前端 `ojm api` 生成 __client,不再消费
8
+ * 旧 `contract.ts`/`defineApi`。本子包现仅保留:
9
+ * - ContractApiError:契约制 client 统一错误类型(前端运行时用)
9
10
  * - ScopedRequestLike:生成 client 的 request 最小结构类型
11
+ * - z:zod re-export(契约 schema 书写;v4 钉版,pnpm catalog 单一来源)
10
12
  */
11
- export { API_DEF, defineApi } from "./define-api.ts";
12
- export type { ApiDefinitionInput, ApiFormFile, ApiFormInput, HttpMethod } from "./define-api.ts";
13
13
  export { ContractApiError } from "./errors.ts";
14
14
  export type { ScopedRequestLike } from "./scoped-request-like.ts";
15
15
  export { z } from "zod";
@@ -1,13 +1,14 @@
1
1
  /**
2
- * @oj-module/runtime/contract —— API 契约 DSL(AC-D11)。
2
+ * @oj-module/runtime/contract —— API 契约运行时。
3
3
  *
4
- * 零浏览器依赖(Node codegen 与浏览器运行时双安全):
5
- * - defineApi:契约端点定义(含定义期校验)
6
- * - API_DEF:端点品牌标记(旧标记 `ram.api.def` 由 cli 侧自行识别,不在公共出口)
7
- * - z:zod re-export(契约 schema 书写;v4 钉版,pnpm catalog 单一来源)
8
- * - ContractApiError:契约制 client 统一错误类型
4
+ * 零浏览器依赖(Node codegen 与浏览器运行时双安全)。
5
+ *
6
+ * 历史:`defineApi` DSL 已在 oj v0.1.71 流水线中退役——单一事实源改为 oj
7
+ * `openapi.json`(`oj openapi` 产物)→ 前端 `ojm api` 生成 __client,不再消费
8
+ * 旧 `contract.ts`/`defineApi`。本子包现仅保留:
9
+ * - ContractApiError:契约制 client 统一错误类型(前端运行时用)
9
10
  * - ScopedRequestLike:生成 client 的 request 最小结构类型
11
+ * - z:zod re-export(契约 schema 书写;v4 钉版,pnpm catalog 单一来源)
10
12
  */
11
- export { API_DEF, defineApi } from "./define-api.js";
12
13
  export { ContractApiError } from "./errors.js";
13
14
  export { z } from "zod";
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * 由宿主的 importmap 提供,模块应直接 import 它们,不要从 runtime 转出。
11
11
  */
12
12
  import "./styles/index.css";
13
+ export { notify } from "./api/notify";
13
14
  export * from "./api/system/menu";
14
15
  export * from "./api/system/role";
15
16
  export * from "./api/user";
@@ -45,5 +46,6 @@ export { getAllExpandedKeys } from "./utils/get-all-expanded-keys";
45
46
  export { getAppInfo } from "./utils/get-app-info";
46
47
  export { getRedirectPath } from "./utils/get-redirect-path";
47
48
  export * from "./utils/tree";
49
+ export type { NoticeCategory, NoticeItem, PushBroadcastInput, PushInput, } from "@oj-module/runtime/notify";
48
50
  export { ErrorBoundary } from "react-error-boundary";
49
51
  export { z } from "zod";
@@ -1,9 +1,20 @@
1
+ import type { NoticeCategory } from "@oj-module/runtime/notify";
1
2
  export interface NotificationItem {
2
- /** 必填——单条已读/清空等写操作的前提(G4,破坏性变更,发版说明标注) */
3
- id: string | number;
3
+ /**
4
+ * 必填——单条已读/清空等写操作的前提(G4)。现为命名空间串:
5
+ * "u:<id>" 按人(私信/@我),"b:<id>" 广播(通知/公告)。破坏性变更:由
6
+ * string|number 改为 string,发版说明标注(写操作须透传命名空间 id)。
7
+ */
8
+ id: string;
9
+ /** 数据来源:user=按人项,broadcast=广播项 */
10
+ scope: "user" | "broadcast";
4
11
  avatar: string;
5
12
  date: string;
6
13
  isRead?: boolean;
7
14
  message: string;
8
15
  title: string;
16
+ /** 深链:前端点击跳转(无深链的通知为 null) */
17
+ link?: string | null;
18
+ /** 消息类别:notice=通知/广播,pm=私信,mention=@我(占位) */
19
+ category?: NoticeCategory;
9
20
  }
@@ -0,0 +1 @@
1
+ export * from "./types.ts";
@@ -0,0 +1,7 @@
1
+ // `@oj-module/runtime/notify` 物理子路径入口(与 contract 子路径同构)。
2
+ //
3
+ // 仅导出共享契约类型 —— 服务端原语不在 runtime 内,而是落在各 oj 工程的
4
+ // api/src/_shared/notify(oj 后端按物理目录解析裸 specifier 且钳制在 api 项目根内,
5
+ // 不能 import 本项目外的包;详见 packages/README 或设计文档 §4.3)。前端经此 facade
6
+ // 取类型;后端直接 import 本地 _shared/notify 的实现。
7
+ export * from "./types.js";
@@ -0,0 +1,31 @@
1
+ /** 消息类别:notice=通知/广播,pm=私信(按人),mention=@我(占位) */
2
+ export type NoticeCategory = "notice" | "pm" | "mention";
3
+ /** 铃展示项。id 为命名空间串:"u:<id>" 按人 / "b:<id>" 广播 */
4
+ export interface NoticeItem {
5
+ id: string;
6
+ scope: "user" | "broadcast";
7
+ avatar: string;
8
+ date: string;
9
+ isRead?: boolean;
10
+ message: string;
11
+ title: string;
12
+ link?: string;
13
+ category?: NoticeCategory;
14
+ }
15
+ /** 给指定用户推一条按人通知 */
16
+ export interface PushInput {
17
+ userId: string | number;
18
+ title: string;
19
+ message: string;
20
+ link?: string;
21
+ avatar?: string;
22
+ category?: NoticeCategory;
23
+ }
24
+ /** 广播一条通知(全员可见,一行存储) */
25
+ export interface PushBroadcastInput {
26
+ title: string;
27
+ message: string;
28
+ link?: string;
29
+ avatar?: string;
30
+ category?: NoticeCategory;
31
+ }
@@ -0,0 +1,3 @@
1
+ // 跨模块通知原语的公共类型(runtime 契约层)。后端模块 import 这些类型 +
2
+ // api/src/_shared/notify(db 实现);前端模块经 @oj-module/runtime 的 notify facade。
3
+ export {};
package/dist/runtime.js CHANGED
@@ -122,6 +122,36 @@ function unregisterApiProviders(moduleName) {
122
122
  var systemCurrent, notificationsCurrent, uploadCurrent, routesCurrent;
123
123
  var init_api_provider = __esmMin((() => {}));
124
124
  //#endregion
125
+ //#region src/api/notify/index.ts
126
+ init_api_provider();
127
+ var NOT_REGISTERED = "NotificationsBackendNotRegistered";
128
+ function requirePushProvider() {
129
+ const p = getNotificationsApiProvider();
130
+ if (!p || typeof p.push !== "function" || typeof p.pushBroadcast !== "function" || typeof p.del !== "function") throw new Error(`[notify] ${NOT_REGISTERED}:未注册 notificationsApi provider 的发射方法(push/pushBroadcast/del)。`);
131
+ return p;
132
+ }
133
+ /**
134
+ * 跨模块通知发射原语(前端侧 facade)。委托给已注册 notificationsApi provider 的
135
+ * 发射方法(经 HTTP 落到后端通知模块)。后端模块请改用 `api/src/_shared/notify`
136
+ * (直接 db 写入两表)——那是真正的服务端原语。
137
+ *
138
+ * 语义:原语不做任何业务判断(不判空间/角色/可见性),收件人过滤是发送方责任。
139
+ */
140
+ var notify = {
141
+ /** 给指定用户推一条按人通知,返回命名空间 id "u:<id>" */
142
+ push(input) {
143
+ return requirePushProvider().push(input);
144
+ },
145
+ /** 广播一条通知(全员可见,一行存储),返回命名空间 id "b:<id>" */
146
+ pushBroadcast(input) {
147
+ return requirePushProvider().pushBroadcast(input);
148
+ },
149
+ /** 删除通知:按人项校验归属(非本人删自己);广播项仅删本人回执 */
150
+ del(id, userId) {
151
+ return requirePushProvider().del(id, userId);
152
+ }
153
+ };
154
+ //#endregion
125
155
  //#region src/router/extra-info/route-path.ts
126
156
  /**
127
157
  * 判断 pathname 是否为登录页(basename 感知,P0)。
@@ -225,10 +255,10 @@ function getAppInfo() {
225
255
  return {
226
256
  "pkg": {
227
257
  "name": "@oj-module/runtime",
228
- "version": "0.1.16",
258
+ "version": "0.1.20",
229
259
  "license": "MIT"
230
260
  },
231
- "lastBuildTime": "2026-10-13 10:35:26"
261
+ "lastBuildTime": "2026-10-15 16:50:57"
232
262
  };
233
263
  }
234
264
  var init_get_app_info = __esmMin((() => {}));
@@ -3550,7 +3580,7 @@ var init_use_layout_style = __esmMin((() => {
3550
3580
  }));
3551
3581
  //#endregion
3552
3582
  //#region src/store/tabs.ts
3553
- var initialState$3, useTabsStore;
3583
+ var initialState$3, closingKeys, useTabsStore;
3554
3584
  var init_tabs = __esmMin((() => {
3555
3585
  init_preferences$3();
3556
3586
  init_get_app_namespace();
@@ -3576,224 +3606,218 @@ var init_tabs = __esmMin((() => {
3576
3606
  */
3577
3607
  isMaximize: false
3578
3608
  };
3579
- useTabsStore = create()(persist((set) => ({
3580
- ...initialState$3,
3609
+ closingKeys = /* @__PURE__ */ new Set();
3610
+ useTabsStore = create()(persist((set, get) => {
3581
3611
  /**
3582
- * @zh 设置标签页是否处于刷新状态
3583
- * @en Set whether the tab is in a refresh state.
3612
+ * 关闭前守卫:待关闭集合里任一标签页声明了 beforeClose 且返回 false / 抛错时,整体中止。
3613
+ * fail-safe:守卫抛错按中止处理,防误关丢内容;console.error 留排查线索。
3584
3614
  */
3585
- setIsRefresh: (state) => {
3586
- set({ isRefresh: state });
3587
- },
3588
- /**
3589
- * @zh 设置标签页
3590
- * @en Set the tab.
3591
- */
3592
- setActiveKey: (routePath) => {
3593
- set({ activeKey: routePath });
3594
- },
3595
- /**
3596
- * @zh 在最前面插入标签页
3597
- * @en Insert a tab at the front.
3598
- */
3599
- insertBeforeTab: (routePath, tabProps) => {
3600
- set((state) => {
3601
- if (routePath?.length) {
3602
- const newMap = /* @__PURE__ */ new Map([[routePath, tabProps]]);
3603
- for (const [key, value] of state.openTabs) newMap.set(key, value);
3604
- return { openTabs: newMap };
3615
+ const guardClose = async (keys) => {
3616
+ for (const key of keys) {
3617
+ const fn = get().openTabs.get(key)?.beforeClose;
3618
+ if (fn == null) continue;
3619
+ const fns = Array.isArray(fn) ? fn : [fn];
3620
+ for (const f of fns) try {
3621
+ if (!await f()) return false;
3622
+ } catch (e) {
3623
+ console.error("[tabs] beforeClose 守卫抛错,按中止关闭处理", e);
3624
+ return false;
3605
3625
  }
3606
- return state;
3607
- });
3608
- },
3609
- /**
3610
- * @zh 添加标签页
3611
- * @en Add a tab.
3612
- */
3613
- addTab: (routePath, tabProps) => {
3614
- set((state) => {
3615
- if (routePath?.length) {
3616
- const newTabs = new Map(state.openTabs);
3617
- /**
3618
- * 1. 如果 tab 已经存在,则更新 historyState 属性,所以不去重,且 ...newTabs.get(routePath) 是为了保证首页的 closable 属性不被覆盖
3619
- * 2. 如果 tab 不存在,则添加到 Map 中
3620
- */
3621
- newTabs.set(routePath, {
3622
- ...newTabs.get(routePath),
3623
- ...tabProps
3624
- });
3625
- return { openTabs: newTabs };
3626
- }
3627
- return state;
3628
- });
3629
- },
3626
+ }
3627
+ return true;
3628
+ };
3630
3629
  /**
3631
- * @zh 移除标签页
3632
- * @en Remove a tab.
3630
+ * 共用关闭路径:in-flight 去重 → 守卫 → 精确删除获批集合(TOCTOU 防护:
3631
+ * 不对 live state 重算,await 期间新开的 tab 不受影响)→ activeKey 修正。
3633
3632
  */
3634
- removeTab: (routePath) => {
3635
- set((state) => {
3633
+ const guardAndClose = async (removed, fallbackActive) => {
3634
+ if (removed.length === 0) return;
3635
+ for (const k of removed) closingKeys.add(k);
3636
+ try {
3637
+ if (!await guardClose(removed)) return;
3638
+ set((state) => {
3639
+ const toClose = new Set(removed);
3640
+ const newTabs = new Map([...state.openTabs].filter(([k]) => !toClose.has(k)));
3641
+ if (newTabs.size === state.openTabs.size) return state;
3642
+ const remaining = [...newTabs.keys()];
3643
+ return {
3644
+ openTabs: newTabs,
3645
+ activeKey: toClose.has(state.activeKey) || !newTabs.has(state.activeKey) ? fallbackActive(remaining) : state.activeKey
3646
+ };
3647
+ });
3648
+ } finally {
3649
+ for (const k of removed) closingKeys.delete(k);
3650
+ }
3651
+ };
3652
+ return {
3653
+ ...initialState$3,
3654
+ /**
3655
+ * @zh 设置标签页是否处于刷新状态
3656
+ * @en Set whether the tab is in a refresh state.
3657
+ */
3658
+ setIsRefresh: (state) => {
3659
+ set({ isRefresh: state });
3660
+ },
3661
+ /**
3662
+ * @zh 设置标签页
3663
+ * @en Set the tab.
3664
+ */
3665
+ setActiveKey: (routePath) => {
3666
+ set({ activeKey: routePath });
3667
+ },
3668
+ /**
3669
+ * @zh 在最前面插入标签页
3670
+ * @en Insert a tab at the front.
3671
+ */
3672
+ insertBeforeTab: (routePath, tabProps) => {
3673
+ set((state) => {
3674
+ if (routePath?.length) {
3675
+ const newMap = /* @__PURE__ */ new Map([[routePath, tabProps]]);
3676
+ for (const [key, value] of state.openTabs) newMap.set(key, value);
3677
+ return { openTabs: newMap };
3678
+ }
3679
+ return state;
3680
+ });
3681
+ },
3682
+ /**
3683
+ * @zh 添加标签页
3684
+ * @en Add a tab.
3685
+ */
3686
+ addTab: (routePath, tabProps) => {
3687
+ set((state) => {
3688
+ if (routePath?.length) {
3689
+ const newTabs = new Map(state.openTabs);
3690
+ /**
3691
+ * 1. 如果 tab 已经存在,则更新 historyState 属性,所以不去重,且 ...newTabs.get(routePath) 是为了保证首页的 closable 属性不被覆盖
3692
+ * 2. 如果 tab 不存在,则添加到 Map 中
3693
+ * 3. beforeClose 是注册语义而非覆盖语义:数组 concat + 按引用去重
3694
+ * (keep-alive 重挂载重复注册不叠加);null 显式清除全部守卫
3695
+ */
3696
+ const prev = newTabs.get(routePath);
3697
+ const merged = {
3698
+ ...prev,
3699
+ ...tabProps
3700
+ };
3701
+ if (tabProps.beforeClose === null) merged.beforeClose = void 0;
3702
+ else if (prev?.beforeClose != null && tabProps.beforeClose !== void 0) merged.beforeClose = [...Array.isArray(prev.beforeClose) ? prev.beforeClose : [prev.beforeClose], ...Array.isArray(tabProps.beforeClose) ? tabProps.beforeClose : [tabProps.beforeClose]].filter((f, i, a) => a.indexOf(f) === i);
3703
+ newTabs.set(routePath, merged);
3704
+ return { openTabs: newTabs };
3705
+ }
3706
+ return state;
3707
+ });
3708
+ },
3709
+ /**
3710
+ * @zh 移除标签页
3711
+ * @en Remove a tab.
3712
+ */
3713
+ removeTab: async (routePath) => {
3636
3714
  const homePath = "/home";
3637
- if (routePath === homePath) return state;
3638
- const newTabs = new Map(state.openTabs);
3639
- newTabs.delete(routePath);
3640
- let newActiveKey = state.activeKey;
3641
- if (routePath === state.activeKey) newActiveKey = Array.from(newTabs.keys()).at(-1) || homePath;
3642
- if (newTabs.size === 0) {
3643
- newTabs.set(homePath, state.openTabs.get(homePath));
3644
- newActiveKey = homePath;
3645
- }
3646
- return {
3647
- openTabs: newTabs,
3648
- activeKey: newActiveKey
3649
- };
3650
- });
3651
- },
3652
- /**
3653
- * @zh 关闭右侧标签页
3654
- * @en Close tabs on the right.
3655
- */
3656
- closeRightTabs: (routePath) => {
3657
- set((state) => {
3658
- const newTabs = /* @__PURE__ */ new Map();
3659
- let found = false;
3660
- let activeKeyFound = false;
3661
- let newActiveKey = state.activeKey;
3662
- for (const [key, value] of state.openTabs) {
3663
- if (found) break;
3664
- newTabs.set(key, value);
3665
- if (key === routePath) found = true;
3666
- if (key === state.activeKey) activeKeyFound = true;
3667
- }
3668
- if (!activeKeyFound) newActiveKey = routePath;
3669
- return {
3670
- openTabs: newTabs,
3671
- activeKey: newActiveKey
3672
- };
3673
- });
3674
- },
3675
- /**
3676
- * @zh 关闭左侧标签页
3677
- * @en Close tabs on the left.
3678
- */
3679
- closeLeftTabs: (routePath) => {
3680
- set((state) => {
3681
- const newTabs = /* @__PURE__ */ new Map();
3715
+ if (!routePath?.length || routePath === homePath || closingKeys.has(routePath)) return;
3716
+ await guardAndClose([routePath], (keys) => keys.at(-1) ?? homePath);
3717
+ },
3718
+ /**
3719
+ * @zh 关闭右侧标签页
3720
+ * @en Close tabs on the right.
3721
+ */
3722
+ closeRightTabs: async (routePath) => {
3723
+ if (closingKeys.has(routePath)) return;
3724
+ const keys = [...get().openTabs.keys()];
3725
+ const idx = keys.indexOf(routePath);
3726
+ if (idx < 0) return;
3727
+ await guardAndClose(keys.slice(idx + 1), () => routePath);
3728
+ },
3729
+ /**
3730
+ * @zh 关闭左侧标签页
3731
+ * @en Close tabs on the left.
3732
+ */
3733
+ closeLeftTabs: async (routePath) => {
3734
+ if (closingKeys.has(routePath)) return;
3682
3735
  const homePath = "/home";
3683
- let found = false;
3684
- let newActiveKey = state.activeKey;
3685
- let activeKeyOnRight = false;
3686
- newTabs.set(homePath, state.openTabs.get(homePath));
3687
- for (const [key, value] of state.openTabs) {
3688
- if (key === homePath) continue;
3689
- if (found || key === routePath) {
3690
- newTabs.set(key, value);
3691
- found = true;
3692
- }
3693
- if (key === state.activeKey && found) activeKeyOnRight = true;
3694
- }
3695
- if (!activeKeyOnRight) newActiveKey = routePath;
3696
- return {
3697
- openTabs: newTabs,
3698
- activeKey: newActiveKey
3699
- };
3700
- });
3701
- },
3702
- /**
3703
- * @zh 关闭其他标签页
3704
- * @en Close other tabs.
3705
- */
3706
- closeOtherTabs: (routePath) => {
3707
- set((state) => {
3708
- const newTabs = /* @__PURE__ */ new Map();
3736
+ const keys = [...get().openTabs.keys()];
3737
+ const idx = keys.indexOf(routePath);
3738
+ if (idx < 0) return;
3739
+ await guardAndClose(keys.slice(0, idx).filter((k) => k !== homePath), () => routePath);
3740
+ },
3741
+ /**
3742
+ * @zh 关闭其他标签页
3743
+ * @en Close other tabs.
3744
+ */
3745
+ closeOtherTabs: async (routePath) => {
3746
+ if (closingKeys.has(routePath)) return;
3709
3747
  const homePath = "/home";
3710
- newTabs.set(homePath, state.openTabs.get(homePath));
3711
- if (routePath !== homePath && state.openTabs.has(routePath)) newTabs.set(routePath, state.openTabs.get(routePath));
3712
- let newActiveKey = state.activeKey;
3713
- if (!newTabs.has(state.activeKey)) newActiveKey = routePath;
3714
- return {
3715
- openTabs: newTabs,
3716
- activeKey: newActiveKey
3717
- };
3718
- });
3719
- },
3720
- /**
3721
- * @zh 关闭所有标签页
3722
- * @en Close all tabs.
3723
- */
3724
- closeAllTabs: () => {
3725
- set((state) => {
3726
- const newTabs = /* @__PURE__ */ new Map();
3748
+ await guardAndClose([...get().openTabs.keys()].filter((k) => k !== homePath && k !== routePath), () => routePath);
3749
+ },
3750
+ /**
3751
+ * @zh 关闭所有标签页
3752
+ * @en Close all tabs.
3753
+ */
3754
+ closeAllTabs: async () => {
3727
3755
  const homePath = "/home";
3728
- newTabs.set(homePath, state.openTabs.get(homePath));
3729
- return {
3730
- openTabs: newTabs,
3731
- activeKey: homePath
3732
- };
3733
- });
3734
- },
3735
- /**
3736
- * @zh 更改标签页顺序
3737
- * @en Change tab order.
3738
- */
3739
- changeTabOrder: (from, to) => {
3740
- set((state) => {
3741
- const newTabs = Array.from(state.openTabs.entries());
3742
- const [movedTab] = newTabs.splice(from, 1);
3743
- newTabs.splice(to, 0, movedTab);
3744
- return { openTabs: new Map(newTabs) };
3745
- });
3746
- },
3747
- /**
3748
- * @zh 切换标签页最大化状态
3749
- * @en Toggle tab maximization status
3750
- * @param {boolean} state - 最大化状态
3751
- */
3752
- toggleMaximize: (state) => {
3753
- set({ isMaximize: state });
3754
- },
3755
- /**
3756
- * @zh 设置标签页标题
3757
- * @en Set the tab title
3758
- */
3759
- setTableTitle: (routePath, title) => {
3760
- set((state) => {
3761
- const newTabs = new Map(state.openTabs);
3762
- const targetTab = newTabs.get(routePath);
3763
- if (targetTab) {
3764
- targetTab.newTabTitle = title;
3765
- newTabs.set(routePath, targetTab);
3766
- return { openTabs: newTabs };
3767
- }
3768
- return state;
3769
- });
3770
- },
3771
- /**
3772
- * @zh 重置标签页标题(删除自定义的标题)
3773
- * @en Reset the tab title (delete custom titles)
3774
- */
3775
- resetTableTitle: (routePath) => {
3776
- set((state) => {
3777
- const newTabs = new Map(state.openTabs);
3778
- const targetTab = newTabs.get(routePath);
3779
- if (targetTab) {
3780
- delete targetTab.newTabTitle;
3781
- newTabs.set(routePath, targetTab);
3782
- return { openTabs: newTabs };
3783
- }
3784
- return state;
3785
- });
3786
- },
3787
- /**
3788
- * @zh 重置所有标签页的状态
3789
- * @en Reset all tab states
3790
- */
3791
- resetTabs: () => {
3792
- set(() => {
3793
- return { ...initialState$3 };
3794
- });
3795
- }
3796
- }), {
3756
+ await guardAndClose([...get().openTabs.keys()].filter((k) => k !== homePath), () => homePath);
3757
+ },
3758
+ /**
3759
+ * @zh 更改标签页顺序
3760
+ * @en Change tab order.
3761
+ */
3762
+ changeTabOrder: (from, to) => {
3763
+ set((state) => {
3764
+ const newTabs = Array.from(state.openTabs.entries());
3765
+ const [movedTab] = newTabs.splice(from, 1);
3766
+ newTabs.splice(to, 0, movedTab);
3767
+ return { openTabs: new Map(newTabs) };
3768
+ });
3769
+ },
3770
+ /**
3771
+ * @zh 切换标签页最大化状态
3772
+ * @en Toggle tab maximization status
3773
+ * @param {boolean} state - 最大化状态
3774
+ */
3775
+ toggleMaximize: (state) => {
3776
+ set({ isMaximize: state });
3777
+ },
3778
+ /**
3779
+ * @zh 设置标签页标题
3780
+ * @en Set the tab title
3781
+ */
3782
+ setTableTitle: (routePath, title) => {
3783
+ set((state) => {
3784
+ const newTabs = new Map(state.openTabs);
3785
+ const targetTab = newTabs.get(routePath);
3786
+ if (targetTab) {
3787
+ targetTab.newTabTitle = title;
3788
+ newTabs.set(routePath, targetTab);
3789
+ return { openTabs: newTabs };
3790
+ }
3791
+ return state;
3792
+ });
3793
+ },
3794
+ /**
3795
+ * @zh 重置标签页标题(删除自定义的标题)
3796
+ * @en Reset the tab title (delete custom titles)
3797
+ */
3798
+ resetTableTitle: (routePath) => {
3799
+ set((state) => {
3800
+ const newTabs = new Map(state.openTabs);
3801
+ const targetTab = newTabs.get(routePath);
3802
+ if (targetTab) {
3803
+ delete targetTab.newTabTitle;
3804
+ newTabs.set(routePath, targetTab);
3805
+ return { openTabs: newTabs };
3806
+ }
3807
+ return state;
3808
+ });
3809
+ },
3810
+ /**
3811
+ * @zh 重置所有标签页的状态
3812
+ * @en Reset all tab states
3813
+ */
3814
+ resetTabs: () => {
3815
+ set(() => {
3816
+ return { ...initialState$3 };
3817
+ });
3818
+ }
3819
+ };
3820
+ }, {
3797
3821
  name: getAppNamespace("tabbar"),
3798
3822
  /**
3799
3823
  * activeKey 不需要持久化存储
@@ -4429,6 +4453,7 @@ var init_notification = __esmMin((() => {
4429
4453
  const handleClick = (item) => {
4430
4454
  if (readOnly) return;
4431
4455
  onEventChange && onEventChange("read", item);
4456
+ if (item.link) window.open(item.link, "_blank", "noopener");
4432
4457
  };
4433
4458
  dot = useMemo(() => {
4434
4459
  return !!notifications?.filter((item) => !item?.isRead).length;
@@ -27416,4 +27441,4 @@ init_ri();
27416
27441
  init_menu_icons();
27417
27442
  init_tree();
27418
27443
  //#endregion
27419
- export { AccessControl, AccessControlRoles, AntdApp, BasicButton, BasicContent, BasicTable, EmbeddedIcon, ErrorBoundary, ExternalIcon, FormAvatarItem, FormTreeItem, Iframe, LayoutCenterIcon, LayoutEffects, LayoutLeftIcon, LayoutRightIcon, MixedNavigationIcon, OutsidePageIcon, PageError, ProfileCardIcon, RiAccountCircleLine, RiContrastFill, RiFullscreenExitLine, RiFullscreenLine, RiMailCheckLine, RiMoonIcon, RiReactjsLine, RiSunIcon, RiUserSettingsLine, ServerErrorIcon, SideNavigationIcon, TopNavigationIcon, TwoColumnNavigationIcon, accessControlCodes, defineModule, fetchAddMenuItem, fetchAddRoleItem, fetchAsyncRoutes, fetchDeleteMenuItem, fetchDeleteRoleItem, fetchLogin, fetchLogout, fetchMenuByRoleId, fetchMenuList, fetchRefreshToken, fetchRoleList, fetchRoleMenu, fetchUpdateMenuItem, fetchUpdateRoleItem, fetchUserInfo, filterTree, getAllExpandedKeys, getAppInfo, getBooleanOptions, getModule, getModules, getRedirectPath, getRegisteredApiPrefix, getRegisteredStore, getRoutes, getYesNoOptions, handleTree, loadAll, mapTree, menuIcons, permissionPrefix, setupI18n, traverseTreeValues, unloadModule, useAccess, useAuthStore, usePreferences, useSlotNodes, useTabsStore, useUserStore, external_exports as z };
27444
+ export { AccessControl, AccessControlRoles, AntdApp, BasicButton, BasicContent, BasicTable, EmbeddedIcon, ErrorBoundary, ExternalIcon, FormAvatarItem, FormTreeItem, Iframe, LayoutCenterIcon, LayoutEffects, LayoutLeftIcon, LayoutRightIcon, MixedNavigationIcon, OutsidePageIcon, PageError, ProfileCardIcon, RiAccountCircleLine, RiContrastFill, RiFullscreenExitLine, RiFullscreenLine, RiMailCheckLine, RiMoonIcon, RiReactjsLine, RiSunIcon, RiUserSettingsLine, ServerErrorIcon, SideNavigationIcon, TopNavigationIcon, TwoColumnNavigationIcon, accessControlCodes, defineModule, fetchAddMenuItem, fetchAddRoleItem, fetchAsyncRoutes, fetchDeleteMenuItem, fetchDeleteRoleItem, fetchLogin, fetchLogout, fetchMenuByRoleId, fetchMenuList, fetchRefreshToken, fetchRoleList, fetchRoleMenu, fetchUpdateMenuItem, fetchUpdateRoleItem, fetchUserInfo, filterTree, getAllExpandedKeys, getAppInfo, getBooleanOptions, getModule, getModules, getRedirectPath, getRegisteredApiPrefix, getRegisteredStore, getRoutes, getYesNoOptions, handleTree, loadAll, mapTree, menuIcons, notify, permissionPrefix, setupI18n, traverseTreeValues, unloadModule, useAccess, useAuthStore, usePreferences, useSlotNodes, useTabsStore, useUserStore, external_exports as z };
@@ -1,3 +1,4 @@
1
+ import type { PushBroadcastInput, PushInput } from "@oj-module/runtime/notify";
1
2
  import type { MenuItemType } from "../api/system/menu/types";
2
3
  import type { FetchAddRoleItemBody, FetchAddRoleItemData, FetchDeleteRoleItemBody, FetchDeleteRoleItemData, FetchMenuByRoleIdData, FetchMenuByRoleIdQuery, FetchRoleListData, FetchRoleListQuery, FetchRoleMenuData, FetchUpdateRoleItemBody, FetchUpdateRoleItemData } from "../api/system/role/client/api";
3
4
  import type { NotificationItem } from "../layout/widgets/notification/types";
@@ -26,14 +27,31 @@ export interface SystemApiProvider {
26
27
  }
27
28
  /**
28
29
  * 通知 provider(D9 + G4):模块经 ctx.register.notificationsApi 接管通知。
29
- * 四方法全必填——「接管通知 API」是全量接管(读 + 三个写),不做部分托管;
30
- * 老 bundle 只有 fetchNotifications 的版本漂移由消费点运行时防御(评审 P2-7)。
30
+ *
31
+ * 定位澄清(Part A):这是「铃的数据通道」——消费端(读 + 三个写),供前端通知铃
32
+ * 接管内置通知 API。**单一 provider 槽位、先到先得是「一个铃 / 一套端点」的事实,
33
+ * 不是模块竞争通知归属的特权**(与 authProvider 同规则:消费位独占合理)。
34
+ * 框架从未提供「给某用户发通知」的原语——跨模块发射请走 notify 发射方法(见下)
35
+ * 或后端 api/src/_shared/notify,而非各模块自建私有表抢这一个数据源。
36
+ *
37
+ * 消费端四方法全必填——「接管通知 API」是全量接管,不做部分托管;老 bundle 只有
38
+ * fetchNotifications 的版本漂移由消费点运行时防御(评审 P2-7)。
39
+ *
40
+ * 发射端三方法(跨模块原语):显式 userId,前端 provider 经 HTTP 落到后端通知模块;
41
+ * 后端模块请直接用 api/src/_shared/notify(db 直写,绕开 HTTP)。原语不做任何业务
42
+ * 判断(不判空间/角色/可见性),收件人过滤是发送方责任。
31
43
  */
32
44
  export interface NotificationsApiProvider {
33
45
  fetchNotifications: () => Promise<NotificationItem[]>;
34
46
  markRead: (id: string | number) => Promise<void>;
35
47
  markAllRead: () => Promise<void>;
36
48
  clearAll: () => Promise<void>;
49
+ /** 给指定用户推按人通知,返回命名空间 id "u:<id>" */
50
+ push: (input: PushInput) => Promise<string>;
51
+ /** 广播通知(全员可见,一行存储),返回命名空间 id "b:<id>" */
52
+ pushBroadcast: (input: PushBroadcastInput) => Promise<string>;
53
+ /** 删通知:按人项校验归属;广播项仅删本人回执 */
54
+ del: (id: string, userId: string | number) => Promise<void>;
37
55
  }
38
56
  /** 上传 provider(D9):模块经 ctx.register.uploadApi 接管头像/附件上传端点 */
39
57
  export interface UploadApiProvider {
@@ -1,4 +1,9 @@
1
1
  import type { TabPaneProps } from "antd";
2
+ /**
3
+ * @zh 关闭前守卫:返回 false(或 Promise<false>)取消关闭;抛错按取消处理(fail-safe)
4
+ * @en Close guard: return false (or Promise<false>) to cancel the close; a thrown error is treated as cancel (fail-safe).
5
+ */
6
+ export type BeforeTabCloseGuard = () => boolean | Promise<boolean>;
2
7
  /**
3
8
  * @zh 标签页项目属性接口
4
9
  * @en Tab item properties interface.
@@ -17,6 +22,12 @@ export interface TabItemProps extends Omit<TabPaneProps, "tab"> {
17
22
  * @see {@link https://reactrouter.com/en/main/hooks/use-navigate#optionsstate | usenavigate - options state}
18
23
  */
19
24
  historyState?: Record<string, any>;
25
+ /**
26
+ * @zh 关闭前守卫:单个或数组(顺序执行,按函数引用去重,null 清除全部守卫)。
27
+ * 五个关闭动作(单个/右/左/其他/全部)关闭前 await 守卫,任一否决或抛错整批取消。
28
+ * @en Guard before close: a single guard or an array (executed in order, deduped by reference; null clears all guards). All five close actions await guards before closing — any veto or thrown error cancels the whole batch.
29
+ */
30
+ beforeClose?: BeforeTabCloseGuard | BeforeTabCloseGuard[] | null;
20
31
  }
21
32
  export interface TabStateType extends Omit<TabItemProps, "label"> {
22
33
  label: string;
@@ -32,13 +43,13 @@ export interface TabStateType extends Omit<TabItemProps, "label"> {
32
43
  */
33
44
  interface TabsAction {
34
45
  setIsRefresh: (state: boolean) => void;
35
- addTab: (routePath: string, tabProps: TabStateType) => void;
46
+ addTab: (routePath: string, tabProps: Partial<TabStateType>) => void;
36
47
  insertBeforeTab: (routePath: string, tabProps: TabStateType) => void;
37
- removeTab: (routePath: string) => void;
38
- closeRightTabs: (routePath: string) => void;
39
- closeLeftTabs: (routePath: string) => void;
40
- closeOtherTabs: (routePath: string) => void;
41
- closeAllTabs: () => void;
48
+ removeTab: (routePath: string) => Promise<void>;
49
+ closeRightTabs: (routePath: string) => Promise<void>;
50
+ closeLeftTabs: (routePath: string) => Promise<void>;
51
+ closeOtherTabs: (routePath: string) => Promise<void>;
52
+ closeAllTabs: () => Promise<void>;
42
53
  setActiveKey: (routePath: string) => void;
43
54
  resetTabs: () => void;
44
55
  changeTabOrder: (from: number, to: number) => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/runtime",
3
3
  "type": "module",
4
- "version": "0.1.16",
4
+ "version": "0.1.20",
5
5
  "description": "React Antd Admin 框架运行时,供模块工程消费",
6
6
  "license": "MIT",
7
7
  "imports": {
@@ -19,6 +19,10 @@
19
19
  "./contract/errors": {
20
20
  "types": "./dist/contract/errors.d.ts",
21
21
  "default": "./dist/contract/errors.js"
22
+ },
23
+ "./notify": {
24
+ "types": "./dist/notify/index.d.ts",
25
+ "default": "./dist/notify/index.js"
22
26
  }
23
27
  },
24
28
  "files": [
@@ -64,6 +68,6 @@
64
68
  "unplugin-icons": "^23.0.1"
65
69
  },
66
70
  "scripts": {
67
- "build": "vite build && tsc -p tsconfig.contract.json && tsc -p tsconfig.dts.json && node scripts/rewrite-dts-specifiers.mjs && node scripts/inline-css.mjs"
71
+ "build": "vite build && tsc -p tsconfig.contract.json && tsc -p tsconfig.notify.json && tsc -p tsconfig.dts.json && node scripts/rewrite-dts-specifiers.mjs && node scripts/inline-css.mjs"
68
72
  }
69
73
  }
@@ -1,76 +0,0 @@
1
- import type { z } from "zod";
2
- /**
3
- * AC-D11/AC-D4:契约端点定义。
4
- *
5
- * route 一律相对 apiPrefix(无 oj 根绝对写法——前端受模块手册 D11 前缀收敛,
6
- * 根绝对逃逸语义不放行);参数段支持 oj matchit 同款 `{id}` 单段与
7
- * `{*path}` catch-all,参数段内不得混字面(`{id}.json` 非法)。
8
- */
9
- export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
10
- /**
11
- * multipart/form-data 的文件部件:**只声明名字与形态**。
12
- *
13
- * 二进制刻意不进 zod(JSON Schema 无 binary 类型,且字节由运行时的 FormData
14
- * 组装):oj 侧按 `http.files`(元数据 { field, filename, content_type, size })
15
- * 与 `http.file(i)`(字节)读取。
16
- */
17
- export interface ApiFormFile {
18
- /** 表单字段名(= FormData 的 append key = oj 侧 http.files[].field) */
19
- name: string;
20
- /** true = 必传;缺省可选(可选文件不 append 部件,oj 侧自然缺省) */
21
- required?: boolean;
22
- /** true = 同名多文件:生成 client 接受数组并逐个 append(oj 侧 http.files 多条) */
23
- multiple?: boolean;
24
- }
25
- /** multipart/form-data 请求体:文本部件走 zod,文件部件只声明形态(与 body 互斥) */
26
- export interface ApiFormInput {
27
- /** 文本部件 schema(z.object——字段名即部件名;进 OpenAPI 的 multipart JSON schema) */
28
- fields?: z.ZodType;
29
- /** 文件部件(与 fields 至少声明其一) */
30
- files?: ApiFormFile[];
31
- }
32
- export interface ApiDefinitionInput {
33
- /** 模块 API 前缀("/" 开头);uni-dev 形态字面等于 oj 模块段名(AC-D9) */
34
- apiPrefix: string;
35
- /**
36
- * 线上 URL 前缀("/" 开头,D-M8):存量 oj 后端 URL 命名空间与目录名错位时
37
- * 显式声明(如目录 workitems、URL /workspaces/{slug}/...)。缺省 = apiPrefix。
38
- * 认领/护栏身份仍按 apiPrefix(目录名);线上前缀不一致的同模块端点混用报错。
39
- */
40
- urlPrefix?: string;
41
- /** 相对 apiPrefix 的路由("/" 开头),支持 `{id}` / `{*path}` 参数段 */
42
- route: string;
43
- /** HTTP 方法,缺省 "GET"(IR 层归一) */
44
- method?: HttpMethod;
45
- /** 查询参数 schema(声明语义类型,URL 序列化由生成物负责) */
46
- query?: z.ZodType;
47
- /** 路径参数 schema(与 route 参数段一一对应) */
48
- params?: z.ZodType;
49
- /** 请求体 schema(JSON;与 form 互斥) */
50
- body?: z.ZodType;
51
- /**
52
- * multipart/form-data 请求体(与 body 互斥):文本部件写进 form.fields(zod),
53
- * 文件部件只声明名字——二进制无法保真进 JSON Schema,FormData 由生成 client 组装。
54
- */
55
- form?: ApiFormInput;
56
- /** 响应信封 data 部分的 schema;与 response:"raw" 互斥 */
57
- data?: z.ZodType;
58
- /** "raw" = 二进制/非信封逃生口:不解包、不校验、不进 mock 生成 */
59
- response?: "raw";
60
- /** true = 该端点不触发全局加载条(客户端表现层开关,非线协议,不进 OpenAPI) */
61
- ignoreLoading?: boolean;
62
- /** 接口描述(进 OpenAPI 文档) */
63
- description?: string;
64
- }
65
- /**
66
- * defineApi 产物的品牌标记(Symbol.for 跨模块实例稳定)。
67
- * 非枚举属性——不影响 .route 等的可枚举性;codegen 据此可靠识别端点,
68
- * 不与契约文件里导出的普通 schema/常量混淆。
69
- *
70
- * 注:旧标记 `Symbol.for("ram.api.def")` 不在本包公开——只有 cli 的 IR 识别
71
- * 存量产物时才需要它,故由 `packages/cli/src/contract/ir.ts` 自行声明(避免把
72
- * 兼容专用符号扩进公共出口,设计 §7 R3)。
73
- */
74
- export declare const API_DEF: unique symbol;
75
- /** 定义一个契约端点:定义期校验后原样返回(描述符 .route 等可枚举,供 codegen/mock 遍历) */
76
- export declare function defineApi<D extends ApiDefinitionInput>(def: D): D;
@@ -1,61 +0,0 @@
1
- /** oj matchit 同款参数段:整段必须是 {name} 或 {*name},不得混字面 */
2
- const PARAM_SEGMENT = /^\{\*?[a-z_]\w*\}$/i;
3
- function fail(route, reason) {
4
- throw new Error(`[契约] 端点定义非法(route: ${route}):${reason}`);
5
- }
6
- function validateDefinition(def) {
7
- const { apiPrefix, route } = def;
8
- if (!apiPrefix?.startsWith("/")) {
9
- throw new Error(`[契约] apiPrefix 必须以 "/" 开头(收到: ${apiPrefix})——如 "/order";uni-dev 形态请与 oj 模块段名保持一致(AC-D9)。`);
10
- }
11
- if (def.urlPrefix !== undefined && !def.urlPrefix.startsWith("/"))
12
- fail(route, `urlPrefix 必须以 "/" 开头(收到: ${def.urlPrefix})。`);
13
- if (!route?.startsWith("/"))
14
- fail(route, `route 必须以 "/" 开头(收到: ${route}),且一律相对 apiPrefix——不支持 oj 的根绝对写法(模块手册 D11 前缀收敛)。`);
15
- if (route.split("/").some(seg => seg === ".." || seg === "." || seg === "\\"))
16
- fail(route, "route 含路径穿越段(.././\\),请改为正常静态段。");
17
- for (const seg of route.split("/")) {
18
- if (seg.includes("{") && !PARAM_SEGMENT.test(seg))
19
- fail(route, `参数段 "${seg}" 混入字面量——matchit 约束:参数段必须整段为 {name} 或 {*name},需要前缀/后缀字面时请拆成静态多段。`);
20
- }
21
- if (def.data && def.response === "raw")
22
- fail(route, "data schema 与 response:\"raw\" 互斥——raw 端点不解包信封,不需要 data schema。");
23
- if (def.form && def.body)
24
- fail(route, "form 与 body 互斥——一个端点只有一种请求体形态:multipart/form-data(form)与 JSON(body)声明其一,文本字段请写进 form.fields。");
25
- if (def.form) {
26
- const files = def.form.files ?? [];
27
- if (!def.form.fields && files.length === 0)
28
- fail(route, "form 至少需要 fields 或一项 files——空 multipart 请求体无意义;无请求体请删掉 form(改用 query/params)。");
29
- const seen = new Set();
30
- for (const file of files) {
31
- if (!file?.name)
32
- fail(route, "form.files 每项必须有 name——它既是 FormData 的 append key,也是 oj 侧 http.files[].field 的字段名。");
33
- if (seen.has(file.name))
34
- fail(route, `form.files 字段名 "${file.name}" 重复——同名多文件请用 multiple: true。`);
35
- seen.add(file.name);
36
- }
37
- }
38
- if (def.response !== undefined && def.response !== "raw")
39
- fail(route, `response 仅支持 "raw"(收到: ${String(def.response)})。`);
40
- // 评审 F9:方法面在定义期封顶,而不是延迟到 codegen 才炸
41
- if (def.method === "OPTIONS")
42
- fail(route, "OPTIONS 不在支持范围(ky/oj 均无对应方法)——预检请求由 fetch/CORS 层处理,契约不表达。");
43
- if (def.method === "HEAD" && def.data)
44
- fail(route, "HEAD 端点无响应体,不能声明 data schema——需要响应体请改用 GET。");
45
- }
46
- /**
47
- * defineApi 产物的品牌标记(Symbol.for 跨模块实例稳定)。
48
- * 非枚举属性——不影响 .route 等的可枚举性;codegen 据此可靠识别端点,
49
- * 不与契约文件里导出的普通 schema/常量混淆。
50
- *
51
- * 注:旧标记 `Symbol.for("ram.api.def")` 不在本包公开——只有 cli 的 IR 识别
52
- * 存量产物时才需要它,故由 `packages/cli/src/contract/ir.ts` 自行声明(避免把
53
- * 兼容专用符号扩进公共出口,设计 §7 R3)。
54
- */
55
- export const API_DEF = Symbol.for("ojm.api.def");
56
- /** 定义一个契约端点:定义期校验后原样返回(描述符 .route 等可枚举,供 codegen/mock 遍历) */
57
- export function defineApi(def) {
58
- validateDefinition(def);
59
- Object.defineProperty(def, API_DEF, { value: true, enumerable: false });
60
- return def;
61
- }