@oj-module/cli 0.2.66 → 0.2.68

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 (200) hide show
  1. package/bin/.oj-version +1 -0
  2. package/bin/oj +4 -0
  3. package/devkit/README.md +15 -7
  4. package/devkit/SKILL.md +38 -4
  5. package/devkit/framework-dev.md +10 -4
  6. package/devkit/manual.md +146 -24
  7. package/devkit/menu-rules.md +19 -4
  8. package/devkit/module-dev-and-cdn-playground.md +4 -0
  9. package/devkit/publish-walkthrough.md +132 -37
  10. package/devkit/wecom-internal-app.md +8 -3
  11. package/devkit/wecom-multi-corp-links.md +163 -0
  12. package/package.json +2 -2
  13. package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
  14. package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.1xzahckt10.js} +4863 -4847
  15. package/shell-dist/index.html +4 -4
  16. package/shell-dist/versions.json +3 -3
  17. package/src/__tests__/cli.test.ts +31 -0
  18. package/src/__tests__/new-scaffold.test.ts +13 -0
  19. package/src/__tests__/publish-keygen.test.ts +157 -0
  20. package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
  21. package/src/__tests__/templates-schema-columns.test.ts +15 -2
  22. package/src/__tests__/templates-tenant-scope.test.ts +6 -2
  23. package/src/build/module-def.ts +1 -0
  24. package/src/builtin.ts +3 -2
  25. package/src/bundle.ts +8 -3
  26. package/src/cli.ts +21 -5
  27. package/src/dev.ts +15 -10
  28. package/src/init.ts +47 -41
  29. package/src/install.ts +11 -4
  30. package/src/keygen.ts +54 -0
  31. package/src/lifecycle/__tests__/bundle.test.ts +21 -3
  32. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
  33. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
  34. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
  35. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
  36. package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
  37. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  38. package/src/manifest-contract.ts +91 -8
  39. package/src/market/bootstrap.ts +26 -2
  40. package/src/market/command.ts +1 -0
  41. package/src/market/publish.ts +8 -1
  42. package/src/market/walkthrough.ts +24 -8
  43. package/src/new.ts +2 -0
  44. package/src/scaffold-guard.ts +227 -0
  45. package/src/site.ts +2 -0
  46. package/src/usage.ts +13 -9
  47. package/src/vendor.ts +5 -1
  48. package/templates/config.yaml +4 -2
  49. package/templates/init.include.yaml +10 -3
  50. package/templates/openapi.json +460 -1
  51. package/templates/src/README.md +37 -0
  52. package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
  53. package/templates/src/app/api/_platform/_lifecycle/installer.ts +78 -25
  54. package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
  55. package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
  56. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
  57. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
  58. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
  59. package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
  60. package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
  61. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
  62. package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
  63. package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +25 -10
  64. package/templates/src/app/api/_platform/manifest.yaml +1 -0
  65. package/templates/src/app/api/_platform/market/api.ts +235 -10
  66. package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
  67. package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
  68. package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
  69. package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
  70. package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
  71. package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
  72. package/templates/src/app/api/_platform/schema.yaml +18 -0
  73. package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
  74. package/templates/src/app/api/_platform/seed.pg.sql +10 -0
  75. package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
  76. package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
  77. package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
  78. package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
  79. package/templates/src/app/api/home/seed.mysql.sql +71 -0
  80. package/templates/src/app/api/home/seed.pg.sql +71 -0
  81. package/templates/src/app/api/notification/_shared/notify.ts +6 -6
  82. package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
  83. package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
  84. package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
  85. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
  86. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
  87. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
  88. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
  89. package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
  90. package/templates/src/app/api/notification/seed.mysql.sql +8 -0
  91. package/templates/src/app/api/notification/seed.pg.sql +11 -0
  92. package/templates/src/app/api/publish/_shared/identity.ts +69 -0
  93. package/templates/src/app/api/publish/api.ts +349 -0
  94. package/templates/src/app/api/publish/manifest.yaml +8 -0
  95. package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
  96. package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
  97. package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
  98. package/templates/src/app/api/publish/schema.yaml +33 -0
  99. package/templates/src/app/api/system/login-methods/api.ts +2 -1
  100. package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
  101. package/templates/src/app/web/_platform/__client/index.ts +25 -1
  102. package/templates/src/app/web/entry.ts +9 -5
  103. package/templates/src/app/web/home/routes.ts +5 -0
  104. package/templates/src/app/web/login/locales/en-US.json +7 -1
  105. package/templates/src/app/web/login/locales/zh-CN.json +7 -1
  106. package/templates/src/app/web/login/pages/login.tsx +79 -86
  107. package/templates/src/app/web/login/routes.ts +54 -1
  108. package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
  109. package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
  110. package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
  111. package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
  112. package/templates/src/app/web/system/locales/en-US.json +1 -8
  113. package/templates/src/app/web/system/locales/zh-CN.json +1 -8
  114. package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
  115. package/templates/src/app/web/system/routes.ts +0 -6
  116. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
  117. package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
  118. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
  119. package/templates/src/oj-market/web/api/client.ts +3 -0
  120. package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
  121. package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
  122. package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
  123. package/templates/src/oj-market/web/entry.ts +31 -2
  124. package/templates/src/oj-market/web/locales/en-US.json +5 -1
  125. package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
  126. package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
  127. package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
  128. package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
  129. package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
  130. package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
  131. package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
  132. package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
  133. package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
  134. package/templates/src/oj-market/web/publish/routes.ts +41 -0
  135. package/templates/src/oj-market/web/types.ts +30 -0
  136. package/templates/tests/lifecycle/compensate.spec.ts +207 -0
  137. package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
  138. package/templates/tests/lifecycle/installer.spec.ts +28 -9
  139. package/templates/tests/lifecycle/market-api.spec.ts +164 -12
  140. package/templates/tests/lifecycle/preflight.spec.ts +68 -0
  141. package/templates/tests/lifecycle/register.spec.ts +22 -0
  142. package/templates/tests/lifecycle/role-title-translate.spec.ts +4 -34
  143. package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
  144. package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
  145. package/templates/tests/publish-credential.spec.ts +354 -0
  146. package/templates/tests/publish-identity.spec.ts +44 -0
  147. package/templates/tests/rbac-solution-perms.spec.ts +48 -3
  148. package/templates/tests/wecom-client-timeout.spec.ts +33 -3
  149. package/templates/tests/wecom-landing.spec.ts +63 -1
  150. package/templates/tsconfig.paths.json +1 -0
  151. package/templates/web.config.ts +8 -4
  152. package/vendor/host-versions.json +3 -3
  153. package/templates/seed.mysql.sql +0 -6
  154. package/templates/seed.pg.sql +0 -6
  155. package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
  156. package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
  157. package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
  158. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
  159. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
  160. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
  161. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
  162. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
  163. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
  164. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
  165. package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
  166. package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
  167. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
  168. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
  169. package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
  170. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
  171. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
  172. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
  173. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
  174. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
  175. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
  176. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
  177. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
  178. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
  179. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
  180. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
  181. package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
  182. package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
  183. package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
  184. package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
  185. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
  186. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
  187. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
  188. package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
  189. package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
  190. package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
  191. package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
  192. package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
  193. package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
  194. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
  195. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
  196. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
  197. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
  198. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
  199. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
  200. package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
