@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,8 @@
1
+ name: publish
2
+ desc: 发布凭证与发布身份(业务路由):申请 / 列表 / 撤销 / 一次性兑换。凭证兑换即作废,Ed25519 密钥长期有效;身份只由服务端推导,CLI 不重复实现编码规则。
3
+ version: 0.1.0
4
+ tables:
5
+ # 共享表(B 组):兑换发生在 CLI,无业务租户语义;租户归属走 owner_tenant 列
6
+ - publish_credential
7
+ deps: # 判权(currentAccess)、统一信封(respond)、分页与限速都来自平台内核
8
+ _platform: ^0.1.0
@@ -0,0 +1,23 @@
1
+ -- 0001__publish_credential(mysql 方言):列语义同 0001__publish_credential.sql 头注释。
2
+ -- 差异(沿用 _platform 基线的既有惯例):键列/短文本收窄为 VARCHAR;秒级 epoch 取 BIGINT;
3
+ -- 索引写在 CREATE TABLE 内(mysql 无 CREATE INDEX IF NOT EXISTS)。
4
+ CREATE TABLE IF NOT EXISTS publish_credential (
5
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
6
+ token_hash VARCHAR(255) NOT NULL,
7
+ token_hint VARCHAR(64) NOT NULL,
8
+ uid BIGINT NOT NULL,
9
+ username VARCHAR(255) NOT NULL,
10
+ display_name TEXT,
11
+ product VARCHAR(255) NOT NULL,
12
+ key_id VARCHAR(255) NOT NULL,
13
+ owner_tenant VARCHAR(255) NOT NULL,
14
+ expires_at BIGINT NOT NULL,
15
+ used BIGINT NOT NULL DEFAULT 0,
16
+ used_at BIGINT,
17
+ revoked BIGINT NOT NULL DEFAULT 0,
18
+ created_at BIGINT NOT NULL,
19
+ created_ip VARCHAR(64),
20
+ UNIQUE KEY uk_publish_credential_token (token_hash),
21
+ KEY idx_publish_credential_owner (owner_tenant, uid, created_at),
22
+ KEY idx_publish_credential_key (key_id)
23
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,23 @@
1
+ -- 0001__publish_credential(postgres 方言):列语义同 0001__publish_credential.sql 头注释。
2
+ -- 差异(沿用 _platform 基线的既有惯例):TEXT 全保留,秒级 epoch 取 BIGINT。
3
+ CREATE TABLE IF NOT EXISTS publish_credential (
4
+ id BIGSERIAL PRIMARY KEY,
5
+ token_hash TEXT NOT NULL,
6
+ token_hint TEXT NOT NULL,
7
+ uid BIGINT NOT NULL,
8
+ username TEXT NOT NULL,
9
+ display_name TEXT,
10
+ product TEXT NOT NULL,
11
+ key_id TEXT NOT NULL,
12
+ owner_tenant TEXT NOT NULL,
13
+ expires_at BIGINT NOT NULL,
14
+ used INTEGER NOT NULL DEFAULT 0,
15
+ used_at BIGINT,
16
+ revoked INTEGER NOT NULL DEFAULT 0,
17
+ created_at BIGINT NOT NULL,
18
+ created_ip TEXT
19
+ );
20
+ CREATE UNIQUE INDEX IF NOT EXISTS uk_publish_credential_token ON publish_credential (token_hash);
21
+ CREATE INDEX IF NOT EXISTS idx_publish_credential_owner
22
+ ON publish_credential (owner_tenant, uid, created_at);
23
+ CREATE INDEX IF NOT EXISTS idx_publish_credential_key ON publish_credential (key_id);
@@ -0,0 +1,39 @@
1
+ -- 0001__publish_credential(sqlite 方言):发布凭证表。
2
+ --
3
+ -- 定性:**共享表**(schema.yaml `tenant: false` + config.yaml `tenant.shared_allow` 双声明)。
4
+ -- 理由:兑换是 CLI 发起的,那一刻没有业务租户上下文;建成租户表会让 sql_guard: deny 给
5
+ -- 按 token_hash 的查询强加 tenant_id 过滤,换个租户头就查不到。租户归属由 owner_tenant
6
+ -- 显式承载(命名同 wecom_login_ticket:共享表出现 tenant_id 会被定性红线判为自相矛盾)。
7
+ --
8
+ -- 语义:
9
+ -- · 一次性兑换令牌:兑换成功后 used=1,再兑即拒;未兑换前可用 revoked 手动作废
10
+ -- · key_id 在**申请时**就算好落库,兑换只回读——CLI 不实现任何编码规则(单一真源)
11
+ -- · 只存 token_hash(sha256),明文只在申请响应里出现一次
12
+ --
13
+ -- 主键用自增 id 而非 token_hash:列表分页与撤销都按行 id 操作(pagedFactory 也按 id 排序),
14
+ -- 用 hash 做主键会让这两件事都得先算 hash 再反查。token_hash 仍唯一。
15
+ CREATE TABLE IF NOT EXISTS publish_credential (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ token_hash TEXT NOT NULL,
18
+ token_hint TEXT NOT NULL,
19
+ uid INTEGER NOT NULL,
20
+ username TEXT NOT NULL,
21
+ display_name TEXT,
22
+ product TEXT NOT NULL,
23
+ key_id TEXT NOT NULL,
24
+ owner_tenant TEXT NOT NULL,
25
+ expires_at INTEGER NOT NULL,
26
+ used INTEGER NOT NULL DEFAULT 0,
27
+ used_at INTEGER,
28
+ revoked INTEGER NOT NULL DEFAULT 0,
29
+ created_at INTEGER NOT NULL,
30
+ created_ip TEXT
31
+ );
32
+ CREATE UNIQUE INDEX IF NOT EXISTS uk_publish_credential_token ON publish_credential (token_hash);
33
+
34
+ -- 「发布管理」页的主查询:按租户 + 本人 + 时间倒序
35
+ CREATE INDEX IF NOT EXISTS idx_publish_credential_owner
36
+ ON publish_credential (owner_tenant, uid, created_at);
37
+
38
+ -- 撞号检查(同 key_id 是否已属于别人)
39
+ CREATE INDEX IF NOT EXISTS idx_publish_credential_key ON publish_credential (key_id);
@@ -0,0 +1,33 @@
1
+ # 声明式表结构(S006 纪律:结构真源是 migrations/0001__publish_credential*,本文件只负责
2
+ # 「列集/主键/租户定性」的运行时元数据,与迁移逐列对齐;templates-migration-schema-parity 双双守着)。
3
+ #
4
+ # 定性(三处一致:这里 / config.yaml `tenant.shared_allow` / 迁移文件头注释):
5
+ # **共享表**(tenant: false)。凭证在 CLI 兑换那一跳被执行,此刻没有业务租户语义——
6
+ # 若建成租户表,sql_guard: deny 会给按 token_hash 的查询强加 tenant_id 过滤,换个租户头即查不到。
7
+ # 租户归属由 `owner_tenant` 列显式承载(语义同 wecom_login_ticket:共享表出现 tenant_id
8
+ # 会被定性红线判为自相矛盾)。
9
+ tables:
10
+ publish_credential:
11
+ tenant: false
12
+ pk: id
13
+ columns:
14
+ id: {type: integer, null: false, autoincrement: true}
15
+ # 只存 sha256(明文):明文只在申请响应里出现一次
16
+ token_hash: {type: text, null: false}
17
+ # 明文前 8 位,仅供「发布管理」页识别是哪一张,不参与任何校验
18
+ token_hint: {type: text, null: false}
19
+ uid: {type: integer, null: false}
20
+ # 申请时快照:改名不影响已签发的凭证与已推导的 key_id
21
+ username: {type: text, null: false}
22
+ display_name: {type: text}
23
+ # 申请时填写的产品名(NAME_RE 校验,落库即定)
24
+ product: {type: text, null: false}
25
+ # 服务端推导并落库的发布身份;兑换时原样下发,CLI 零推导
26
+ key_id: {type: text, null: false}
27
+ owner_tenant: {type: text, null: false}
28
+ expires_at: {type: integer, null: false}
29
+ used: {type: integer, null: false}
30
+ used_at: {type: integer}
31
+ revoked: {type: integer, null: false}
32
+ created_at: {type: integer, null: false}
33
+ created_ip: {type: text}
@@ -10,9 +10,10 @@ async function get() {
10
10
  }
11
11
  get.response = {
12
12
  type: "object",
13
- required: ["methods"],
13
+ required: ["methods", "site_title"],
14
14
  properties: {
15
15
  methods: { type: "array", items: { type: "string" } },
16
+ site_title: { type: "string" },
16
17
  },
17
18
  };
18
19
 
@@ -124,6 +124,21 @@ export const schemas = {
124
124
  data: z.array(z.object({
125
125
 
126
126
  }).catchall(z.unknown())),
127
+ },
128
+ "platformMarketReleasesGet": {
129
+ query: z.object({
130
+ "page": z.number().min(1).optional(),
131
+ "limit": z.number().min(1).max(200).optional()
132
+ }),
133
+ data: z.object({
134
+ "list": z.array(z.object({
135
+
136
+ }).catchall(z.unknown())),
137
+ "total": z.number(),
138
+ "page": z.number(),
139
+ "size": z.number(),
140
+ "syncError": z.string().nullable().optional()
141
+ }),
127
142
  },
128
143
  "platformOrgDepartmentsGet": {
129
144
  query: z.object({
@@ -36,7 +36,7 @@ export function create_platformClient(ctx: ModuleContext) {
36
36
  }
37
37
  ctx.register.apiPrefix(API_PREFIX);
38
38
  bindRequest(ctx.utils.request);
39
- return { platformContactsGet, platformContactsDetailGet, platformMarketInstallPost, platformMarketInstallCancelPost, platformMarketTaskGet, platformMarketEnablePost, platformMarketMetaGet, platformMarketTrustGet, platformMarketTrustPost, platformMarketTrustKeyPost, platformMarketTrustKeyDelete, platformMarketIndexGet, platformMarketUninstallImpactGet, platformMarketUninstallPost, platformMarketHistoryGet, platformOrgDepartmentsGet, platformOrgIsMemberGet, platformOrgMembersGet, platformOrgPickerGet, platformOrgResolvePost, platformOrgTreeGet, platformOrgUserGet, platformSolutionsRoutesGet, platformSolutionsVerifyPost, platformSolutionsVerifyLastGet };
39
+ return { platformContactsGet, platformContactsDetailGet, platformMarketInstallPost, platformMarketInstallCancelPost, platformMarketTaskGet, platformMarketEnablePost, platformMarketMetaGet, platformMarketTrustGet, platformMarketTrustPost, platformMarketTrustKeyPost, platformMarketTrustKeyDelete, platformMarketIndexGet, platformMarketUninstallImpactGet, platformMarketUninstallPost, platformMarketHistoryGet, platformMarketReleasesGet, platformOrgDepartmentsGet, platformOrgIsMemberGet, platformOrgMembersGet, platformOrgPickerGet, platformOrgResolvePost, platformOrgTreeGet, platformOrgUserGet, platformSolutionsRoutesGet, platformSolutionsVerifyPost, platformSolutionsVerifyLastGet };
40
40
  }
41
41
 
42
42
  /**
@@ -423,6 +423,30 @@ export async function platformMarketHistoryGet(): Promise<PlatformMarketHistoryG
423
423
  }
424
424
  }
425
425
 
426
+ export type PlatformMarketReleasesGetQuery = z.input<(typeof schemas)["platformMarketReleasesGet"]["query"]>;
427
+ export type PlatformMarketReleasesGetData = z.infer<(typeof schemas)["platformMarketReleasesGet"]["data"]>;
428
+
429
+ export async function platformMarketReleasesGet(query: PlatformMarketReleasesGetQuery): Promise<PlatformMarketReleasesGetData> {
430
+ const client = ensureReq();
431
+ try {
432
+ const env = await client.get(`_platform/market/releases`, { searchParams: query as Record<string, string | number | boolean> }).json<OjEnvelope<PlatformMarketReleasesGetData>>();
433
+ // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
434
+ if (typeof env.code === "number" && env.code !== 0)
435
+ throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
436
+ const data = env.data as PlatformMarketReleasesGetData;
437
+ if (import.meta.env.DEV) {
438
+ const { schemas } = await import("./api.schemas");
439
+ const r = schemas["platformMarketReleasesGet"].data.safeParse(data);
440
+ if (!r.success)
441
+ throw new ContractApiError(-1, `[契约违例] platformMarketReleasesGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
442
+ }
443
+ return data;
444
+ }
445
+ catch (e) {
446
+ throw await toApiError(e);
447
+ }
448
+ }
449
+
426
450
  export type PlatformOrgDepartmentsGetQuery = z.input<(typeof schemas)["platformOrgDepartmentsGet"]["query"]>;
427
451
  export type PlatformOrgDepartmentsGetData = z.infer<(typeof schemas)["platformOrgDepartmentsGet"]["data"]>;
428
452
 
@@ -8,13 +8,16 @@ import { onInit as personalCenterOnInit, routes as personalCenterRoutes } from "
8
8
  import { onInit as systemOnInit, routes as systemRoutes } from "./system/routes";
9
9
 
10
10
  /**
11
- * 应用模块(多根聚合):一个 entry 承载六个业务根,各根独立成一级菜单。
11
+ * 应用模块(多根聚合):一个 entry 承载五个业务根,各根独立成一级菜单。
12
12
  *
13
- * 为什么聚合:这六个根(首页/登录/个人中心/通知/系统管理)是脚手架的框架内置页面,
14
- * 同版本发布、同生命周期、缺一不可,拆成六个模块只会把工程目录、web.config 登记、
15
- * modules.json 与首屏模块入口请求都放大六倍。聚合后仍是「一目录一模块」,但模块内
13
+ * 为什么聚合:这五个根(首页/登录/个人中心/通知/系统管理)是脚手架的框架内置页面,
14
+ * 同版本发布、同生命周期、缺一不可,拆成五个模块只会把工程目录、web.config 登记、
15
+ * modules.json 与首屏模块入口请求都放大五倍。聚合后仍是「一目录一模块」,但模块内
16
16
  * 按根分目录(`<root>/routes.ts` + `pages/` + `__client/`),页面相对导入不变。
17
17
  *
18
+ * 发布管理不在这里:它属于方案市场,随 `src/oj-market/web/publish` 走市场模块
19
+ * (`/publish` 仍是独立一级菜单,URL 与菜单 key 不变)。
20
+ *
18
21
  * 结构约定(多根模块):
19
22
  * - 顶层路由首段去重 > 1 → runtime 判为多根(`isMultiRoot`),各根按 entry 里的
20
23
  * **绝对路径**原样注册,不被 `routePrefix` 改写——URL 与菜单 key 都不变;
@@ -24,7 +27,8 @@ import { onInit as systemOnInit, routes as systemRoutes } from "./system/routes"
24
27
  *
25
28
  * i18n 用 `i18nNamespaces` 沿用各根**原命名空间**(home / login / personal-center /
26
29
  * notification / system),因此 `t("system:menu.users")`、`useTranslation("system")`
27
- * 与菜单标题 `<ns>:menu.<key>` 全部零改动。
30
+ * 与菜单标题 `<ns>:menu.<key>` 全部零改动。发布管理的 `publish` 命名空间随页面
31
+ * 在 oj-market 侧登记。
28
32
  */
29
33
  const i18nNamespaces: Record<string, ModuleI18n> = {
30
34
  "home": {
@@ -35,6 +35,11 @@ export const routes: AppRouteRecordRaw[] = [
35
35
  handle: {
36
36
  title: "home:menu.home",
37
37
  icon: createElement(HomeOutlined),
38
+ // 首页是 /home 根的 index 子路由,运行时匹配到的叶子路由 id 为 `/home/`(带尾斜杠),
39
+ // 而菜单项 key 是父级 `/home`,selectedKeys 取叶子 id 导致高亮对不上——
40
+ // 复刻 react-antd-admin 时「首页」菜单项不亮。用 currentActiveMenu 显式声明高亮 /home
41
+ // (框架既定机制:页面指定高亮哪个菜单),对齐 playground / react-antd-admin 的选中态。
42
+ currentActiveMenu: "/home",
38
43
  },
39
44
  },
40
45
  ],
@@ -30,9 +30,15 @@
30
30
  "sso.action": "Sign in with SSO",
31
31
  "wecom": "WeCom",
32
32
  "wecom.action": "Sign in with WeCom",
33
+ "wecomPending": "WeCom login will be available in a later stage",
33
34
  "wecom.hint": "Enter the enterprise code (corp_id, visible in the WeCom console), or use the dedicated sign-in link from your admin",
34
35
  "wecom.agent": "App ID (agentid)",
35
36
  "wecom.agentHint": "Leave empty if the corp has a single app; otherwise use the agentid provided by your admin",
36
- "wecom.exchanging": "Completing WeCom sign-in…"
37
+ "wecom.exchanging": "Completing WeCom sign-in…",
38
+ "wecom.scan.hint": "Open the WeCom app, scan with 「Scan」 and confirm — no username or password needed",
39
+ "wecom.scan.action": "One-tap sign in with WeCom",
40
+ "wecom.otherCorp": "Another enterprise? Sign in with an enterprise code",
41
+ "wecom.otherCorpHide": "Hide enterprise code"
42
+
37
43
  }
38
44
  }
@@ -30,9 +30,15 @@
30
30
  "sso.action": "使用 SSO 登录",
31
31
  "wecom": "企业微信",
32
32
  "wecom.action": "使用企业微信登录",
33
+ "wecomPending": "企业微信登录将在后续阶段提供",
33
34
  "wecom.hint": "填写企业代码(企微后台可见的 corp_id),或直接使用管理员收到的专属登录链接",
34
35
  "wecom.agent": "应用 ID(agentid)",
35
36
  "wecom.agentHint": "留空使用该企业下唯一应用;多应用部署请填管理员提供的 agentid",
36
- "wecom.exchanging": "正在完成企业微信登录…"
37
+ "wecom.exchanging": "正在完成企业微信登录…",
38
+ "wecom.scan.hint": "打开企业微信 App,用「扫一扫」扫码确认后自动进入,无需输入账号密码",
39
+ "wecom.scan.action": "使用企业微信一键登录",
40
+ "wecom.otherCorp": "其他企业?使用企业代码登录",
41
+ "wecom.otherCorpHide": "收起企业代码"
42
+
37
43
  }
38
44
  }
