@oj-module/cli 0.2.6 → 0.2.14

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 (169) hide show
  1. package/README.md +1 -1
  2. package/devkit/README.md +70 -24
  3. package/devkit/framework-dev.md +14 -5
  4. package/devkit/manual.md +28 -23
  5. package/devkit/module-dev-and-cdn-playground.md +197 -0
  6. package/devkit/sql-builder-missing-capabilities-pr.md +268 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/runtime.js +130 -45
  9. package/shell-dist/index.html +2 -2
  10. package/shell-dist/versions.json +3 -3
  11. package/src/__tests__/build-backend.test.ts +64 -0
  12. package/src/__tests__/versions.test.ts +26 -0
  13. package/src/build.ts +9 -0
  14. package/src/bundle.ts +8 -5
  15. package/src/contract/__tests__/handler-scan.test.ts +29 -0
  16. package/src/contract/__tests__/market-client.integration.test.ts +170 -0
  17. package/src/contract/handler-scan.ts +35 -4
  18. package/src/dev.ts +10 -2
  19. package/src/index.ts +9 -3
  20. package/src/init.ts +24 -0
  21. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  22. package/src/market/__tests__/bootstrap.test.ts +204 -0
  23. package/src/market/__tests__/build-index.test.ts +154 -0
  24. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  25. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  26. package/src/market/__tests__/fixture.test.ts +48 -0
  27. package/src/market/__tests__/publish.test.ts +109 -0
  28. package/src/market/__tests__/report.test.ts +51 -0
  29. package/src/market/__tests__/setup.test.ts +114 -0
  30. package/src/market/__tests__/verify.test.ts +111 -0
  31. package/src/market/bootstrap.ts +255 -0
  32. package/src/market/build-index.ts +159 -0
  33. package/src/market/cdn-layout.ts +122 -0
  34. package/src/market/command.ts +139 -0
  35. package/src/market/failure-playbook.ts +57 -0
  36. package/src/market/fixture.ts +138 -0
  37. package/src/market/index-builder.ts +76 -0
  38. package/src/market/publish.ts +156 -0
  39. package/src/market/report.ts +67 -0
  40. package/src/market/scan.ts +99 -0
  41. package/src/market/setup.ts +144 -0
  42. package/src/market/trial.ts +121 -0
  43. package/src/market/verify.ts +298 -0
  44. package/src/market/walkthrough.ts +506 -0
  45. package/src/oj.ts +3 -1
  46. package/src/preview-config.ts +177 -0
  47. package/src/preview.ts +106 -56
  48. package/src/sign.ts +21 -6
  49. package/src/usage.ts +22 -2
  50. package/src/vendor.ts +4 -2
  51. package/src/versions.ts +28 -0
  52. package/templates/api/.ojm-api-exempt.json +6 -2
  53. package/templates/api/config.yaml +24 -2
  54. package/templates/api/src/_platform/README.md +31 -0
  55. package/templates/api/src/_platform/_lifecycle/atomic.ts +1 -1
  56. package/templates/api/src/_platform/_lifecycle/history.ts +10 -5
  57. package/templates/api/src/_platform/_lifecycle/installer.ts +4 -4
  58. package/templates/api/src/_platform/_lifecycle/sha384.ts +1 -1
  59. package/templates/api/src/_platform/_lifecycle/steps/download.ts +1 -1
  60. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  61. package/templates/api/src/_platform/_lifecycle/steps/register.ts +57 -29
  62. package/templates/api/src/_platform/_lifecycle/trust.ts +1 -1
  63. package/templates/api/src/_platform/_lifecycle/uninstall.ts +29 -56
  64. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +2 -2
  65. package/templates/api/src/_platform/_shared/service/{contacts.ts → domain/contacts.ts} +20 -13
  66. package/templates/api/src/_platform/_shared/service/{roles.ts → domain/roles.ts} +18 -19
  67. package/templates/api/src/_platform/_shared/service/{settings.ts → domain/settings.ts} +6 -6
  68. package/templates/api/src/_platform/_shared/service/{solution-perms.ts → domain/solution-perms.ts} +25 -22
  69. package/templates/api/src/_platform/_shared/service/{users.ts → domain/users.ts} +20 -26
  70. package/templates/api/src/_platform/_shared/service/{access.ts → infra/access.ts} +5 -8
  71. package/templates/api/src/_platform/_shared/service/{audit.ts → infra/audit.ts} +26 -26
  72. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +40 -0
  73. package/templates/api/src/_platform/_shared/service/{query.ts → infra/query.ts} +18 -0
  74. package/templates/api/src/_platform/_shared/service/{rate.ts → infra/rate.ts} +16 -13
  75. package/templates/api/src/_platform/_shared/site-facade.ts +1 -1
  76. package/templates/api/src/_platform/contacts/api.ts +2 -2
  77. package/templates/api/src/_platform/contacts/detail/api.ts +2 -2
  78. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  79. package/templates/api/src/_platform/manifest.yaml +4 -1
  80. package/templates/api/src/_platform/market/api.ts +54 -16
  81. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  82. package/templates/api/src/_platform/schema.yaml +26 -0
  83. package/templates/api/src/_platform/solutions/api.ts +13 -12
  84. package/templates/api/src/auth/_shared/ldap-login.ts +11 -7
  85. package/templates/api/src/auth/_shared/login-throttle.ts +2 -2
  86. package/templates/api/src/auth/_shared/session.ts +26 -22
  87. package/templates/api/src/auth/api.ts +1 -1
  88. package/templates/api/src/demo/todos/api.ts +8 -8
  89. package/templates/api/src/home/line/api.ts +4 -4
  90. package/templates/api/src/home/pie/api.ts +4 -4
  91. package/templates/api/src/notification/_shared/notify.ts +97 -57
  92. package/templates/api/src/notification/api.ts +1 -1
  93. package/templates/api/src/notification/manifest.yaml +1 -0
  94. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  95. package/templates/api/src/notification/schema.yaml +7 -0
  96. package/templates/api/src/notification/seed.sql +5 -4
  97. package/templates/api/src/personal-center/profile/api.ts +63 -0
  98. package/templates/api/src/personal-center/upload/api.ts +1 -1
  99. package/templates/api/src/system/login-methods/api.ts +1 -1
  100. package/templates/api/src/system/oplog/api.ts +3 -3
  101. package/templates/api/src/system/permissions/api.ts +2 -2
  102. package/templates/api/src/system/roles/api.ts +2 -2
  103. package/templates/api/src/system/roles/bindings/api.ts +2 -2
  104. package/templates/api/src/system/settings/api.ts +2 -2
  105. package/templates/api/src/system/users/api.ts +2 -2
  106. package/templates/api/src/system/users/forbidden/api.ts +2 -2
  107. package/templates/api/src/system/users/password/api.ts +2 -2
  108. package/templates/api/src/system/users/roles/api.ts +2 -2
  109. package/templates/api/src/web/user-info/api.ts +4 -5
  110. package/templates/api/tests/auth-session.spec.ts +1 -1
  111. package/templates/api/tests/auth-throttle.spec.ts +1 -1
  112. package/templates/api/tests/fixture.ts +8 -16
  113. package/templates/api/tests/guard.spec.ts +1 -1
  114. package/templates/api/tests/l1-builder.test.ts +91 -0
  115. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  116. package/templates/api/tests/lifecycle/market-api.spec.ts +2 -4
  117. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  118. package/templates/api/tests/lifecycle/trust.spec.ts +1 -1
  119. package/templates/api/tests/lifecycle/uninstall.spec.ts +29 -89
  120. package/templates/api/tests/query-utils.spec.ts +85 -1
  121. package/templates/api/tests/rbac-audit.spec.ts +1 -1
  122. package/templates/api/tests/rbac-contacts.spec.ts +2 -2
  123. package/templates/api/tests/rbac-guard.spec.ts +1 -1
  124. package/templates/api/tests/rbac-roles.spec.ts +2 -2
  125. package/templates/api/tests/rbac-settings.spec.ts +66 -21
  126. package/templates/api/tests/rbac-solution-perms.spec.ts +1 -1
  127. package/templates/api/tests/rbac-users.spec.ts +1 -1
  128. package/templates/api/tsconfig.json +8 -0
  129. package/templates/cdn/README.md +48 -0
  130. package/templates/env.d.ts +2 -0
  131. package/templates/global.d.ts +850 -0
  132. package/templates/init.include.yaml +14 -2
  133. package/templates/logs/task-events.jsonl +0 -0
  134. package/templates/openapi.json +580 -23
  135. package/templates/package.json +2 -1
  136. package/templates/web/src/_platform/__client/api.schemas.ts +97 -0
  137. package/templates/web/src/_platform/__client/api.ts +152 -39
  138. package/templates/web/src/notification/entry.ts +46 -9
  139. package/templates/web/src/notification/locales/en-US.json +19 -0
  140. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  141. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  142. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +19 -0
  143. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +3 -0
  144. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +1 -0
  145. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +1 -0
  146. package/templates/web/src/oj-market/api/client.ts +5 -105
  147. package/templates/web/src/oj-market/api/defaultClient.ts +111 -5
  148. package/templates/web/src/oj-market/components/CardGrid.tsx +4 -1
  149. package/templates/web/src/oj-market/components/CardItem.tsx +9 -5
  150. package/templates/web/src/oj-market/components/DetailDrawer.tsx +7 -3
  151. package/templates/web/src/oj-market/components/InstallDialog.tsx +1 -1
  152. package/templates/web/src/oj-market/entry.ts +8 -2
  153. package/templates/web/src/oj-market/hooks/useMarket.ts +4 -1
  154. package/templates/web/src/oj-market/locales/en-US.json +2 -1
  155. package/templates/web/src/oj-market/locales/zh-CN.json +2 -1
  156. package/templates/web/src/oj-market/pages/MarketApp.tsx +16 -1
  157. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  158. package/templates/web/src/personal-center/__client/api.ts +25 -1
  159. package/templates/web/src/personal-center/entry.ts +5 -0
  160. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  161. package/templates/web/src/system/entry.ts +2 -1
  162. package/templates/web.config.ts +13 -10
  163. package/vendor/host-versions.json +3 -3
  164. package/templates/api/src/_platform/_shared/service/oplog.ts +0 -47
  165. package/templates/api/src/notifications/api.ts +0 -32
  166. package/templates/api/src/notifications/manifest.yaml +0 -5
  167. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  168. package/templates/web/src/notifications/__client/api.ts +0 -80
  169. /package/templates/api/src/_platform/_shared/service/{guard.ts → infra/guard.ts} +0 -0
