@oj-module/cli 0.2.66 → 0.2.67

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 (187) 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 +30 -1
  5. package/devkit/framework-dev.md +10 -4
  6. package/devkit/manual.md +38 -12
  7. package/devkit/publish-walkthrough.md +110 -37
  8. package/devkit/wecom-internal-app.md +7 -2
  9. package/devkit/wecom-multi-corp-links.md +163 -0
  10. package/package.json +2 -2
  11. package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
  12. package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.2dgstpjsu0.js} +3449 -3449
  13. package/shell-dist/index.html +4 -4
  14. package/shell-dist/versions.json +3 -3
  15. package/src/__tests__/publish-keygen.test.ts +157 -0
  16. package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +15 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +6 -2
  19. package/src/bundle.ts +8 -3
  20. package/src/cli.ts +17 -5
  21. package/src/dev.ts +15 -10
  22. package/src/install.ts +11 -4
  23. package/src/keygen.ts +54 -0
  24. package/src/lifecycle/__tests__/bundle.test.ts +21 -3
  25. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
  26. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
  27. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
  28. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
  29. package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
  30. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  31. package/src/manifest-contract.ts +91 -8
  32. package/src/market/bootstrap.ts +26 -2
  33. package/src/market/command.ts +1 -0
  34. package/src/market/publish.ts +5 -1
  35. package/src/market/walkthrough.ts +24 -8
  36. package/src/site.ts +2 -0
  37. package/src/usage.ts +8 -4
  38. package/src/vendor.ts +5 -1
  39. package/templates/config.yaml +4 -2
  40. package/templates/init.include.yaml +8 -3
  41. package/templates/openapi.json +460 -1
  42. package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
  43. package/templates/src/app/api/_platform/_lifecycle/installer.ts +71 -22
  44. package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
  45. package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
  46. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
  47. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
  48. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
  49. package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
  50. package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
  51. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
  52. package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
  53. package/templates/src/app/api/_platform/manifest.yaml +1 -0
  54. package/templates/src/app/api/_platform/market/api.ts +235 -10
  55. package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
  56. package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
  57. package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
  58. package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
  59. package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
  60. package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
  61. package/templates/src/app/api/_platform/schema.yaml +18 -0
  62. package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
  63. package/templates/src/app/api/_platform/seed.pg.sql +10 -0
  64. package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
  65. package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
  66. package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
  67. package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
  68. package/templates/src/app/api/home/seed.mysql.sql +71 -0
  69. package/templates/src/app/api/home/seed.pg.sql +71 -0
  70. package/templates/src/app/api/notification/_shared/notify.ts +6 -6
  71. package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
  72. package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
  73. package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
  74. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
  75. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
  76. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
  77. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
  78. package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
  79. package/templates/src/app/api/notification/seed.mysql.sql +8 -0
  80. package/templates/src/app/api/notification/seed.pg.sql +11 -0
  81. package/templates/src/app/api/publish/_shared/identity.ts +69 -0
  82. package/templates/src/app/api/publish/api.ts +349 -0
  83. package/templates/src/app/api/publish/manifest.yaml +8 -0
  84. package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
  85. package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
  86. package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
  87. package/templates/src/app/api/publish/schema.yaml +33 -0
  88. package/templates/src/app/api/system/login-methods/api.ts +2 -1
  89. package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
  90. package/templates/src/app/web/_platform/__client/index.ts +25 -1
  91. package/templates/src/app/web/entry.ts +9 -5
  92. package/templates/src/app/web/home/routes.ts +5 -0
  93. package/templates/src/app/web/login/locales/en-US.json +7 -1
  94. package/templates/src/app/web/login/locales/zh-CN.json +7 -1
  95. package/templates/src/app/web/login/pages/login.tsx +79 -86
  96. package/templates/src/app/web/login/routes.ts +54 -1
  97. package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
  98. package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
  99. package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
  100. package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
  101. package/templates/src/app/web/system/locales/en-US.json +1 -8
  102. package/templates/src/app/web/system/locales/zh-CN.json +1 -8
  103. package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
  104. package/templates/src/app/web/system/routes.ts +0 -6
  105. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
  106. package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
  107. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
  108. package/templates/src/oj-market/web/api/client.ts +3 -0
  109. package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
  110. package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
  111. package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
  112. package/templates/src/oj-market/web/entry.ts +31 -2
  113. package/templates/src/oj-market/web/locales/en-US.json +5 -1
  114. package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
  115. package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
  116. package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
  117. package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
  118. package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
  119. package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
  120. package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
  121. package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
  122. package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
  123. package/templates/src/oj-market/web/publish/routes.ts +41 -0
  124. package/templates/src/oj-market/web/types.ts +30 -0
  125. package/templates/tests/lifecycle/compensate.spec.ts +207 -0
  126. package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
  127. package/templates/tests/lifecycle/installer.spec.ts +28 -9
  128. package/templates/tests/lifecycle/market-api.spec.ts +164 -12
  129. package/templates/tests/lifecycle/preflight.spec.ts +68 -0
  130. package/templates/tests/lifecycle/role-title-translate.spec.ts +2 -32
  131. package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
  132. package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
  133. package/templates/tests/publish-credential.spec.ts +354 -0
  134. package/templates/tests/publish-identity.spec.ts +44 -0
  135. package/templates/tests/wecom-client-timeout.spec.ts +33 -3
  136. package/templates/tests/wecom-landing.spec.ts +63 -1
  137. package/templates/tsconfig.paths.json +1 -0
  138. package/templates/web.config.ts +6 -4
  139. package/vendor/host-versions.json +3 -3
  140. package/templates/seed.mysql.sql +0 -6
  141. package/templates/seed.pg.sql +0 -6
  142. package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
  143. package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
  144. package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
  145. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
  146. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
  147. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
  148. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
  149. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
  150. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
  151. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
  152. package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
  153. package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
  154. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
  155. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
  156. package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
  157. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
  158. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
  159. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
  160. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
  161. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
  162. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
  163. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
  164. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
  165. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
  166. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
  167. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
  168. package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
  169. package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
  170. package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
  171. package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
  172. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
  173. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
  174. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
  175. package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
  176. package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
  177. package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
  178. package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
  179. package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
  180. package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
  181. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
  182. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
  183. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
  184. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
  185. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
  186. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
  187. package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