@@ -6,10 +6,11 @@ import {
6
6
  useAuthStore,
7
7
  useTenantStore,
8
8
  } from "@oj-module/runtime";
9
- import { Alert, Button, Form, Input, Tabs, Typography } from "antd";
9
+ import { Alert, Button, Form, Input, Skeleton, Tabs, Typography } from "antd";
10
10
  import { useEffect, useRef, useState } from "react";
11
11
  import { useTranslation } from "react-i18next";
12
12
  import { useLocation, useNavigate } from "react-router";
13
+ import { getWecomBootError } from "../routes";
13
14
  import { parseWecomLanding, wecomAuthorizePath } from "../wecom";
14
15
 
15
16
  type LoginMethod = "local" | "ldap" | "sso" | "wecom";
@@ -19,7 +20,7 @@ type LoginMethod = "local" | "ldap" | "sso" | "wecom";
19
20
  * 后端 `methods` 是**发现结果**、这里是**渲染能力**:后端多报一种而本页没有对应
20
21
  * renderer 时宁可不显示(点了也没实现),新增方式 = 这里加一项 + 渲染分支加一路。
21
22
  */
22
- const KNOWN_METHODS: LoginMethod[] = ["local", "ldap", "sso", "wecom"];
23
+ const KNOWN_METHODS: LoginMethod[] = ["wecom", "local", "ldap", "sso"];
23
24
 