@@ -0,0 +1,131 @@
1
+ import type { NoticeCategory } from "@oj-module/runtime/notify";
2
+
3
+ import { CheckOutlined, ClearOutlined } from "@ant-design/icons";
4
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { App, Avatar, Button, Empty, List, Popconfirm, Segmented, Tag, Tooltip } from "antd";
6
+ import { useState } from "react";
7
+ import { useTranslation } from "react-i18next";
8
+
9
+ import {
10
+ notificationNotificationsClearPost,
11
+ notificationNotificationsGet,
12
+ notificationNotificationsReadAllPost,
13
+ notificationNotificationsReadPost,
14
+ } from "../../__client/api";
15
+
16
+ type Item = Awaited<ReturnType<typeof notificationNotificationsGet>>[number];
17
+ type CatFilter = "all" | NoticeCategory;
18
+
19
+ const CAT_COLOR: Record<NoticeCategory, string> = {
20
+ notice: "blue",
21
+ pm: "magenta",
22
+ mention: "orange",
23
+ };
24
+
25
+ /**
26
+ * 通知中心(N3 落地):全量消息页,铃铛弹层「查看全部」跳转至此。
27
+ * 按类型(notice/pm/mention)Tab 过滤;单条点击标记已读并跳深链;
28
+ * 全部已读/清空与铃铛弹层同一数据路径(/api/notification/*)。
29
+ */
30
+ export default function NotificationCenter() {
31
+ const { t } = useTranslation("notification");
32
+ const { message } = App.useApp();
33
+ const queryClient = useQueryClient();
34
+ const [cat, setCat] = useState<CatFilter>("all");
35
+
36
+ const { data, isPending } = useQuery({
37
+ queryKey: ["notification-center"],
38
+ queryFn: notificationNotificationsGet,
39
+ });
40
+
41
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ["notification-center"] });
42
+
43
+ const readMut = useMutation({
44
+ mutationFn: (id: string) => notificationNotificationsReadPost({ id }),
45
+ onSettled: invalidate,
46
+ onError: e => message.error(String(e)),
47
+ });
48
+ const readAllMut = useMutation({
49
+ mutationFn: notificationNotificationsReadAllPost,
50
+ onSettled: invalidate,
51
+ onError: e => message.error(String(e)),
52
+ });
53
+ const clearMut = useMutation({
54
+ mutationFn: notificationNotificationsClearPost,
55
+ onSettled: invalidate,
56
+ onError: e => message.error(String(e)),
57
+ });
58
+
59
+ const items = (data ?? []).filter(x => cat === "all" || x.category === cat);
60
+
61
+ const handleClick = (item: Item) => {
62
+ if (!item.isRead)
63
+ readMut.mutate(item.id);
64
+ // 深链与铃铛弹层同约定:标记已读后新标签打开
65
+ if (item.link)
66
+ window.open(item.link, "_blank", "noopener");
67
+ };
68
+
69
+ const hasUnread = (data ?? []).some(x => !x.isRead);
70
+
71
+ return (
72
+ <div className="max-w-2xl mx-auto p-4">
73
+ <div className="flex items-center justify-between mb-3">
74
+ <Segmented
75
+ value={cat}
76
+ onChange={v => setCat(v as CatFilter)}
77
+ options={[
78
+ { label: t("center.tabAll"), value: "all" },
79
+ { label: t("center.category.notice"), value: "notice" },
80
+ { label: t("center.category.pm"), value: "pm" },
81
+ { label: t("center.category.mention"), value: "mention" },
82
+ ]}
83
+ />
84
+ <div className="flex items-center gap-2">
85
+ <Tooltip title={hasUnread ? t("center.markAllRead") : null}>
86
+ <Button
87
+ disabled={!hasUnread || readAllMut.isPending}
88
+ icon={<CheckOutlined />}
89
+ onClick={() => readAllMut.mutate()}
90
+ >
91
+ {t("center.markAllRead")}
92
+ </Button>
93
+ </Tooltip>
94
+ <Popconfirm title={t("center.clearConfirm")} onConfirm={() => clearMut.mutate()}>
95
+ <Button danger disabled={!(data ?? []).length || clearMut.isPending} icon={<ClearOutlined />}>
96
+ {t("center.clear")}
97
+ </Button>
98
+ </Popconfirm>
99
+ </div>
100
+ </div>
101
+ <List
102
+ loading={isPending}
103
+ itemLayout="horizontal"
104
+ locale={{ emptyText: <Empty description={t("center.empty")} /> }}
105
+ dataSource={items}
106
+ renderItem={item => (
107
+ <List.Item className="cursor-pointer hover:bg-gray-100" onClick={() => handleClick(item)}>
108
+ <List.Item.Meta
109
+ avatar={<Avatar src={item.avatar || undefined}>{item.title.slice(0, 1)}</Avatar>}
110
+ title={(
111
+ <span className="flex items-center gap-2">
112
+ {!item.isRead && <span className="w-2 h-2 rounded bg-blue-600 inline-block shrink-0" />}
113
+ <span className="font-semibold">{item.title}</span>
114
+ <Tag color={CAT_COLOR[item.category ?? "notice"]}>
115
+ {t(`center.category.${item.category ?? "notice"}`)}
116
+ </Tag>
117
+ </span>
118
+ )}
119
+ description={(
120
+ <div className="flex flex-col gap-1">
121
+ <span>{item.message}</span>
122
+ <span className="text-xs text-muted-foreground">{item.date}</span>
123
+ </div>
124
+ )}
125
+ />
126
+ </List.Item>
127
+ )}
128
+ />
129
+ </div>
130
+ );
131
+ }
@@ -73,6 +73,25 @@ describe("cardGrid 卡片网格(BDD)", () => {
73
73
  expect(handlers.onRollback).toHaveBeenCalledWith(installed);
74
74
  });