@@ -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
+ }
@@ -0,0 +1,52 @@
1
+ // 「发布管理」页的纯逻辑(无 React、无 IO)——状态颜色、可撤销判定、给用户的命令行。
2
+ // 抽出来的唯一理由:这三件事是页面里最该被钉死的口径,且都必须能脱离 antd 断言。
3
+
4
+ export type CredentialStatus = "active" | "used" | "revoked" | "expired";
5
+
6
+ /** 状态 → antd Tag 颜色。Record 全量约束:新增状态时这里编译期就缺键。 */
7
+ const STATUS_COLOR: Record<CredentialStatus, string> = {
8
+ active: "processing",
9
+ used: "success",
10
+ revoked: "default",
11
+ expired: "warning",
12
+ };
13
+
14
+ export function statusColor(status: CredentialStatus): string {
15
+ return STATUS_COLOR[status];
16
+ }
17
+
18
+ /**
19
+ * 能否撤销:只有「待兑换」可以。
20
+ *
21
+ * 兑换只下发 key_id,公钥是发布方本机生成的——站点手里没有那把钥匙,也就没有「撤回」可言;
22
+ * 撤回一条已兑换的凭证只会制造「以为撤了其实没撤」的错觉(后端的 400 文案就是这个意思,
23
+ * 真要停用只能到宿主信任库移除对应公钥)。按钮仍要渲染(禁用态),不用隐藏:
24
+ * 隐藏会让用户以为页面没做完。
25
+ */
26
+ export function canRevoke(status: string): boolean {
27
+ return status === "active";
28
+ }
29
+
30
+ /**
31
+ * 复制给用户的一行命令。
32
+ *
33
+ * 只带凭证,不带 `--site`:站点地址由 CLI 从本机配置或上次发布记录里取,
34
+ * 页面拼一个 origin 反而在反代/多域名部署下更容易错。
35
+ */
36
+ export function buildKeygenCommand(token: string): string {
37
+ return `ojm keygen --credential ${token}`;
38
+ }
39
+
40
+ /**
41
+ * 统一信封错误 → 人话。后端 fail(status, message) 的文案本身就是给用户看的,
42
+ * 故优先取 `msg`(ContractApiError),其次 Error.message。
43
+ *
44
+ * 与 system/pages/errors.ts 的 errMsg 同构但不互相 import:那是系统管理根内部模块,
45
+ * 让「发布管理」依赖它会把两个根的加载顺序绑在一起(多根聚合下互不依赖是硬约定)。
46
+ */
47
+ export function errorText(e: unknown): string {
48
+ const msg = (e as { msg?: unknown } | null)?.msg;
49
+ if (typeof msg === "string" && msg !== "")
50
+ return msg;
51
+ return e instanceof Error ? e.message : String(e);
52
+ }
@@ -0,0 +1,82 @@
1
+ import { Alert, Button, Input, Modal, Space, Typography } from "antd";
2
+ import { useState } from "react";
3
+ import { useTranslation } from "react-i18next";
4
+
5
+ import { buildKeygenCommand } from "./model";
6
+
7
+ /** 申请成功的返回值:明文只在这一刻存在 */
8
+ export interface IssuedCredential {
9
+ token: string
10
+ key_id: string
11
+ product: string
12
+ token_hint?: string
13
+ expires_at: number
14
+ }
15
+
16
+ /** 复制到剪贴板:非 https / 无权限时静默失败(输入框里的文本本来就能手动选中) */
17
+ async function copy(text: string): Promise<void> {
18
+ try {
19
+ await navigator.clipboard.writeText(text);
20
+ }
21
+ catch {
22
+ // 不打断流程:用户可直接在只读输入框里选中复制
23
+ }
24
+ }
25
+
26
+ /**
27
+ * 一次性明文弹窗。
28
+ *
29
+ * 三条不可退让的约束:
30
+ * · 不渲染 = 没有。`issued` 为 null 时整个 Modal 不挂载,关闭后 DOM 里不再有明文
31
+ * (服务端只存 sha256,关掉就真的找不回来——所以文案必须先说清楚)。
32
+ * · 不可点遮罩/ESC 关闭:误触的代价是重新申请一张凭证。
33
+ * · 凭证与命令都是只读输入框 + 复制按钮:64 位 hex 手抄必错。
34
+ */
35
+ export function TokenModal({ issued, onClose }: { issued: IssuedCredential | null, onClose: () => void }) {
36
+ const { t } = useTranslation();
37
+ const [copied, setCopied] = useState<string | null>(null);
38
+ if (!issued)
39
+ return null;
40
+ const command = buildKeygenCommand(issued.token);
41
+ const copyRow = (testId: string, value: string) => (
42
+ <Space.Compact style={{ width: "100%", marginBottom: 16 }}>
43
+ <Input readOnly value={value} data-testid={testId} />
44
+ <Button
45
+ onClick={() => {
46
+ void copy(value);
47
+ setCopied(testId);
48
+ }}
49
+ >
50
+ {copied === testId ? t("publish:issued.copied") : t("publish:issued.copy")}
51
+ </Button>
52
+ </Space.Compact>
53
+ );
54
+
55
+ return (
56
+ <Modal
57
+ open
58
+ closable={false}
59
+ maskClosable={false}
60
+ keyboard={false}
61
+ title={t("publish:issued.title")}
62
+ footer={<Button type="primary" onClick={onClose}>{t("publish:issued.done")}</Button>}
63
+ >
64
+ <div data-testid="publish-issued">
65
+ <Alert type="warning" showIcon message={t("publish:issued.warning")} style={{ marginBottom: 16 }} />
66
+ <Typography.Paragraph>
67
+ <Typography.Text type="secondary">{t("publish:issued.identity")}</Typography.Text>
68
+ <br />
69
+ <Typography.Text copyable={{ text: issued.key_id }} code>{issued.key_id}</Typography.Text>
70
+ </Typography.Paragraph>
71
+ <Typography.Paragraph style={{ marginBottom: 4 }}>
72
+ <Typography.Text type="secondary">{t("publish:issued.token")}</Typography.Text>
73
+ </Typography.Paragraph>
74
+ {copyRow("publish-token", issued.token)}
75
+ <Typography.Paragraph style={{ marginBottom: 4 }}>
76
+ <Typography.Text type="secondary">{t("publish:issued.command")}</Typography.Text>
77
+ </Typography.Paragraph>
78
+ {copyRow("publish-command", command)}
79
+ </div>
80
+ </Modal>
81
+ );
82
+ }
@@ -0,0 +1,41 @@
1
+ import type { AppRouteRecordRaw, ModuleContext } from "@oj-module/runtime";
2
+
3
+ import { HistoryOutlined, KeyOutlined } from "@ant-design/icons";
4
+
5
+ import { createElement, lazy } from "react";
6
+
7
+ import { createPublishClient } from "./__client";
8
+
9
+ const PublishPage = lazy(() => import("./pages"));
10
+ const ReleaseHistoryPage = lazy(() => import("./pages/history"));
11
+
12
+ /**
13
+ * 「方案市场」下的两个子页(单模单根:菜单只有 `/oj-market` 一个根)。
14
+ *
15
+ * 路径是**相对父路由**声明的:`publish` → `/oj-market/publish`,`history` → `/oj-market/history`。
16
+ * 发布管理不挂 handle.roles:申请发布凭证是普通登录用户的能力,超管多出来的只是
17
+ * 「看到所有租户的凭证」,由后端按角色决定返回范围,前端不重复判权。
18
+ */
19
+ export const routes: AppRouteRecordRaw[] = [
20
+ {
21
+ path: "publish",
22
+ Component: PublishPage,
23
+ handle: {
24
+ title: "publish:menu.publish",
25
+ icon: createElement(KeyOutlined),
26
+ },
27
+ },
28
+ {
29
+ path: "history",
30
+ Component: ReleaseHistoryPage,
31
+ handle: {
32
+ title: "publish:menu.history",
33
+ icon: createElement(HistoryOutlined),
34
+ },
35
+ },
36
+ ];
37
+
38
+ /** 构造即接线:登记契约前缀 /publish + 绑定 scoped request,页面直接 import 的函数才有通道 */
39
+ export function onInit(ctx: ModuleContext): void {
40
+ createPublishClient(ctx);
41
+ }
@@ -61,6 +61,12 @@ export interface IndexRow {
61
61
  upgradable: boolean
62
62
  /** 最新版许可(SPDX 简化式,来自分片条目的 manifest.license;未声明 → null) */
63
63
  license?: string | null
64
+ /** 发布者(分片条目 archive.signature.key_id,经验签的签名身份;与后端 trust_state/key_id 同用 snake_case) */
65
+ publisher_key?: string | null
66
+ /** 方案简介(manifest.summary,C2-04 单行 1..200;索引未带该字段 → undefined/null) */
67
+ summary?: string | null
68
+ /** 方案详细描述(manifest.description,C2-05 ≤2000;索引未带该字段 → undefined/null) */
69
+ description?: string | null
64
70
  }
65
71
 
66
72
  /** 安装输入(POST /_platform/market/install) */
@@ -97,3 +103,27 @@ export interface UninstallImpact {
97
103
  affectedRoles: number
98
104
  affectedUsers: number
99
105
  }
106
+
107
+ /** 发布历史一行(`market_release`;来源是签名索引,追加式记录) */
108
+ export interface ReleaseRow {
109
+ id: number
110
+ product: string
111
+ version: string
112
+ key_id: string | null
113
+ sha256: string | null
114
+ size: number | null
115
+ published_at: number
116
+ /** file = 分发包 Last-Modified;index = 索引生成时间兜底 */
117
+ published_at_source: "file" | "index"
118
+ yanked: number
119
+ }
120
+
121
+ /** 发布历史分页结果 */
122
+ export interface ReleasePage {
123
+ list: ReleaseRow[]
124
+ total: number
125
+ page: number
126
+ size: number
127
+ /** 本次同步索引失败的人话(页面照常显示已入库历史) */
128
+ syncError: string | null
129
+ }