@@ -0,0 +1,164 @@
1
+ /* eslint-disable */
2
+ // 生成物:ojm api 从契约生成,勿手改(改动请改契约文件后重跑 ojm api)
3
+ import { ContractApiError } from "@oj-module/runtime/contract/errors";
4
+ import type { ScopedRequestLike } from "@oj-module/runtime/contract/errors";
5
+ import type { ModuleContext } from "@oj-module/runtime";
6
+ import type { z } from "@oj-module/runtime";
7
+ import type { schemas } from "./api.schemas";
8
+
9
+ /** oj 信封(AC-D16):code=0 成功;非 0 时 HTTP status=code,由 toApiError 归一为 ContractApiError */
10
+ interface OjEnvelope<T> { code: number, msg?: string, data?: T }
11
+
12
+ let req: ScopedRequestLike | undefined;
13
+
14
+ /** 线上 API 前缀(ojm api 从契约抽取,唯一真源,勿手改):请求基址 + 前缀登记用 */
15
+ export const API_PREFIX = "/publish";
16
+
17
+ /** 认领身份前缀(契约 apiPrefix,字面等于模块目录名):跨模块认领护栏对比基准 */
18
+ export const MODULE_PREFIX = "/publish";
19
+
20
+ /** 认领方模块(web.config clientOwners 指定):跨模块认领护栏对比基准 */
21
+ export const MODULE_OWNER = "/oj-market";
22
+
23
+ /**
24
+ * 模块 entry 的 onInit 里一行创建(构造即 install):完成 apiPrefix 登记 +
25
+ * scoped request 绑定,返回绑好前缀的端点函数集合。一模块一 client;
26
+ * 重复构造幂等安全(前缀重复登记同值、request 重绑同值)。
27
+ */
28
+ export function createPublishClient(ctx: ModuleContext) {
29
+ // 跨模块认领护栏(DEV):工厂知道自己该属于谁(MODULE_PREFIX = 契约 apiPrefix),
30
+ // ctx 又带 module.name——名实不符说明别的模块在替本模块创建 client,会共享 request
31
+ // 单槽、卸载互相耦合;跨模块需求应由本模块创建后经 provider 暴露。
32
+ // 注意对比基准是 MODULE_PREFIX(目录名)而非 API_PREFIX——D-M8 映射时线上前缀
33
+ // 属于别的命名空间,认领身份仍按目录名。
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 暴露。`);
36
+ }
37
+ ctx.register.apiPrefix(API_PREFIX);
38
+ bindRequest(ctx.utils.request);
39
+ return { publishCredentialGet, publishCredentialPost, publishCredentialRevokePost, publishCredentialExchangePost };
40
+ }
41
+
42
+ /**
43
+ * @deprecated 请改用 createPublishClient(ctx)——工厂构造即完成
44
+ * 前缀登记 + request 绑定,不会出现「登记了前缀没绑 request」的半接线状态。
45
+ */
46
+ export function bindRequest(r: ScopedRequestLike): void {
47
+ req = r;
48
+ }
49
+
50
+ function ensureReq(): ScopedRequestLike {
51
+ if (!req)
52
+ throw new ContractApiError(-1, "[ojm-api] 请求未绑定——请在模块 entry.ts 的 onInit 里调用 createPublishClient(ctx)(或 bindRequest(ctx.utils.request))。");
53
+ return req;
54
+ }
55
+
56
+ /** ky HTTPError → ContractApiError(错误体为信封时取 code/msg);契约违例与原错误原样透传 */
57
+ async function toApiError(e: unknown): Promise<unknown> {
58
+ if (e instanceof ContractApiError)
59
+ return e;
60
+ // ky 2.x 构造 HTTPError 时已用 getReader() 消费掉原 response 体(res.clone().json() 按规范抛 TypeError,
61
+ // 体方法一律失效),解析好的错误体在公开字段 error.data——故读 error.data,勿改回 clone().json()。
62
+ const err = e as { data?: { code?: unknown, msg?: string }, response?: Response } | null;
63
+ const res = err?.response;
64
+ const d = err?.data;
65
+ if (res instanceof Response && d && typeof d.code === "number")
66
+ return new ContractApiError(d.code, d.msg ?? res.statusText);
67
+ return e;
68
+ }
69
+
70
+ export type PublishCredentialGetQuery = z.input<(typeof schemas)["publishCredentialGet"]["query"]>;
71
+ export type PublishCredentialGetData = z.infer<(typeof schemas)["publishCredentialGet"]["data"]>;
72
+
73
+ export async function publishCredentialGet(query: PublishCredentialGetQuery): Promise<PublishCredentialGetData> {
74
+ const client = ensureReq();
75
+ try {
76
+ const env = await client.get(`publish/credential`, { searchParams: query as Record<string, string | number | boolean> }).json<OjEnvelope<PublishCredentialGetData>>();
77
+ // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
78
+ if (typeof env.code === "number" && env.code !== 0)
79
+ throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
80
+ const data = env.data as PublishCredentialGetData;
81
+ if (import.meta.env.DEV) {
82
+ const { schemas } = await import("./api.schemas");
83
+ const r = schemas["publishCredentialGet"].data.safeParse(data);
84
+ if (!r.success)
85
+ throw new ContractApiError(-1, `[契约违例] publishCredentialGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
86
+ }
87
+ return data;
88
+ }
89
+ catch (e) {
90
+ throw await toApiError(e);
91
+ }
92
+ }
93
+
94
+ export type PublishCredentialPostBody = z.input<(typeof schemas)["publishCredentialPost"]["body"]>;
95
+ export type PublishCredentialPostData = z.infer<(typeof schemas)["publishCredentialPost"]["data"]>;
96
+
97
+ export async function publishCredentialPost(body: PublishCredentialPostBody): Promise<PublishCredentialPostData> {
98
+ const client = ensureReq();
99
+ try {
100
+ const env = await client.post(`publish/credential`, { json: body }).json<OjEnvelope<PublishCredentialPostData>>();
101
+ // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
102
+ if (typeof env.code === "number" && env.code !== 0)
103
+ throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
104
+ const data = env.data as PublishCredentialPostData;
105
+ if (import.meta.env.DEV) {
106
+ const { schemas } = await import("./api.schemas");
107
+ const r = schemas["publishCredentialPost"].data.safeParse(data);
108
+ if (!r.success)
109
+ throw new ContractApiError(-1, `[契约违例] publishCredentialPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
110
+ }
111
+ return data;
112
+ }
113
+ catch (e) {
114
+ throw await toApiError(e);
115
+ }
116
+ }
117
+
118
+ export type PublishCredentialRevokePostBody = z.input<(typeof schemas)["publishCredentialRevokePost"]["body"]>;
119
+ export type PublishCredentialRevokePostData = z.infer<(typeof schemas)["publishCredentialRevokePost"]["data"]>;
120
+
121
+ export async function publishCredentialRevokePost(body: PublishCredentialRevokePostBody): Promise<PublishCredentialRevokePostData> {
122
+ const client = ensureReq();
123
+ try {
124
+ const env = await client.post(`publish/credential/revoke`, { json: body }).json<OjEnvelope<PublishCredentialRevokePostData>>();
125
+ // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
126
+ if (typeof env.code === "number" && env.code !== 0)
127
+ throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
128
+ const data = env.data as PublishCredentialRevokePostData;
129
+ if (import.meta.env.DEV) {
130
+ const { schemas } = await import("./api.schemas");
131
+ const r = schemas["publishCredentialRevokePost"].data.safeParse(data);
132
+ if (!r.success)
133
+ throw new ContractApiError(-1, `[契约违例] publishCredentialRevokePost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
134
+ }
135
+ return data;
136
+ }
137
+ catch (e) {
138
+ throw await toApiError(e);
139
+ }
140
+ }
141
+
142
+ export type PublishCredentialExchangePostBody = z.input<(typeof schemas)["publishCredentialExchangePost"]["body"]>;
143
+ export type PublishCredentialExchangePostData = z.infer<(typeof schemas)["publishCredentialExchangePost"]["data"]>;
144
+
145
+ export async function publishCredentialExchangePost(body: PublishCredentialExchangePostBody): Promise<PublishCredentialExchangePostData> {
146
+ const client = ensureReq();
147
+ try {
148
+ const env = await client.post(`publish/credential/exchange`, { json: body }).json<OjEnvelope<PublishCredentialExchangePostData>>();
149
+ // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
150
+ if (typeof env.code === "number" && env.code !== 0)
151
+ throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
152
+ const data = env.data as PublishCredentialExchangePostData;
153
+ if (import.meta.env.DEV) {
154
+ const { schemas } = await import("./api.schemas");
155
+ const r = schemas["publishCredentialExchangePost"].data.safeParse(data);
156
+ if (!r.success)
157
+ throw new ContractApiError(-1, `[契约违例] publishCredentialExchangePost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
158
+ }
159
+ return data;
160
+ }
161
+ catch (e) {
162
+ throw await toApiError(e);
163
+ }
164
+ }
@@ -0,0 +1,80 @@
1
+ {
2
+ "menu": {
3
+ "publish": "Publishing",
4
+ "history": "Release history"
5
+ },
6
+ "history": {
7
+ "title": "Release history",
8
+ "subtitle": "Every release recorded in the signed index, newest first. Product, version and publisher all come from the index itself.",
9
+ "empty": "No releases yet",
10
+ "syncFailed": "The market index is temporarily unreachable; showing records already stored",
11
+ "indexTime": "index time",
12
+ "publishedAtHint": "Uses the CDN file's Last-Modified when available; otherwise falls back to the index build time, tagged “index time”",
13
+ "total": "{{total}} in total",
14
+ "columns": {
15
+ "product": "Product",
16
+ "version": "Version",
17
+ "publisher": "Publisher key",
18
+ "publishedAt": "Published at",
19
+ "status": "Status"
20
+ },
21
+ "status": {
22
+ "active": "Available",
23
+ "yanked": "Yanked"
24
+ }
25
+ },
26
+ "page": {
27
+ "title": "Publishing",
28
+ "subtitle": "A publishing credential is single-use: redeeming it mints a long-lived publishing identity (key_id).",
29
+ "apply": "Request credential",
30
+ "empty": "No publishing credential yet",
31
+ "total": "{{total}} in total"
32
+ },
33
+ "apply": {
34
+ "title": "Request publishing credential",
35
+ "product": "Product",
36
+ "productExtra": "The solution name you are publishing (lowercase, a-z0-9- only). Identity = product + your username; it cannot be changed later.",
37
+ "productRequired": "Product is required",
38
+ "ok": "Generate",
39
+ "cancel": "Cancel"
40
+ },
41
+ "issued": {
42
+ "title": "Credential created (shown once)",
43
+ "warning": "You will not be able to view it again. Copy it now, or copy the command below.",
44
+ "token": "Credential",
45
+ "command": "Command",
46
+ "copy": "Copy",
47
+ "copied": "Copied",
48
+ "identity": "Identity",
49
+ "expires": "Valid until",
50
+ "done": "Saved"
51
+ },
52
+ "columns": {
53
+ "product": "Product",
54
+ "keyId": "Identity",
55
+ "applicant": "Applicant",
56
+ "tokenHint": "Credential",
57
+ "status": "Status",
58
+ "createdAt": "Requested",
59
+ "usedAt": "Redeemed",
60
+ "expiresAt": "Valid until",
61
+ "tenant": "Tenant",
62
+ "actions": "Actions"
63
+ },
64
+ "status": {
65
+ "active": "Pending",
66
+ "used": "Redeemed",
67
+ "revoked": "Revoked",
68
+ "expired": "Expired"
69
+ },
70
+ "revoke": {
71
+ "action": "Revoke",
72
+ "confirm": "Revoking invalidates this credential immediately. Continue?",
73
+ "ok": "Revoke",
74
+ "done": "Revoked"
75
+ },
76
+ "filter": {
77
+ "tenant": "Filter by tenant",
78
+ "tenantPlaceholder": "Leave empty for all tenants"
79
+ }
80
+ }
@@ -0,0 +1,80 @@
1
+ {
2
+ "menu": {
3
+ "publish": "发布管理",
4
+ "history": "发布历史"
5
+ },
6
+ "history": {
7
+ "title": "发布历史",
8
+ "subtitle": "签名索引里出现过的每一次发版:产品、版本与发布身份都来自索引本身,按发布时间倒序。",
9
+ "empty": "还没有发布记录",
10
+ "syncFailed": "暂时读不到市场索引,下面是已入库的历史记录",
11
+ "indexTime": "索引时间",
12
+ "publishedAtHint": "优先取分发包落 CDN 的时间(Last-Modified);源不提供时用索引生成时间,会标注「索引时间」",
13
+ "total": "共 {{total}} 条",
14
+ "columns": {
15
+ "product": "产品名",
16
+ "version": "版本",
17
+ "publisher": "发布身份",
18
+ "publishedAt": "发布时间",
19
+ "status": "状态"
20
+ },
21
+ "status": {
22
+ "active": "在架",
23
+ "yanked": "已下线"
24
+ }
25
+ },
26
+ "page": {
27
+ "title": "发布管理",
28
+ "subtitle": "发布凭证是一次性令牌:兑换后换到一个长期有效的发布身份(key_id),凭证本身随即作废。",
29
+ "apply": "申请发布凭证",
30
+ "empty": "还没有申请过发布凭证",
31
+ "total": "共 {{total}} 条"
32
+ },
33
+ "apply": {
34
+ "title": "申请发布凭证",
35
+ "product": "产品名",
36
+ "productExtra": "要发布的解决方案名(小写字母开头,仅 a-z、0-9、-)。发布身份 = 产品名.你的用户名,申请后不可更改。",
37
+ "productRequired": "请填写产品名",
38
+ "ok": "生成凭证",
39
+ "cancel": "取消"
40
+ },
41
+ "issued": {
42
+ "title": "凭证已生成(只显示这一次)",
43
+ "warning": "关闭后无法再次查看明文,只能重新申请。请先复制凭证或下方命令。",
44
+ "identity": "发布身份",
45
+ "token": "凭证",
46
+ "command": "在本机执行",
47
+ "expires": "有效期至",
48
+ "copy": "复制",
49
+ "copied": "已复制",
50
+ "done": "我已保存"
51
+ },
52
+ "columns": {
53
+ "product": "产品名",
54
+ "keyId": "发布身份",
55
+ "applicant": "申请人",
56
+ "tokenHint": "凭证标识",
57
+ "status": "状态",
58
+ "createdAt": "申请时间",
59
+ "usedAt": "兑换时间",
60
+ "expiresAt": "有效期至",
61
+ "tenant": "归属租户",
62
+ "actions": "操作"
63
+ },
64
+ "status": {
65
+ "active": "待兑换",
66
+ "used": "已兑换",
67
+ "revoked": "已撤销",
68
+ "expired": "已过期"
69
+ },
70
+ "revoke": {
71
+ "action": "撤销",
72
+ "confirm": "撤销后该凭证立刻作废,确定?",
73
+ "ok": "撤销",
74
+ "done": "已撤销"
75
+ },
76
+ "filter": {
77
+ "tenant": "按租户筛选",
78
+ "tenantPlaceholder": "留空查看全部租户"
79
+ }
80
+ }
@@ -0,0 +1,112 @@
1
+ import type { ReleaseRow } from "../../types";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { Alert, Space, Table, Tag, Tooltip, Typography } from "antd";
4
+ import dayjs from "dayjs";
5
+ import { useState } from "react";
6
+ import { useTranslation } from "react-i18next";
7
+ import { useMarketClient } from "../../api/context";
8
+
9
+ const DEFAULT_PAGE_SIZE = 20;
10
+ const PAGE_SIZE_OPTIONS = [20, 50, 100];
11
+
12
+ /** 秒级 epoch(服务端统一存秒)→ 本地时间;缺值显示 - */
13
+ function formatSecs(secs: number | null | undefined): string {
14
+ return secs == null ? "-" : dayjs.unix(secs).format("YYYY-MM-DD HH:mm");
15
+ }
16
+
17
+ /**
18
+ * 发布历史页。
19
+ *
20
+ * 数据真相在服务端:每次打开都由后端先与签名索引对账(新版本补进来,已入库行不改时间),
21
+ * 前端只做分页展示——不在浏览器里重算「谁先谁后」,避免两处排序口径分叉。
22
+ *
23
+ * 发布时间优先取分发包落 CDN 的 Last-Modified;源不提供时回落索引生成时间,
24
+ * 这种情况标一个「索引时间」,让读者知道它不是精确落包时刻。
25
+ */
26
+ export default function ReleaseHistoryPage() {
27
+ const { t } = useTranslation();
28
+ const client = useMarketClient();
29
+ const [page, setPage] = useState(1);
30
+ const [size, setSize] = useState(DEFAULT_PAGE_SIZE);
31
+
32
+ const query = useQuery({
33
+ queryKey: ["market", "releases", page, size],
34
+ queryFn: () => client.releases({ page, limit: size }),
35
+ });
36
+
37
+ const rows = query.data?.list ?? [];
38
+
39
+ const columns = [
40
+ { title: t("publish:history.columns.product"), dataIndex: "product" },
41
+ { title: t("publish:history.columns.version"), dataIndex: "version" },
42
+ {
43
+ title: t("publish:history.columns.publisher"),
44
+ dataIndex: "key_id",
45
+ render: (value: string | null) => value ?? "-",
46
+ },
47
+ {
48
+ title: (
49
+ <Tooltip title={t("publish:history.publishedAtHint")}>
50
+ <span>{t("publish:history.columns.publishedAt")}</span>
51
+ </Tooltip>
52
+ ),
53
+ dataIndex: "published_at",
54
+ render: (value: number, row: ReleaseRow) => (
55
+ <Space size={4}>
56
+ <span>{formatSecs(value)}</span>
57
+ {row.published_at_source === "index" && <Tag color="default">{t("publish:history.indexTime")}</Tag>}
58
+ </Space>
59
+ ),
60
+ },
61
+ {
62
+ title: t("publish:history.columns.status"),
63
+ dataIndex: "yanked",
64
+ render: (value: number) => (
65
+ <Tag color={value === 1 ? "default" : "green"}>
66
+ {value === 1 ? t("publish:history.status.yanked") : t("publish:history.status.active")}
67
+ </Tag>
68
+ ),
69
+ },
70
+ ];
71
+
72
+ return (
73
+ <Space direction="vertical" style={{ width: "100%", paddingTop: 16 }} size="middle">
74
+ <Space direction="vertical" size={4}>
75
+ <Typography.Title level={4} style={{ margin: 0 }}>
76
+ {t("publish:history.title")}
77
+ </Typography.Title>
78
+ <Typography.Text type="secondary">{t("publish:history.subtitle")}</Typography.Text>
79
+ </Space>
80
+
81
+ {query.data?.syncError != null && (
82
+ <Alert
83
+ type="warning"
84
+ showIcon
85
+ message={t("publish:history.syncFailed")}
86
+ description={query.data.syncError}
87
+ />
88
+ )}
89
+
90
+ <Table<ReleaseRow>
91
+ rowKey="id"
92
+ size="middle"
93
+ columns={columns}
94
+ dataSource={rows}
95
+ loading={query.isLoading}
96
+ locale={{ emptyText: t("publish:history.empty") }}
97
+ pagination={{
98
+ current: page,
99
+ pageSize: size,
100
+ total: query.data?.total ?? 0,
101
+ showSizeChanger: true,
102
+ pageSizeOptions: PAGE_SIZE_OPTIONS,
103
+ showTotal: total => t("publish:history.total", { total }),
104
+ onChange: (nextPage, nextSize) => {
105
+ setPage(nextPage);
106
+ setSize(nextSize);
107
+ },
108
+ }}
109
+ />
110
+ </Space>
111
+ );
112
+ }
@@ -0,0 +1,196 @@
1
+ import type { PublishCredentialGetData } from "../__client";
2
+ import type { CredentialStatus } from "./model";
3
+ import type { IssuedCredential } from "./token-modal";
4
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { App, Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from "antd";
6
+
7
+ import dayjs from "dayjs";
8
+ import { useState } from "react";
9
+ import { useTranslation } from "react-i18next";
10
+ import { publishCredentialGet, publishCredentialPost, publishCredentialRevokePost } from "../__client";
11
+ import { canRevoke, errorText, statusColor } from "./model";
12
+ import { TokenModal } from "./token-modal";
13
+
14
+ type CredentialRow = PublishCredentialGetData["list"][number];
15
+
16
+ const DEFAULT_PAGE_SIZE = 20;
17
+ const PAGE_SIZE_OPTIONS = [20, 50, 100];
18
+
19
+ /** 秒级 epoch(后端统一存秒,不是毫秒——故用 dayjs.unix 而非 dayjs()) */
20
+ function formatSecs(secs: number | null | undefined): string {
21
+ return secs == null ? "-" : dayjs.unix(secs).format("YYYY-MM-DD HH:mm");
22
+ }
23
+
24
+ /**
25
+ * 发布管理页。
26
+ *
27
+ * 页面的职责只有三件事:列凭证、申请、撤销。所有推导都留在服务端
28
+ * (`key_id` 在申请时算好、`status` 由 used/revoked/expiry 三列统一判定),
29
+ * 前端不重复算——否则「页面显示已过期、后端仍可兑换」这类分叉迟早出现。
30
+ *
31
+ * 可见范围同样由服务端决定:普通用户只拿到自己的;超管拿到全部,响应里多一个
32
+ * `owner_tenant` 字段。前端据「数据里有没有这个字段」决定是否显示租户列与租户筛选,
33
+ * 不去单独查一次「我是谁」——少一个请求,也少一处会过期的判断。
34
+ */
35
+ export default function PublishPage() {
36
+ const { t } = useTranslation();
37
+ const { message } = App.useApp();
38
+ const queryClient = useQueryClient();
39
+ const [page, setPage] = useState(1);
40
+ const [size, setSize] = useState(DEFAULT_PAGE_SIZE);
41
+ const [tenant, setTenant] = useState("");
42
+ const [tenantInput, setTenantInput] = useState("");
43
+ const [applyOpen, setApplyOpen] = useState(false);
44
+ const [issued, setIssued] = useState<IssuedCredential | null>(null);
45
+ const [form] = Form.useForm<{ product: string }>();
46
+
47
+ const query = useQuery({
48
+ queryKey: ["publish", "credential", page, size, tenant],
49
+ queryFn: () => publishCredentialGet({ page, limit: size, tenant: tenant || undefined }),
50
+ });
51
+
52
+ const apply = useMutation({
53
+ mutationFn: (product: string) => publishCredentialPost({ product }),
54
+ onSuccess: (r) => {
55
+ setApplyOpen(false);
56
+ form.resetFields();
57
+ // 明文只在响应里:先落进弹窗,再刷新列表(刷新失败也不该丢掉用户手里这张凭证)
58
+ setIssued(r);
59
+ void queryClient.invalidateQueries({ queryKey: ["publish", "credential"] });
60
+ },
61
+ onError: e => message.error(errorText(e)),
62
+ });
63
+
64
+ const revoke = useMutation({
65
+ mutationFn: (id: number) => publishCredentialRevokePost({ id }),
66
+ onSuccess: () => {
67
+ message.success(t("publish:revoke.done"));
68
+ void queryClient.invalidateQueries({ queryKey: ["publish", "credential"] });
69
+ },
70
+ onError: e => message.error(errorText(e)),
71
+ });
72
+
73
+ const rows = query.data?.list ?? [];
74
+ // 超管专属字段出现即说明当前账号看得见全部租户;已在筛选时也保持入口可见(否则无法清空筛选)
75
+ const seesTenants = tenant !== "" || rows.some(row => row.owner_tenant != null);
76
+
77
+ const columns = [
78
+ { title: t("publish:columns.product"), dataIndex: "product" },
79
+ {
80
+ title: t("publish:columns.keyId"),
81
+ dataIndex: "key_id",
82
+ ellipsis: { showTitle: false },
83
+ render: (keyId: string) => <Tooltip title={keyId}>{keyId}</Tooltip>,
84
+ },
85
+ {
86
+ title: t("publish:columns.applicant"),
87
+ dataIndex: "username",
88
+ render: (username: string, row: CredentialRow) => (row.display_name ? `${row.display_name}(${username})` : username),
89
+ },
90
+ {
91
+ title: t("publish:columns.tokenHint"),
92
+ dataIndex: "token_hint",
93
+ render: (hint: string) => (
94
+ <Typography.Text code>
95
+ {hint}
96
+ …
97
+ </Typography.Text>
98
+ ),
99
+ },
100
+ {
101
+ title: t("publish:columns.status"),
102
+ dataIndex: "status",
103
+ render: (status: CredentialStatus) => <Tag color={statusColor(status)}>{t(`publish:status.${status}`)}</Tag>,
104
+ },
105
+ { title: t("publish:columns.createdAt"), dataIndex: "created_at", render: (v: number) => formatSecs(v) },
106
+ { title: t("publish:columns.usedAt"), dataIndex: "used_at", render: (v: number | null) => formatSecs(v) },
107
+ { title: t("publish:columns.expiresAt"), dataIndex: "expires_at", render: (v: number) => formatSecs(v) },
108
+ ...(seesTenants ? [{ title: t("publish:columns.tenant"), dataIndex: "owner_tenant" }] : []),
109
+ {
110
+ title: t("publish:columns.actions"),
111
+ dataIndex: "id",
112
+ render: (_: number, row: CredentialRow) => (canRevoke(row.status)
113
+ ? (
114
+ <Popconfirm title={t("publish:revoke.confirm")} okText={t("publish:revoke.ok")} onConfirm={() => revoke.mutate(row.id)}>
115
+ <Button type="link" danger size="small" loading={revoke.isPending}>{t("publish:revoke.action")}</Button>
116
+ </Popconfirm>
117
+ )
118
+ : <Typography.Text type="secondary">-</Typography.Text>),
119
+ },
120
+ ];
121
+
122
+ return (
123
+ <Space direction="vertical" style={{ width: "100%", paddingTop: 16 }}>
124
+ <Space style={{ width: "100%", justifyContent: "space-between" }} wrap>
125
+ <Typography.Text type="secondary">{t("publish:page.subtitle")}</Typography.Text>
126
+ <Space>
127
+ {seesTenants && (
128
+ <Input.Search
129
+ data-testid="publish-tenant-filter"
130
+ allowClear
131
+ placeholder={t("publish:filter.tenantPlaceholder")}
132
+ value={tenantInput}
133
+ onChange={e => setTenantInput(e.target.value)}
134
+ onSearch={(value) => {
135
+ setTenant(value.trim());
136
+ setPage(1);
137
+ }}
138
+ style={{ width: 240 }}
139
+ />
140
+ )}
141
+ <Button type="primary" data-testid="publish-apply" onClick={() => setApplyOpen(true)}>{t("publish:page.apply")}</Button>
142
+ </Space>
143
+ </Space>
144
+ <Table
145
+ rowKey="id"
146
+ loading={query.isLoading}
147
+ dataSource={rows}
148
+ columns={columns}
149
+ locale={{ emptyText: t("publish:page.empty") }}
150
+ pagination={{
151
+ current: page,
152
+ pageSize: size,
153
+ total: query.data?.total ?? 0,
154
+ showSizeChanger: true,
155
+ pageSizeOptions: PAGE_SIZE_OPTIONS,
156
+ showTotal: total => t("publish:page.total", { total }),
157
+ onChange: (nextPage, nextSize) => {
158
+ // 改每页条数后原页码可能越界:统一回第 1 页(与操作日志页同口径)
159
+ if (nextSize !== size) {
160
+ setSize(nextSize);
161
+ setPage(1);
162
+ return;
163
+ }
164
+ setPage(nextPage);
165
+ },
166
+ }}
167
+ />
168
+
169
+ <Modal
170
+ open={applyOpen}
171
+ title={t("publish:apply.title")}
172
+ confirmLoading={apply.isPending}
173
+ okText={t("publish:apply.ok")}
174
+ cancelText={t("publish:apply.cancel")}
175
+ onCancel={() => setApplyOpen(false)}
176
+ onOk={() => form.submit()}
177
+ >
178
+ <Form form={form} layout="vertical" onFinish={v => apply.mutate(v.product)}>
179
+ <Form.Item
180
+ name="product"
181
+ label={t("publish:apply.product")}
182
+ extra={t("publish:apply.productExtra")}
183
+ rules={[{ required: true, message: t("publish:apply.productRequired") }]}
184
+ >
185
+ <Input data-testid="publish-product" placeholder="demo-solution" autoFocus />
186
+ </Form.Item>
187
+ </Form>
188
+ </Modal>
189
+
190
+ <TokenModal
191
+ issued={issued}
192
+ onClose={() => setIssued(null)}
193
+ />
194
+ </Space>
195
+ );
196
+ }