75
75
 
76
+ it("given canManage=false(非 superadmin)When 渲染 Then 安装/启停/回滚/卸载按钮禁用、详情/历史保持可用", () => {
77
+ renderWithProviders(
78
+ <CardGrid rows={[installed]} canManage={false} {...handlers} />,
79
+ new InMemoryMarketClient(),
80
+ );
81
+ const btn = (name: string) => screen.getByRole("button", { name }) as HTMLButtonElement;
82
+ // 写操作禁用
83
+ expect(btn("升级").disabled).toBe(true);
84
+ expect(btn("停用").disabled).toBe(true);
85
+ expect(btn("卸载").disabled).toBe(true);
86
+ expect((screen.getByTestId("card-rollback-alpha") as HTMLButtonElement).disabled).toBe(true);
87
+ // 只读操作不受影响
88
+ expect(btn("详情").disabled).toBe(false);
89
+ expect(btn("历史").disabled).toBe(false);
90
+ // 禁用时不应触发写回调
91
+ btn("卸载").click();
92
+ expect(handlers.onUninstall).not.toHaveBeenCalled();
93
+ });
94
+
76
95
  it("given 空列表 When 渲染 Then 显示空态", () => {
77
96
  renderWithProviders(<CardGrid rows={[]} {...handlers} />, new InMemoryMarketClient());
78
97
  expect(screen.getByTestId("card-grid-empty")).toBeTruthy();
@@ -18,6 +18,7 @@ describe("installDialog 安装进度(BDD)", () => {
18
18
  progress: 100,
19
19
  message: "ok",
20
20
  stages: [],
21
+ startedAt: 0,
21
22
  };
22
23
  const client = new InMemoryMarketClient({ taskRecord: done, installResult: { task_id: "t1" } });
23
24
  renderWithProviders(<InstallDialog row={row} open onClose={() => {}} />, client);
@@ -37,6 +38,7 @@ describe("installDialog 安装进度(BDD)", () => {
37
38
  message: "verify failed",
38
39
  stages: [{ stage: "verifying", ok: false, message: "bad hash" }],
39
40
  error: { reason: "包校验失败", retryable: false, hintKey: "lifecycle.stage.verifying.hint" },
41
+ startedAt: 0,
40
42
  };
41
43
  const client = new InMemoryMarketClient({ taskRecord: failed, installResult: { task_id: "t1" } });
42
44
  renderWithProviders(<InstallDialog row={row} open onClose={() => {}} />, client);
@@ -54,6 +56,7 @@ describe("installDialog 安装进度(BDD)", () => {
54
56
  progress: 100,
55
57
  message: "ok",
56
58
  stages: [],
59
+ startedAt: 0,
57
60
  };
58
61
  const client = new InMemoryMarketClient({ taskRecord: done, installResult: { task_id: "t1" } });
59
62
  const rollbackRow: IndexRow = { name: "alpha", latest: "1.2.0", installed: "1.2.0", enabled: true, upgradable: false };
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
4
4
  import { RollbackDialog } from "../components/RollbackDialog";
5
5
  import { InMemoryMarketClient } from "./marketClient.fake";
6
6
  import { renderWithProviders } from "./render";
7
+ import "@testing-library/jest-dom/vitest";
7
8
 
8
9
  const row: IndexRow = { name: "alpha", latest: "1.2.0", installed: "1.2.0", enabled: true, upgradable: false };
9
10
 
@@ -45,6 +45,7 @@ export class InMemoryMarketClient implements MarketClient {
45
45
  progress: 100,
46
46
  message: "ok",
47
47
  stages: [],
48
+ startedAt: 0,
48
49
  } as TaskRecord);
49
50
  }
50
51
 
@@ -1,6 +1,9 @@
1
- // 市场 API 客户端(DIP):定义与宿主 _platform 编排面 + market_history 端点的契约。
2
- // 实现与 ky 形态解耦——组件/hooks 注入 MarketClient,测试可注入内存 fake(见 __tests__)。
1
+ // 市场 API 客户端契约(DIP seams):组件/hooks 依赖此接口,而非具体 http 实现;
2
+ // 测试可注入内存 fake(见 __tests__/marketClient.fake.ts)。
3
3
  // 第一方专用调用面(R29):仅市场应用消费 /api/_platform/market/*,第三方方案不得仿照。
4
+ //
5
+ // 实现见同目录 defaultClient.ts——统一委托给 ojm 从契约生成的 __client,消除手写
6
+ // URL/信封解包与契约漂移(DEV 下生成 client 还会对响应做 zod 校验)。
4
7
 
5
8
  import type {
6
9
  EnableInput,
@@ -11,27 +14,6 @@ import type {
11
14
  UninstallImpact,
12
15
  } from "../types";
13
16
 
14
- /** ky 形态的最小请求子集(get/post 返回带 .json<T>() 的响应) */
15
- export interface HttpRequest {
16
- get: <T = unknown>(
17
- url: string,
18
- // silentError:错误体由本客户端消化(未配置市场地址走空态),不弹全局 toast;
19
- // retry: 0:503 是配置态而非抖动,默认重试 3 次只会刷出 4 条同样错误。
20
- opts?: { searchParams?: Record<string, string>, silentError?: boolean, retry?: number },
21
- ) => Promise<{ json: <R = T>() => Promise<R> }>
22
- post: <T = unknown>(
23
- url: string,
24
- opts?: { json?: unknown },
25
- ) => Promise<{ json: <R = T>() => Promise<R> }>
26
- }
27
-
28
- /** 与运行时/生成客户端一致的响应信封(DIP:服务端统一 {code,msg,data} 包裹) */
29
- interface OjEnvelope<T> {
30
- code: number
31
- msg?: string
32
- data?: T
33
- }
34
-
35
17
  /** 市场客户端契约(组件/hooks 依赖此接口,而非具体 http 实现) */
36
18
  export interface MarketClient {
37
19
  /** 登录即可:{ apiVersion }(peerPlatform 预检口径) */
@@ -51,85 +33,3 @@ export interface MarketClient {
51
33
  /** 卸载(P1-15a 补齐后接通) */
52
34
  uninstall: (name: string) => Promise<{ ok: boolean, message: string }>
53
35
  }
54
-
55
- // 第一方市场端点:相对前缀,由运行时 request 的 VITE_API_BASE_URL(/api)统一加前缀;
56
- // 此处若再写 /api 会与运行时前缀叠加成 /api/api/(R29 联调发现)。
57
- const PLATFORM = "/_platform/market";
58
- const HISTORY = "/_platform/market/history";
59
-
60
- /**
61
- * 503 = 服务端「未配置市场地址(framework.market_cdn_url)」,是**预期空态**而非故障
62
- * (PRD §5.3:卡片区显示「未配置市场地址」)。模板默认不配市场地址,新工程一进市场页
63
- * 就会撞它——在此归空数组,交由卡片空态文案承载;其它状态码照旧上抛。
64
- */
65
- function isUnconfigured(e: unknown): boolean {
66
- return (e as { response?: { status?: number } } | null)?.response?.status === 503;
67
- }
68
-
69
- /** 默认 HTTP 实现:基于运行时 request(ky 形态) */
70
- export function createHttpMarketClient(request: HttpRequest): MarketClient {
71
- return {
72
- async meta() {
73
- const r = await request.get<OjEnvelope<{ apiVersion: string }>>(`${PLATFORM}/meta`);
74
- const env = await r.json();
75
- return env.data!;
76
- },
77
- async index() {
78
- try {
79
- const r = await request.get<OjEnvelope<IndexRow[]>>(`${PLATFORM}/index`, {
80
- silentError: true,
81
- retry: 0,
82
- });
83
- const env = await r.json();
84
- return env.data!;
85
- }
86
- catch (e) {
87
- if (isUnconfigured(e))
88
- return [];
89
- throw e;
90
- }
91
- },
92
- async install(input: InstallInput) {
93
- const r = await request.post<OjEnvelope<{ task_id: string }>>(`${PLATFORM}/install`, {
94
- json: {
95
- name: input.name,
96
- version: input.version ?? null,
97
- source: input.source ?? "cdn",
98
- },
99
- });
100
- const env = await r.json();
101
- return env.data!;
102
- },
103
- async task(id: string) {
104
- const r = await request.get<OjEnvelope<TaskRecord>>(`${PLATFORM}/task`, { searchParams: { id } });
105
- const env = await r.json();
106
- return env.data!;
107
- },
108
- async enable(input: EnableInput) {
109
- const r = await request.post<OjEnvelope<{ ok: boolean, message: string }>>(`${PLATFORM}/enable`, {
110
- json: { name: input.name, enabled: input.enabled },
111
- });
112
- const env = await r.json();
113
- return env.data!;
114
- },
115
- async history() {
116
- const r = await request.get<OjEnvelope<MarketHistory[]>>(HISTORY);
117
- const env = await r.json();
118
- return env.data!;
119
- },
120
- async uninstallImpact(name: string) {
121
- const r = await request.get<OjEnvelope<UninstallImpact>>(`${PLATFORM}/uninstall-impact`, {
122
- searchParams: { name },
123
- });
124
- const env = await r.json();
125
- return env.data!;
126
- },
127
- async uninstall(name: string) {
128
- const r = await request.post<OjEnvelope<{ ok: boolean, message: string }>>(`${PLATFORM}/uninstall`, {
129
- json: { name },
130
- });
131
- const env = await r.json();
132
- return env.data!;
133
- },
134
- };
135
- }
@@ -1,7 +1,113 @@
1
- // 默认市场客户端单例:基于运行时 request(ky 形态)。
2
- // 组件经 MarketClientContext 消费,测试可注入内存 fake,避免依赖真实后端(DIP)。
1
+ // 默认市场客户端单例:统一委托给 ojm 从契约生成的 __client(运行时校验 + 唯一真源
2
+ // URL/信封)。组件经 MarketClientContext 消费,测试可注入内存 fake,避免依赖真实后端(DIP)。
3
+ //
4
+ // 统一收益:消除手写 URL 字符串、信封 {code,msg,data} 解包与契约漂移——DEV 下生成
5
+ // client 还会对响应做 zod 校验,服务端形状一旦漂移立即在控制台报错。
6
+ //
7
+ // index 是唯一「预期空态(未配置市场地址 → 503)」端点,需 silentError 抑制全局 toast;
8
+ // 生成 client 不暴露该选项,故 index 仍直接走运行时 request(见下方注释),其余方法统一委托。
9
+
10
+ import type {
11
+ EnableInput,
12
+ IndexRow,
13
+ InstallInput,
14
+ MarketHistory,
15
+ TaskRecord,
16
+ UninstallImpact,
17
+ } from "../types";
18
+ import type { MarketClient } from "./client";
19
+
3
20
  import { request } from "@oj-module/runtime";
4
- import { createHttpMarketClient } from "./client";
21
+ import {
22
+ bindRequest,
23
+ platformMarketEnablePost,
24
+ platformMarketHistoryGet,
25
+ platformMarketInstallPost,
26
+ platformMarketMetaGet,
27
+ platformMarketTaskGet,
28
+ platformMarketUninstallImpactGet,
29
+ platformMarketUninstallPost,
30
+ } from "../../_platform/__client/api";
31
+
32
+ // 绑定运行时 request 到生成 client 的模块级单槽(生成 client 是静态单例,必须由某处
33
+ // bindRequest;此处兜底确保市场子应用无论挂载顺序都能拿到请求实例)。与 _platform 模块
34
+ // entry 若再调用 create_platformClient(ctx) 幂等(绑定同实例)。
35
+ bindRequest(request as Parameters<typeof bindRequest>[0]);
36
+
37
+ /** ky 形态的最小请求子集(仅 index 直连 request 时用,get 返回带 .json<T>() 的响应) */
38
+ interface HttpGetLike {
39
+ get: <T = unknown>(
40
+ url: string,
41
+ // silentError:错误体由本客户端消化(未配置市场地址走空态),不弹全局 toast;
42
+ // retry: 0:503 是配置态而非抖动,默认重试 3 次只会刷出 4 条同样错误。
43
+ opts?: { searchParams?: Record<string, string>, silentError?: boolean, retry?: number },
44
+ ) => Promise<{ json: <R = T>() => Promise<R> }>
45
+ }
46
+
47
+ /** 与运行时/生成客户端一致的响应信封(DIP:服务端统一 {code,msg,data} 包裹) */
48
+ interface OjEnvelope<T> {
49
+ code: number
50
+ msg?: string
51
+ data?: T
52
+ }
53
+
54
+ // 第一方市场端点:相对前缀(无前导 /),由运行时 request 的 VITE_API_BASE_URL(/api)统一加前缀;
55
+ // 与生成 client(platformMarket* 用 `_platform/...`)保持一致,避免 /api 叠加成 /api/api/(R29)。
56
+ const MARKET = "_platform/market";
57
+
58
+ /**
59
+ * 503 = 服务端「未配置市场地址(framework.market_cdn_url)」,是**预期空态**而非故障
60
+ * (PRD §5.3:卡片区显示「未配置市场地址」)。模板默认不配市场地址,新工程一进市场页
61
+ * 就会撞它——在此归空数组,交由卡片空态文案承载;其它状态码照旧上抛。
62
+ */
63
+ function isUnconfigured(e: unknown): boolean {
64
+ return (e as { response?: { status?: number } } | null)?.response?.status === 503;
65
+ }
5
66
 
6
- /** 运行时 request 形态与 HttpRequest 兼容(ky get/post 返回带 .json<T>() 的响应) */
7
- export const httpMarketClient = createHttpMarketClient(request as unknown as Parameters<typeof createHttpMarketClient>[0]);
67
+ export const httpMarketClient: MarketClient = {
68
+ async meta() {
69
+ // 生成 client 已解包信封 + DEV 校验;此处仅对齐领域类型。
70
+ return (await platformMarketMetaGet()) as { apiVersion: string };
71
+ },
72
+ async index() {
73
+ // 唯一需 silentError 的端点:直连 request 抑制「未配置市场地址」的全局 toast。
74
+ try {
75
+ const r = await (request as unknown as HttpGetLike)
76
+ .get<OjEnvelope<IndexRow[]>>(`${MARKET}/index`, { silentError: true, retry: 0 });
77
+ const env = await r.json();
78
+ return env.data!;
79
+ }
80
+ catch (e) {
81
+ if (isUnconfigured(e))
82
+ return [];
83
+ throw e;
84
+ }
85
+ },
86
+ async install(input: InstallInput) {
87
+ const data = await platformMarketInstallPost({
88
+ name: input.name,
89
+ version: input.version ?? null,
90
+ source: input.source ?? "cdn",
91
+ });
92
+ return data as { task_id: string };
93
+ },
94
+ async task(id: string) {
95
+ // 生成 Data 为空 schema(仅 DEV catchall 校验),经 unknown 桥接领域类型;
96
+ // 运行时形状由服务端真源 + DEV zod 校验保证,TS 侧以领域类型为准。
97
+ return (await platformMarketTaskGet({ id })) as unknown as TaskRecord;
98
+ },
99
+ async enable(input: EnableInput) {
100
+ const data = await platformMarketEnablePost({ name: input.name, enabled: input.enabled });
101
+ return data as { ok: boolean, message: string };
102
+ },
103
+ async history() {
104
+ return (await platformMarketHistoryGet()) as unknown as MarketHistory[];
105
+ },
106
+ async uninstallImpact(name: string) {
107
+ return (await platformMarketUninstallImpactGet({ name })) as unknown as UninstallImpact;
108
+ },
109
+ async uninstall(name: string) {
110
+ const data = await platformMarketUninstallPost({ name });
111
+ return data as { ok: boolean, message: string };
112
+ },
113
+ };
@@ -11,6 +11,8 @@ export interface CardGridProps {
11
11
  error?: string | null
12
12
  rows: IndexRow[]
13
13
  busyName?: string | null
14
+ /** 是否具备管理权限(superadmin);透传给 CardItem 以禁用写操作。 */
15
+ canManage?: boolean
14
16
  onInstall: (row: IndexRow) => void
15
17
  onEnableToggle: (row: IndexRow) => void
16
18
  onDetails: (row: IndexRow) => void
@@ -23,7 +25,7 @@ export interface CardGridProps {
23
25
  /** 卡片网格容器:三态(加载/空/错误)+ 正常网格,DRY 渲染交由 CardItem */
24
26
  export function CardGrid(props: CardGridProps) {
25
27
  const { t } = useTranslation();
26
- const { loading, error, rows, busyName, onInstall, onEnableToggle, onDetails, onHistory, onUninstall, onRollback, onRetry } = props;
28
+ const { loading, error, rows, busyName, canManage, onInstall, onEnableToggle, onDetails, onHistory, onUninstall, onRollback, onRetry } = props;
27
29
 
28
30
  if (loading) {
29
31
  return (
@@ -63,6 +65,7 @@ export function CardGrid(props: CardGridProps) {
63
65
  key={row.name}
64
66
  row={row}
65
67
  busy={busyName === row.name}
68
+ canManage={canManage}
66
69
  onInstall={onInstall}
67
70
  onEnableToggle={onEnableToggle}
68
71
  onDetails={onDetails}
@@ -6,6 +6,8 @@ import { useTranslation } from "react-i18next";
6
6
  export interface CardItemProps {
7
7
  row: IndexRow
8
8
  busy?: boolean
9
+ /** 是否具备管理权限(superadmin)。false 时禁用一切写操作(安装/升级/启停/回滚/卸载),仅保留浏览。 */
10
+ canManage?: boolean
9
11
  onInstall: (row: IndexRow) => void
10
12
  onEnableToggle: (row: IndexRow) => void
11
13
  onDetails: (row: IndexRow) => void
@@ -19,12 +21,14 @@ export interface CardItemProps {
19
21
  * 双版本展示(P1-15b):已装且可升级时并列「已装 installed → 最新 latest」(upgradable 对比),
20
22
  * 否则单版本。已装方案提供回滚入口(RollbackDialog 选旧版降级安装)。
21
23
  */
22
- export function CardItem({ row, busy, onInstall, onEnableToggle, onDetails, onHistory, onUninstall, onRollback }: CardItemProps) {
24
+ export function CardItem({ row, busy, canManage = true, onInstall, onEnableToggle, onDetails, onHistory, onUninstall, onRollback }: CardItemProps) {
23
25
  const { t } = useTranslation();
24
26
  const installed = row.installed != null;
25
27
  const enabled = row.enabled === true;
26
28
  const disabled = row.enabled === false;
27
29
  const dualVersion = installed && row.upgradable && row.latest != null;
30
+ // 非 superadmin:所有写操作禁用并给出原因提示,只读(详情/历史)不受影响。
31
+ const manageTip = canManage ? undefined : t("oj-market:card:superOnly");
28
32
 
29
33
  return (
30
34
  <Card
@@ -72,21 +76,21 @@ export function CardItem({ row, busy, onInstall, onEnableToggle, onDetails, onHi
72
76
  )}
73
77
  </div>
74
78
  <Space wrap>
75
- <Button type="primary" size="small" loading={busy} onClick={() => onInstall(row)} disabled={busy}>
79
+ <Button type="primary" size="small" loading={busy} onClick={() => onInstall(row)} disabled={busy || !canManage} title={manageTip}>
76
80
  {installed ? t("oj-market:card:upgrade") : t("oj-market:card:install")}
77
81
  </Button>
78
82
  {installed && (
79
- <Button size="small" onClick={() => onEnableToggle(row)} disabled={busy}>
83
+ <Button size="small" onClick={() => onEnableToggle(row)} disabled={busy || !canManage} title={manageTip}>
80
84
  {enabled ? t("oj-market:card:disable") : t("oj-market:card:enable")}
81
85
  </Button>
82
86
  )}
83
87
  {installed && (
84
- <Button size="small" onClick={() => onRollback(row)} disabled={busy} data-testid={`card-rollback-${row.name}`}>
88
+ <Button size="small" onClick={() => onRollback(row)} disabled={busy || !canManage} title={manageTip} data-testid={`card-rollback-${row.name}`}>
85
89
  {t("oj-market:card:rollback")}
86
90
  </Button>
87
91
  )}
88
92
  {installed && (
89
- <Button size="small" danger onClick={() => onUninstall(row)} disabled={busy}>
93
+ <Button size="small" danger onClick={() => onUninstall(row)} disabled={busy || !canManage} title={manageTip}>
90
94
  {t("oj-market:card:uninstall")}
91
95
  </Button>
92
96
  )}
@@ -7,14 +7,18 @@ export interface DetailDrawerProps {
7
7
  row: IndexRow | null
8
8
  open: boolean
9
9
  onClose: () => void
10
+ /** 是否具备管理权限(superadmin);false 时禁用安装/回滚按钮,历史(只读)不受影响。 */
11
+ canManage?: boolean
10
12
  onInstall: (row: IndexRow) => void
11
13
  onHistory: (row: IndexRow) => void
12
14
  onRollback: (row: IndexRow) => void
13
15
  }
14
16
 
15
17
  /** 方案详情抽屉:只读展示卡片元信息(含双版本对比)+ 安装/回滚/历史入口 */
16
- export function DetailDrawer({ row, open, onClose, onInstall, onHistory, onRollback }: DetailDrawerProps) {
18
+ export function DetailDrawer({ row, open, onClose, canManage = true, onInstall, onHistory, onRollback }: DetailDrawerProps) {
17
19
  const { t } = useTranslation();
20
+ // 非 superadmin:写操作禁用并解释原因,历史(只读)保持可用。
21
+ const manageTip = canManage ? undefined : t("oj-market:card:superOnly");
18
22
  return (
19
23
  <Drawer
20
24
  title={row ? row.name : t("oj-market:card:details")}
@@ -36,11 +40,11 @@ export function DetailDrawer({ row, open, onClose, onInstall, onHistory, onRollb
36
40
  </Descriptions.Item>
37
41
  </Descriptions>
38
42
  <Space style={{ marginTop: 16 }}>
39
- <Button type="primary" onClick={() => onInstall(row)}>
43
+ <Button type="primary" onClick={() => onInstall(row)} disabled={!canManage} title={manageTip}>
40
44
  {row.installed ? t("oj-market:card:upgrade") : t("oj-market:card:install")}
41
45
  </Button>
42
46
  {row.installed != null && (
43
- <Button onClick={() => onRollback(row)} data-testid="detail-rollback">
47
+ <Button onClick={() => onRollback(row)} data-testid="detail-rollback" disabled={!canManage} title={manageTip}>
44
48
  {t("oj-market:card:rollback")}
45
49
  </Button>
46
50
  )}
@@ -53,7 +53,7 @@ export function InstallDialog({ row, open, onClose, onDone, version, mode }: Ins
53
53
  }, [open, row, started, client, targetVersion]);
54
54
 
55
55
  useEffect(() => {
56
- if (isTerminal(record) && done)
56
+ if (record && isTerminal(record) && done)
57
57
  onDone?.(record);
58
58
  }, [record, done, onDone]);
59
59
 
@@ -1,4 +1,5 @@
1
- import { defineModule } from "@oj-module/runtime";
1
+ import { defineModule, OutsidePageIcon } from "@oj-module/runtime";
2
+ import { createElement } from "react";
2
3
  import MarketApp from "./pages/MarketApp";
3
4
 
4
5
  // 方案市场前端模块(第一方专用,R29:消费 /api/_platform/market/* 编排面,第三方方案不得仿照此调用面)。
@@ -10,7 +11,12 @@ export default defineModule({
10
11
  routes: [
11
12
  {
12
13
  path: "/oj-market",
13
- handle: { layout: "container", order: 200, title: "oj-market:menu.main" },
14
+ handle: {
15
+ layout: "container",
16
+ order: 200,
17
+ title: "oj-market:menu.main",
18
+ icon: createElement(OutsidePageIcon),
19
+ },
14
20
  children: [
15
21
  {
16
22
  index: true,
@@ -4,7 +4,9 @@ import { useMarketClient } from "../api/context";
4
4
  import { poll } from "../utils/poll";
5
5
 
6
6
  /** 加载态三态(空态/加载/错误)统一建模,便于组件 DRY 渲染 */
7
- export type LoadState<T> = { status: "loading" } | { status: "ok", data: T } | { status: "error", error: string };
7
+ // 注:TS 6 收紧联合类型属性访问——属性须出现在所有成员上方可通过 .data 访问,
8
+ // 故非 ok 变体也声明 data?: undefined(ok 变体 data: T,访问结果为 T | undefined)。
9
+ export type LoadState<T> = { status: "loading", data?: undefined } | { status: "ok", data: T } | { status: "error", error: string, data?: undefined };
8
10
 
9
11
  /** 卡片索引加载(含重试);消费 GET /_platform/market/index */
10
12
  export function useMarketIndex(): LoadState<IndexRow[]> & { retry: () => void } {
@@ -58,6 +60,7 @@ export function useInstallTask(taskId: string | null): {
58
60
  progress: 0,
59
61
  message: e instanceof Error ? e.message : String(e),
60
62
  stages: [],
63
+ startedAt: 0,
61
64
  }))
62
65
  .finally(() => active && setDone(true));
63
66
  return () => {
@@ -37,7 +37,8 @@
37
37
  "enable": "Enable",
38
38
  "disable": "Disable",
39
39
  "upgrade": "Upgrade",
40
- "rollback": "Rollback"
40
+ "rollback": "Rollback",
41
+ "superOnly": "Only superadmins can install, upgrade, roll back, enable/disable or uninstall solutions; other users may browse only."
41
42
  },
42
43
  "task": {
43
44
  "installing": "Installing",
@@ -37,7 +37,8 @@
37
37
  "enable": "启用",
38
38
  "disable": "停用",
39
39
  "upgrade": "升级",
40
- "rollback": "回滚"
40
+ "rollback": "回滚",
41
+ "superOnly": "仅超级管理员可安装、升级、回滚、启停或卸载方案;其余用户仅可浏览。"
41
42
  },
42
43
  "task": {
43
44
  "installing": "安装中",