24
25
  function isKnownMethod(x: string): x is LoginMethod {
25
26
  return (KNOWN_METHODS as string[]).includes(x);
@@ -52,12 +53,14 @@ export default function LoginPage() {
52
53
  const navigate = useNavigate();
53
54
  const { search } = useLocation();
54
55
  const login = useAuthStore(s => s.login);
55
- const [error, setError] = useState<string>();
56
+ // onInit 阶段换票若失败,首帧直接显示表单与错误(不再闪「认证中」)
57
+ const bootError = getWecomBootError();
58
+ const [error, setError] = useState<string | undefined>(bootError || undefined);
56
59
  const [loading, setLoading] = useState(false);
57
60
  const [known, setKnown] = useState(false);
58
61
  const inWecom = typeof navigator !== "undefined" && /wxwork/i.test(navigator.userAgent);
59
62
  const [methods, setMethods] = useState<LoginMethod[]>(["local"]);
60
- const [active, setActive] = useState<LoginMethod>("local");
63
+ const [active, setActive] = useState<LoginMethod>("wecom");
61
64
  // 企业代码入口默认收起:单租户部署(绝大多数)保持今天的登录页外观,
62
65
  // 只有服务商/客户带 `?tenant=` 或手工展开时才需要填。
63
66
  // 例外一:持久化租户不是部署默认值时必须展开——租户头是**隐式**跟着请求走的
@@ -68,6 +71,8 @@ export default function LoginPage() {
68
71
  // 判据用「wecom 是否在登录方式里」而不是去数租户:匿名请求没有租户上下文,
69
72
  // 统计租户要动用 db.asSystem(全仓仅 tenant-admin.ts 一处,不为此破例)。
70
73
  const [showTenant, setShowTenant] = useState(() => useTenantStore.getState().tenant !== DEFAULT_TENANT);
74
+ // 「其他企业」折叠:仅服务商 / 多企业共存部署才需要手填企业代码
75
+ const [showOtherCorp, setShowOtherCorp] = useState(false);
71
76
  // 表单里的企业代码:带上 persisted 值(URL `?tenant=` 已先落到 store),用户看得见才改得动
72
77
  const [tenant] = useState(() => {
73
78
  const current = useTenantStore.getState().tenant;
@@ -83,29 +88,32 @@ export default function LoginPage() {
83
88
 
84
89
  // 企微 302 落地参数:只在首次渲染解析一次(ticket 一次性,解析结果必须稳定)
85
90
  const [landing] = useState(() => parseWecomLanding(search));
86
- const exchangedRef = useRef(false);
87
91
  // 仅「认证中 / 已失败」两态:企微内、回跳落地、或 ?corp 深链即进入认证态,
88
92
  // 直接展示「认证中」,不渲染登录表单;只有认证失败(换票失败 / 企微方式不可用)才回落表单。
93
+ // 换票成功时 onInit 已把 URL 改到首页,路由首帧就落在 /home,本组件根本不会挂载;bootError 非空即换票失败。
89
94
  const [phase, setPhase] = useState<"auth" | "form">(
90
- inWecom || landing || urlCorp ? "auth" : "form",
95
+ bootError ? "form" : inWecom || landing || urlCorp ? "auth" : "form",
91
96
  );
92
97
 
93
98
  useEffect(() => {
94
99
  fetchLoginMethods()
95
100
  .then((r) => {
96
101
  setKnown(true);
102
+ // 站点标题(framework.site_title)经匿名端点回传,登录页即可设置浏览器标签;
103
+ // 其他已登录页保持随菜单(layout-effects 的路由标题),此处仅在登录页生效。
104
+ if (r.site_title)
105
+ document.title = r.site_title;
97
106
  // 先按 string[] 看一眼:runtime 的返回类型把 wecom 排除在外,而这里恰恰要认它
98
107
  const raw: string[] = r.methods;
99
- const m = raw.filter(isKnownMethod);
108
+ // 按 KNOWN_METHODS 排序:企微是主入口,排第一个 tab 并默认选中,
109
+ // 「使用企业微信一键登录」首帧即可见,点一次就走。
110
+ const m = raw.filter(isKnownMethod).sort(
111
+ (a, b) => KNOWN_METHODS.indexOf(a) - KNOWN_METHODS.indexOf(b),
112
+ );
100
113
  if (!m.length)
101
114
  return;
102
115
  setMethods(m);
103
- // 有企微登录 ⇒ 有企业租户(租户键 = corp_id)⇒ 默认展开企业代码,
104
- // 否则平台/企业两类管理员没法切换登录目标(用户仍可点「收起」手动折叠)。
105
- if (raw.includes("wecom"))
106
- setShowTenant(true);
107
- if (!m.includes("local"))
108
- setActive(m[0]);
116
+ setActive(m.includes("wecom") ? "wecom" : m[0]);
109
117
  })
110
118
  .catch(() => {
111
119
  setKnown(true);
@@ -113,28 +121,9 @@ export default function LoginPage() {
113
121
  });
114
122
  }, []);
115
123
 
116
- useEffect(() => {
117
- // 换票只做一次:ticket 单次有效,重复 POST 只会拿到 401 并盖掉真正的错误
118
- if (!landing || exchangedRef.current)
119
- return;
120
- exchangedRef.current = true;
121
- void (async () => {
122
- setLoading(true);
123
- setError(undefined);
124
- try {
125
- // 租户先落 store:provider 每请求读 getState,服务端会比对「头值 == 票据租户」
126
- if (landing.tenant)
127
- useTenantStore.getState().setTenant(landing.tenant);
128
- await login({ username: "", password: "", method: "wecom", ticket: landing.ticket });
129
- navigate(getRedirectPath(search), { replace: true });
130
- }
131
- catch (e) {
132
- setError(e instanceof Error ? e.message : String(e));
133
- setPhase("form");
134
- setLoading(false);
135
- }
136
- })();
137
- }, [landing, login, navigate, search]);
124
+ // 企微票据换会话已提前到模块 onInit(见 login/routes.ts::exchangeWecomTicketOnBoot):
125
+ // 成功时 URL 已在首帧前改到首页,本页不挂载;失败时 bootError 已在上面落成表单 + 错误态。
126
+ // 这里不再发第二次 POST——票据一次性,重复换只会拿到 401 并盖掉真正的错误。
138
127
 
139
128
  // 企微深链自动入口:带 ?corp= 到达(且非回跳落地 / 非出错)且企微方式可用时,
140
129
  // 直接整页发起授权——「点开即登录」,无需手动点「企业微信」tab。
@@ -142,7 +131,8 @@ export default function LoginPage() {
142
131
  useEffect(() => {
143
132
  const c = new URLSearchParams(search).get("corp")?.trim() ?? "";
144
133
  // 企微内(wxwork UA)从「应用主页」点开即自动发起授权;带 ?corp= 的深链同样自动发起。
145
- if ((!c && !inWecom) || landing || autoFiredRef.current)
134
+ // 换票失败后不再自动重发授权:否则废票 → 重新授权 → 再换一次废票会原地打转。
135
+ if ((!c && !inWecom) || landing || getWecomBootError() || autoFiredRef.current)
146
136
  return;
147
137
  if (new URLSearchParams(search).get("error")) {
148
138
  setPhase("form");
@@ -185,14 +175,22 @@ export default function LoginPage() {
185
175
  window.location.assign(`${resolveApiBase()}/auth/sso`);
186
176
  }
187
177
 
188
- function wecomLogin() {
178
+ /**
179
+ * 一键扫码登录:默认不传 corp/agent,后端 `corpSelf()` 从部署常量 `wecom_corp_*`
180
+ * 自动解析本企业(api.ts::login → resolveWecomProfile),用户不必填企业代码。
181
+ * 普通浏览器 UA 下后端 302 到企微 qrConnect 扫码页;企微内 UA 走 oauth2 静默。
182
+ * 只有「其他企业」折叠里才带 corp/agent(服务商 / 多企业共存部署)。
183
+ */
184
+ function wecomLogin(withCorp = false) {
189
185
  // 整页跳转(拿不到 302 的 opaque redirect):授权入口是**后端端点**,
190
186
  // 必须拼 API base(部署期可为 /api 或网关前缀);拼站点 base 只会落到
191
187
  // SPA 兜底页——点击后页面原地重载、一个 auth 请求都不发。
192
188
  // 企业代码不在这里写 store——只有服务端回跳带的 tenant 才是权威值。
193
- window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp, agent)}`);
189
+ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(withCorp ? corp : "", withCorp ? agent : "")}`);
194
190
  }
195
191
 
192
+ // 所有登录方式收进同一组 Tabs,同一时刻只渲染当前面板;默认选中企微(见上方 effect),
193
+ // 一键按钮首帧即可见、点一次就走,账号密码收在另一个 tab,不再上下平铺。
196
194
  const showTabs = methods.length > 1;
197
195
 
198
196
  const form = (method: LoginMethod) => (
@@ -222,50 +220,40 @@ export default function LoginPage() {
222
220
  </Form>
223
221
  );
224
222
 
225
- // 企微:只需企业代码(服务商后台生成的专属链接会预填,客户也可手输)
226
- // 受控值不走 antd 表单(Form.Item 无 name),故显式给 id/htmlFor:
227
- // 少了这层关联,标签只是视觉文本,读屏与 getByLabel 都取不到输入框。
223
+ // 企微一键扫码:默认可见、不藏在 tab 里,点一次即进入企微授权(浏览器扫码 / 企微内静默)。
224
+ // 企业代码 / 应用 ID 收进折叠,只服务服务商多企业场景;单企业部署用户什么都不用填。
228
225
  const wecomCorpId = "wecom-corp";
229
226
  const wecomAgentId = "wecom-agent";
230
- const wecomPanel = (
231
- <Form layout="vertical" onFinish={wecomLogin}>
232
- <Form.Item
233
- label={t("login:page.tenant.label")}
234
- htmlFor={wecomCorpId}
235
- extra={t("login:method.wecom.hint")}
236
- >
237
- <Input
238
- id={wecomCorpId}
239
- autoComplete="off"
240
- placeholder="ww..."
241
- value={corp}
242
- onChange={e => setCorp(e.target.value)}
243
- />
244
- </Form.Item>
245
- <Form.Item
246
- label={t("login:method.wecom.agent")}
247
- htmlFor={wecomAgentId}
248
- extra={t("login:method.wecom.agentHint")}
249
- >
250
- <Input
251
- id={wecomAgentId}
252
- autoComplete="off"
253
- placeholder="1000..."
254
- value={agent}
255
- onChange={e => setAgent(e.target.value)}
256
- />
257
- </Form.Item>
258
- <Button type="primary" htmlType="submit" block>
259
- {t("login:method.wecom.action")}
227
+ const wecomQuickPanel = (
228
+ <>
229
+ <Typography.Paragraph type="secondary">
230
+ {t("login:method.wecom.scan.hint")}
231
+ </Typography.Paragraph>
232
+ <Button type="primary" block size="large" onClick={() => wecomLogin()}>
233
+ {t("login:method.wecom.scan.action")}
260
234
  </Button>
261
- </Form>
235
+ <Button type="link" size="small" block onClick={() => setShowOtherCorp(v => !v)}>
236
+ {t(showOtherCorp ? "login:method.wecom.otherCorpHide" : "login:method.wecom.otherCorp")}
237
+ </Button>
238
+ {showOtherCorp && (
239
+ <Form layout="vertical" onFinish={() => wecomLogin(true)} style={{ marginTop: 8 }}>
240
+ <Form.Item label={t("login:page.tenant.label")} htmlFor={wecomCorpId} extra={t("login:method.wecom.hint")}>
241
+ <Input id={wecomCorpId} autoComplete="off" placeholder="ww..." value={corp} onChange={e => setCorp(e.target.value)} />
242
+ </Form.Item>
243
+ <Form.Item label={t("login:method.wecom.agent")} htmlFor={wecomAgentId} extra={t("login:method.wecom.agentHint")}>
244
+ <Input id={wecomAgentId} autoComplete="off" placeholder="1000..." value={agent} onChange={e => setAgent(e.target.value)} />
245
+ </Form.Item>
246
+ <Button htmlType="submit" block>{t("login:method.wecom.action")}</Button>
247
+ </Form>
248
+ )}
249
+ </>
262
250
  );
263
251
 
264
252
  function panelOf(m: LoginMethod) {
265
253
  if (m === "sso")
266
254
  return <Button type="primary" block onClick={ssoLogin}>{t("login:method.sso.action")}</Button>;
267
255
  if (m === "wecom")
268
- return wecomPanel;
256
+ return wecomQuickPanel;
269
257
  return form(m);
270
258
  }
271
259
 
@@ -290,19 +278,24 @@ export default function LoginPage() {
290
278
  {t("login:page.subtitle")}
291
279
  </Typography.Paragraph>
292
280
  {error && <Alert style={{ marginBottom: 16 }} type="error" message={error} showIcon />}
293
- {showTabs
294
- ? (
295
- <Tabs
296
- activeKey={active}
297
- onChange={key => setActive(key as LoginMethod)}
298
- items={methods.map(m => ({
299
- key: m,
300
- label: t(`login:method.${m}`),
301
- children: panelOf(m),
302
- }))}
303
- />
304
- )
305
- : panelOf(methods[0] ?? "local")}
281
+ {/* 登录方式发现完成前不渲染表单区:否则首帧先画账号密码、再切成默认企微 tab,
282
+ 工作台用户会看到一次无谓的闪动(见 8b06d4e1 的闪屏回归)。 */}
283
+ {!known
284
+ ? <Skeleton active paragraph={{ rows: 4 }} />
285
+ : showTabs
286
+ ? (
287
+ <Tabs
288
+ destroyInactiveTabPane
289
+ activeKey={active}
290
+ onChange={key => setActive(key as LoginMethod)}
291
+ items={methods.map(m => ({
292
+ key: m,
293
+ label: t(`login:method.${m}`),
294
+ children: panelOf(m),
295
+ }))}
296
+ />
297
+ )
298
+ : panelOf(methods[0] ?? "local")}
306
299
  </>
307
300
  );
308
301
  }
@@ -1,9 +1,59 @@
1
1
  import type { AppRouteRecordRaw, ModuleContext } from "@oj-module/runtime";
2
2
 
3
- import { useAuthStore, useTenantStore } from "@oj-module/runtime";
3
+ import { getRedirectPath, resolveSiteBase, useAuthStore, useTenantStore } from "@oj-module/runtime";
4
4
  import { lazy } from "react";
5
5
 
6
6
  import { authProvider } from "./auth-provider";
7
+ import { parseWecomLanding } from "./wecom";
8
+
9
+ /**
10
+ * onInit 阶段「企微票据换会话」失败的原因。登录页首帧读一次:非空就直接显示表单与错误,
11
+ * 不再进「认证中」态,也不再自动重发授权(避免废票 → 重授权 → 再失败的循环)。
12
+ */
13
+ let wecomBootError = "";
14
+
15
+ export function getWecomBootError(): string {
16
+ return wecomBootError;
17
+ }
18
+
19
+ /**
20
+ * 企微回跳落地(`/login?wecom_ticket=…`)的换票**提前到首帧 render 之前**。
21
+ *
22
+ * 原来放在 LoginPage 的 useEffect 里:路由先把登录页(全屏外壳 + 品牌位 + 「认证中」)
23
+ * 画出来,等一个 POST 换完票、token 落 store 后才 Navigate 到首页——工作台进入时
24
+ * 登录页明显闪一下。模块 onInit 在宿主建路由之前被 await,在这里换完票即把 URL 改成
25
+ * 首页(见下方 shell 无 AuthGuard 的说明),路由首帧就落在 /home,登录页一次都不挂载。
26
+ *
27
+ * 失败不阻断启动:保持 /login,错误记下来交给登录页首帧显示表单与错误,用户可改用
28
+ * 本地登录 / 重新授权;一次性票据在成功改 URL 时一并从地址栏与历史里抹掉。
29
+ */
30
+ async function exchangeWecomTicketOnBoot(): Promise<void> {
31
+ const landing = parseWecomLanding(window.location.search);
32
+ if (!landing)
33
+ return;
34
+ try {
35
+ // 租户由 tenant.ts::bootstrapTenant 在模块作用域从 ?tenant= 落地;这里补一次,
36
+ // 让换票请求的 X-TENANT-ID 与票据租户严格一致(服务端会比对两者)。
37
+ if (landing.tenant)
38
+ useTenantStore.getState().setTenant(landing.tenant);
39
+ await useAuthStore.getState().login({
40
+ username: "",
41
+ password: "",
42
+ method: "wecom",
43
+ ticket: landing.ticket,
44
+ });
45
+ // 宿主 shell 链**没有 AuthGuard**(免登录演示能力绕开 LayoutRoot),所以「已登录」
46
+ // 不会自动把 /login 跳走。宿主在 loadAll(即本 onInit)之后才 createBrowserRouter,
47
+ // 此刻先把 URL 换成首页,路由首帧就落在 /home,登录页一次都不挂载。
48
+ // runtime 自有链带 AuthGuard,这处 replace 也无害(守卫同样跳首页)。
49
+ const base = resolveSiteBase().replace(/\/+$/, "");
50
+ window.history.replaceState(null, "", `${base}${getRedirectPath(window.location.search)}`);
51
+ }
52
+ catch (e) {
53
+ // 失败保持 /login:bootError 交给登录页首帧显示表单与错误,不再闪「认证中」
54
+ wecomBootError = e instanceof Error ? e.message : String(e);
55
+ }
56
+ }
7
57
 
8
58
  /**
9
59
  * 登录根 `/login`(+ 强制改密 `/change-password`)。
@@ -68,4 +118,7 @@ export async function onInit(ctx: ModuleContext) {
68
118
  if (prev.token && !state.token)
69
119
  useTenantStore.getState().clear();
70
120
  });
121
+ // 必须在 provider 注册之后、首帧 render 之前:企微回跳的票据在这里换完,
122
+ // 登录页就不会先闪一下再跳首页(见 exchangeWecomTicketOnBoot 注释)。
123
+ await exchangeWecomTicketOnBoot();
71
124
  }
@@ -9,6 +9,7 @@
9
9
  "avatarRequired": "Please upload an avatar!",
10
10
  "username": "Username",
11
11
  "usernameRequired": "Please enter your username!",
12
+ "displayName": "Display name",
12
13
  "email": "Email",
13
14
  "emailRequired": "Please enter your email!",
14
15
  "phoneNumber": "Phone",
@@ -9,6 +9,7 @@
9
9
  "avatarRequired": "请上传头像!",
10
10
  "username": "用户名",
11
11
  "usernameRequired": "请输入您的用户名!",
12
+ "displayName": "显示名",
12
13
  "email": "邮箱",
13
14
  "emailRequired": "请输入您的邮箱!",
14
15
  "phoneNumber": "联系电话",