@oj-module/cli 0.2.46 → 0.2.48

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 (97) hide show
  1. package/devkit/SKILL.md +1 -1
  2. package/devkit/framework-dev.md +2 -2
  3. package/devkit/manual.md +24 -14
  4. package/devkit/module-dev-and-cdn-playground.md +2 -2
  5. package/devkit/wecom-daikaifa.md +282 -27
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +2 -2
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/templates-tenant-scope.test.ts +7 -2
  11. package/src/init.ts +1 -1
  12. package/src/market/__tests__/bootstrap.test.ts +1 -1
  13. package/src/market/__tests__/setup.test.ts +7 -7
  14. package/src/market/bootstrap.ts +1 -1
  15. package/templates/README.md +1 -1
  16. package/templates/config.yaml +26 -7
  17. package/templates/init.include.yaml +12 -0
  18. package/templates/openapi.json +2863 -549
  19. package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
  20. package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
  21. package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
  22. package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
  23. package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
  24. package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
  25. package/templates/src/_platform/api/manifest.yaml +3 -0
  26. package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
  27. package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
  28. package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
  29. package/templates/src/_platform/api/org/departments/api.ts +51 -0
  30. package/templates/src/_platform/api/org/is-member/api.ts +33 -0
  31. package/templates/src/_platform/api/org/members/api.ts +55 -0
  32. package/templates/src/_platform/api/org/picker/api.ts +59 -0
  33. package/templates/src/_platform/api/org/resolve/api.ts +38 -0
  34. package/templates/src/_platform/api/org/tree/api.ts +37 -0
  35. package/templates/src/_platform/api/org/user/api.ts +46 -0
  36. package/templates/src/_platform/api/schema.yaml +48 -0
  37. package/templates/src/_platform/api/seed.mysql.sql +10 -5
  38. package/templates/src/_platform/api/seed.pg.sql +10 -5
  39. package/templates/src/_platform/api/seed.sql +13 -5
  40. package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
  41. package/templates/src/_platform/web/__client/api.ts +170 -1
  42. package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
  43. package/templates/src/auth/api/_shared/session.ts +5 -1
  44. package/templates/src/auth/api/_shared/wecom/admin.ts +17 -10
  45. package/templates/src/auth/api/_shared/wecom/client.ts +311 -40
  46. package/templates/src/auth/api/_shared/wecom/config.ts +40 -17
  47. package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
  48. package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
  49. package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
  50. package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
  51. package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
  52. package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
  53. package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
  54. package/templates/src/auth/api/manifest.yaml +3 -0
  55. package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
  56. package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
  57. package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
  58. package/templates/src/auth/api/schema.yaml +31 -0
  59. package/templates/src/auth/api/wecom/admin/api.ts +7 -5
  60. package/templates/src/auth/api/wecom/api.ts +100 -18
  61. package/templates/src/auth/api/wecom/sync/api.ts +199 -0
  62. package/templates/src/auth/web/__client/api.schemas.ts +97 -0
  63. package/templates/src/auth/web/__client/api.ts +73 -1
  64. package/templates/src/login/web/auth-provider.ts +9 -1
  65. package/templates/src/notification/api/seed.sql +1 -1
  66. package/templates/src/system/api/departments/api.ts +142 -0
  67. package/templates/src/system/api/departments/detach/api.ts +34 -0
  68. package/templates/src/system/api/departments/members/api.ts +142 -0
  69. package/templates/src/system/api/departments/move/api.ts +39 -0
  70. package/templates/src/system/api/users/api.ts +45 -5
  71. package/templates/src/system/api/users/profile/api.ts +44 -0
  72. package/templates/src/system/web/__client/api.schemas.ts +143 -0
  73. package/templates/src/system/web/__client/api.ts +293 -1
  74. package/templates/src/system/web/entry.ts +6 -0
  75. package/templates/src/system/web/locales/en-US.json +113 -7
  76. package/templates/src/system/web/locales/zh-CN.json +113 -7
  77. package/templates/src/system/web/pages/departments/index.tsx +346 -0
  78. package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
  79. package/templates/src/system/web/pages/departments/tree.ts +66 -0
  80. package/templates/src/system/web/pages/errors.ts +1 -1
  81. package/templates/src/system/web/pages/users/index.tsx +49 -7
  82. package/templates/src/system/web/pages/wecom/index.tsx +16 -1
  83. package/templates/tests/l1-builder.test.ts +1 -1
  84. package/templates/tests/org-departments.spec.ts +311 -0
  85. package/templates/tests/org-read.spec.ts +469 -0
  86. package/templates/tests/rbac-contacts.spec.ts +13 -3
  87. package/templates/tests/rbac-seed.spec.ts +11 -0
  88. package/templates/tests/seed-role-accounts.spec.ts +6 -2
  89. package/templates/tests/wecom-admin.spec.ts +64 -42
  90. package/templates/tests/wecom-client-timeout.spec.ts +99 -0
  91. package/templates/tests/wecom-connector.spec.ts +115 -11
  92. package/templates/tests/wecom-crypt.spec.ts +12 -3
  93. package/templates/tests/wecom-endpoints.spec.ts +182 -14
  94. package/templates/tests/wecom-org-sync.spec.ts +598 -0
  95. package/templates/tests/wecom-sync.spec.ts +286 -0
  96. package/vendor/host-versions.json +3 -3
  97. package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
@@ -0,0 +1,257 @@
1
+ // 组织架构页顶部的企微同步状态条(方案 §5.4 / §6.4 / §7)。
2
+ //
3
+ // 一条纪律:**状态条只在「本租户装了企微」时才出现**。没装企微的站点(纯 CRUD 用户)看到的
4
+ // 就是一页干净的组织架构——所以 `connected=false`、无权限(403)、没有授权记录(400)都不画红条。
5
+ //
6
+ // 交互形态由后端的分片设计决定:一次 start 只推一片,调用方轮询。这里把它包成「一次点击 =
7
+ // 一直推到 done/failed」,中间按限速配额留最小间隔;暂停/失败后按钮变「继续同步」,进度不丢。
8
+ import { request } from "@oj-module/runtime";
9
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
10
+ import { Alert, App, Button, Progress, Space, Typography } from "antd";
11
+ import { useEffect, useRef, useState } from "react";
12
+ import { useTranslation } from "react-i18next";
13
+
14
+ import { errMsg, statusOf } from "../errors";
15
+
16
+ /** 与后端 `auth/wecom/sync/api.ts` 的 VIEW 契约同形(跨模块调用,走全局 request 而非生成本模块 client) */
17
+ interface SyncView {
18
+ connected: boolean
19
+ authMode: number
20
+ profileStatus: "active" | "disabled" | null
21
+ status: "idle" | "running" | "done" | "failed" | "cancelled"
22
+ phase: "departments" | "members" | "reconcile"
23
+ stats: { departments: number, members: number, skipped: number, reconciled: boolean }
24
+ progress: { done: number, total: number }
25
+ errCode: string | null
26
+ errDetail: { errcode?: number, phase: string, deptId?: string, at: number } | null
27
+ startedAt: number
28
+ finishedAt: number
29
+ updateTime: number
30
+ }
31
+
32
+ const KEY = ["system", "wecom-sync"] as const;
33
+
34
+ /** 单片推完到下一次 start 的最小间隔:后端限速 10 次/分,串行驱动别自己撞 429 */
35
+ const MIN_GAP_MS = 6000;
36
+ /** 一轮点击最多推多少片(12 秒/片 ≈ 12 分钟):兜底防呆,正常企业两三次就到底 */
37
+ const MAX_SLICES = 60;
38
+
39
+ async function fetchStatus(): Promise<SyncView> {
40
+ const env = await request.get("auth/wecom/sync/status").json<{ code: number, data?: SyncView }>();
41
+ if (!env.data)
42
+ throw new Error("企业微信同步状态读取失败");
43
+ return env.data;
44
+ }
45
+
46
+ async function pushSlice(): Promise<SyncView> {
47
+ const env = await request.post("auth/wecom/sync/start", { json: {} }).json<{ code: number, data?: SyncView }>();
48
+ if (!env.data)
49
+ throw new Error("企业微信同步未能启动");
50
+ return env.data;
51
+ }
52
+
53
+ async function cancelSync(): Promise<SyncView> {
54
+ const env = await request.post("auth/wecom/sync/cancel", { json: {} }).json<{ code: number, data?: SyncView }>();
55
+ if (!env.data)
56
+ throw new Error("取消同步失败");
57
+ return env.data;
58
+ }
59
+
60
+ function sleep(ms: number): Promise<void> {
61
+ return new Promise((resolve) => {
62
+ setTimeout(resolve, ms);
63
+ });
64
+ }
65
+
66
+ /**
67
+ * 「X 分钟前」:用**服务端时间戳之差**(updateTime - finishedAt)而不是浏览器 Date.now(),
68
+ * 一来渲染期不调时钟(纯函数),二来服务器与浏览器时区/时钟偏差不会把「刚刚」算成「8 小时前」。
69
+ */
70
+ function agoLabel(secs: number, t: (k: string, o?: Record<string, unknown>) => string): string {
71
+ if (secs < 60)
72
+ return t("system:wecom.sync.ago.justNow");
73
+ if (secs < 3600)
74
+ return t("system:wecom.sync.ago.minutes", { count: Math.floor(secs / 60) });
75
+ if (secs < 86400)
76
+ return t("system:wecom.sync.ago.hours", { count: Math.floor(secs / 3600) });
77
+ return t("system:wecom.sync.ago.days", { count: Math.floor(secs / 86400) });
78
+ }
79
+
80
+ /** 错码 → 卡片文案(§7 表:谁处理、怎么办,别让企业管理员对着错误码发呆) */
81
+ function errorCard(code: string, t: (k: string, o?: Record<string, unknown>) => string): { title: string, hint: string } {
82
+ const pick = (kind: string) => ({
83
+ title: t(`system:wecom.sync.err.${kind}.title`, { code }),
84
+ hint: t(`system:wecom.sync.err.${kind}.hint`, { code }),
85
+ });
86
+ if (code === "60011" || code === "48002")
87
+ return pick("permission");
88
+ if (code === "45009" || code === "45033")
89
+ return pick("rateLimited");
90
+ if (code === "60020")
91
+ return pick("ipWhitelist");
92
+ if (code === "timeout" || code === "network")
93
+ return pick("network");
94
+ return pick("unknown");
95
+ }
96
+
97
+ /** 状态条:只画「能不能同步 / 同步到哪了 / 失败了怎么办」,不暴露「三轨」这类内部名词 */
98
+ export function WecomSyncBar({ onFinished }: { onFinished: () => void }) {
99
+ const { t } = useTranslation();
100
+ const { message } = App.useApp();
101
+ const queryClient = useQueryClient();
102
+ const [driving, setDriving] = useState(false);
103
+ const [cancelling, setCancelling] = useState(false);
104
+ // 组件卸载后停止继续推片:否则离开页面的人在后台被反复打上游
105
+ const aliveRef = useRef(true);
106
+ useEffect(() => {
107
+ return () => {
108
+ aliveRef.current = false;
109
+ };
110
+ }, []);
111
+
112
+ const query = useQuery({
113
+ queryKey: KEY,
114
+ queryFn: fetchStatus,
115
+ retry: false,
116
+ // 只有「别人在推」(另一标签页 / 上次未收尾)时才轮询;自己在推时每一片都带最新状态
117
+ refetchInterval: q => (!driving && q.state.data?.status === "running" ? 2000 : false),
118
+ });
119
+
120
+ const setView = (v: SyncView) => queryClient.setQueryData(KEY, v);
121
+
122
+ const drive = async () => {
123
+ setDriving(true);
124
+ let last: SyncView | null = null;
125
+ try {
126
+ for (let i = 0; i < MAX_SLICES; i++) {
127
+ const startedAt = Date.now();
128
+ const view = await pushSlice();
129
+ if (!aliveRef.current)
130
+ return;
131
+ last = view;
132
+ setView(view);
133
+ if (view.status !== "running") {
134
+ if (view.status === "done")
135
+ message.success(t("system:wecom.sync.done", { depts: view.stats.departments, members: view.stats.members }));
136
+ onFinished();
137
+ return;
138
+ }
139
+ // 短片(限流退避 / 空转)会连着打:补足间隔,别自己撞后端 10 次/分的限速
140
+ const spent = Date.now() - startedAt;
141
+ if (spent < MIN_GAP_MS) {
142
+ await sleep(MIN_GAP_MS - spent);
143
+ if (!aliveRef.current)
144
+ return;
145
+ }
146
+ }
147
+ message.warning(t("system:wecom.sync.paused", { depts: last?.stats.departments ?? 0 }));
148
+ }
149
+ catch (e) {
150
+ message.error(errMsg(e));
151
+ }
152
+ finally {
153
+ if (aliveRef.current)
154
+ setDriving(false);
155
+ queryClient.invalidateQueries({ queryKey: KEY });
156
+ }
157
+ };
158
+
159
+ const stop = async () => {
160
+ setCancelling(true);
161
+ try {
162
+ setView(await cancelSync());
163
+ message.success(t("system:wecom.sync.cancelled"));
164
+ }
165
+ catch (e) {
166
+ message.error(errMsg(e));
167
+ }
168
+ finally {
169
+ if (aliveRef.current)
170
+ setCancelling(false);
171
+ }
172
+ };
173
+
174
+ // 未连接 / 无权限 / 站点没接企微:整条不出现。403 与 400 都是「正常情况下就该没有」,
175
+ // 别的错误(500、网络)才值得占版面,让人知道「是有同步功能但它坏了」。
176
+ if (query.error && statusOf(query.error) !== 403 && statusOf(query.error) !== 400)
177
+ return <Alert type="warning" showIcon message={errMsg(query.error)} />;
178
+
179
+ const view = query.data;
180
+ if (!view || !view.connected)
181
+ return null;
182
+
183
+ if (view.authMode !== 0) {
184
+ return (
185
+ <Alert
186
+ type="warning"
187
+ showIcon
188
+ message={t("system:wecom.sync.memberAuth.title")}
189
+ description={t("system:wecom.sync.memberAuth.hint")}
190
+ />
191
+ );
192
+ }
193
+
194
+ if (view.profileStatus === "disabled") {
195
+ return (
196
+ <Alert
197
+ type="warning"
198
+ showIcon
199
+ message={t("system:wecom.sync.frozen.title")}
200
+ description={t("system:wecom.sync.frozen.hint")}
201
+ />
202
+ );
203
+ }
204
+
205
+ const running = view.status === "running";
206
+ const lastAt = view.finishedAt || view.updateTime;
207
+ const percent = view.progress.total > 0 ? Math.round((view.progress.done / view.progress.total) * 100) : 0;
208
+
209
+ const headline = running
210
+ ? (view.progress.total > 0
211
+ ? t("system:wecom.sync.syncingProgress", { done: view.progress.done, total: view.progress.total })
212
+ : t("system:wecom.sync.syncing"))
213
+ : lastAt > 0
214
+ ? `${t("system:wecom.sync.updatedAt", { time: agoLabel(Math.max(0, view.updateTime - lastAt), t) })} · ${t("system:wecom.sync.auto")}`
215
+ : t("system:wecom.sync.neverSynced");
216
+
217
+ return (
218
+ <Alert
219
+ type={view.status === "failed" ? "error" : "info"}
220
+ showIcon
221
+ message={(
222
+ <Space size="middle" wrap>
223
+ <Typography.Text>{headline}</Typography.Text>
224
+ {!running && view.stats.skipped > 0 && (
225
+ <Typography.Text type="warning">
226
+ {t("system:wecom.sync.skipped", { count: view.stats.skipped })}
227
+ </Typography.Text>
228
+ )}
229
+ </Space>
230
+ )}
231
+ description={view.status === "failed" && view.errCode
232
+ ? (
233
+ <Space direction="vertical" size={0}>
234
+ <Typography.Text strong>{errorCard(view.errCode, t).title}</Typography.Text>
235
+ <Typography.Text type="secondary">{errorCard(view.errCode, t).hint}</Typography.Text>
236
+ </Space>
237
+ )
238
+ : running && view.progress.total > 0
239
+ ? <Progress percent={percent} size="small" style={{ maxWidth: 320 }} />
240
+ : undefined}
241
+ action={(
242
+ <Space direction="vertical" size={4}>
243
+ <Button type="primary" size="small" loading={driving} onClick={drive}>
244
+ {running
245
+ ? t("system:wecom.sync.resume")
246
+ : view.status === "failed" ? t("system:wecom.sync.retry") : t("system:wecom.sync.syncNow")}
247
+ </Button>
248
+ {running && (
249
+ <Button size="small" type="text" danger loading={cancelling} onClick={stop}>
250
+ {t("system:wecom.sync.cancel")}
251
+ </Button>
252
+ )}
253
+ </Space>
254
+ )}
255
+ />
256
+ );
257
+ }
@@ -0,0 +1,66 @@
1
+ import type { TreeDataNode } from "antd";
2
+
3
+ /**
4
+ * 部门平表 → 树 / 子孙闭包。两处消费:部门管理页自己画树、用户页按部门(含子部门)筛选。
5
+ * 只依赖 `id`/`parentId` 两个字段,所以既能吃 `systemDepartmentsGet` 的行,也能吃别的部门投影。
6
+ */
7
+ export interface DeptLike {
8
+ id: number
9
+ parentId: number | null
10
+ }
11
+
12
+ /**
13
+ * 组装 antd 树数据。后端下发平表(响应契约不支持递归 schema),树在这里拼。
14
+ *
15
+ * 两条脏数据防线(外部源可见范围裁剪的必然产物,不是 bug):
16
+ * · emitted:每个节点只挂一次——成环时不会无限递归,环上未挂到的节点补挂到根;
17
+ * · depth:兜底,防御性上限,正常数据远达不到。
18
+ */
19
+ export function buildTreeData<T extends DeptLike>(rows: T[], titleOf: (d: T) => React.ReactNode): TreeDataNode[] {
20
+ const byId = new Map(rows.map(d => [d.id, d]));
21
+ const childIds = new Map<number, number[]>();
22
+ for (const d of rows) {
23
+ if (d.parentId != null && byId.has(d.parentId))
24
+ (childIds.get(d.parentId) ?? childIds.set(d.parentId, []).get(d.parentId)!).push(d.id);
25
+ }
26
+ const emitted = new Set<number>();
27
+ const build = (d: T, depth: number): TreeDataNode | null => {
28
+ if (emitted.has(d.id) || depth > 32)
29
+ return null;
30
+ emitted.add(d.id);
31
+ return {
32
+ key: d.id,
33
+ title: titleOf(d),
34
+ children: (childIds.get(d.id) ?? [])
35
+ .map(id => build(byId.get(id)!, depth + 1))
36
+ .filter((n): n is TreeDataNode => n !== null),
37
+ };
38
+ };
39
+ const roots = rows.filter(d => d.parentId == null || !byId.has(d.parentId)).map(d => build(d, 0));
40
+ // 环上节点:父存在但永远轮不到挂(父在环里)→ 补挂到根,保证一个不丢
41
+ for (const d of rows) {
42
+ if (!emitted.has(d.id))
43
+ roots.push(build(d, 0));
44
+ }
45
+ return roots.filter((n): n is TreeDataNode => n !== null);
46
+ }
47
+
48
+ /**
49
+ * 从 `roots` 出发向下取「自己 + 全部子孙」的 id 闭包。
50
+ *
51
+ * 两处用途:部门管理页挡「把父部门挂到自己孙部门下」(后端判环 400);用户页筛部门时
52
+ * 把子部门的人一并算进来。成环安全——每个节点最多进一次集合,循环必然终止。
53
+ */
54
+ export function subtreeIds<T extends DeptLike>(rows: T[], roots: Iterable<number>): Set<number> {
55
+ const ids = new Set<number>(roots);
56
+ for (let grew = true; grew;) {
57
+ grew = false;
58
+ for (const d of rows) {
59
+ if (d.parentId != null && ids.has(d.parentId) && !ids.has(d.id)) {
60
+ ids.add(d.id);
61
+ grew = true;
62
+ }
63
+ }
64
+ }
65
+ return ids;
66
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /** HTTP 状态:ContractApiError.code 优先,HTTPError 回落 response.status */
8
- function statusOf(e: unknown): number | undefined {
8
+ export function statusOf(e: unknown): number | undefined {
9
9
  const err = e as { code?: number, response?: Response } | null;
10
10
  if (typeof err?.code === "number")
11
11
  return err.code;
@@ -3,13 +3,14 @@ import type { SystemUsersGetData } from "../../__client/api";
3
3
  import { PlusOutlined } from "@ant-design/icons";
4
4
  import { useUserStore } from "@oj-module/runtime";
5
5
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6
- import { App, Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Tag } from "antd";
7
- import { useState } from "react";
6
+ import { App, Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Tag, TreeSelect, Typography } from "antd";
7
+ import { useMemo, useState } from "react";
8
8
  import { useTranslation } from "react-i18next";
9
9
 
10
10
  import { useNavigate } from "react-router";
11
11
 
12
- import { systemUsersForbiddenPut, systemUsersGet, systemUsersPasswordPut, systemUsersPost } from "../../__client/api";
12
+ import { systemDepartmentsGet, systemUsersForbiddenPut, systemUsersGet, systemUsersPasswordPut, systemUsersPost } from "../../__client/api";
13
+ import { buildTreeData, subtreeIds } from "../departments/tree";
13
14
  import { explainError, isForbidden } from "../errors";
14
15
  import { ForbiddenPanel } from "../forbidden";
15
16
 
@@ -19,6 +20,7 @@ type UserRow = SystemUsersGetData[number];
19
20
  * 用户管理(C1):GET /system/users 列表 + 建号 / 禁用切换 / 重置口令。
20
21
  * 本人行禁用一切自操作(后端 assertNotSelf 400 兜底,前端不给会失败的按钮,R3);
21
22
  * 成员资格入口仅 superadmin 可见(成员资格写是 superadmin-only,规格 §4)。
23
+ * 部门列标出主部门,顶部可按部门筛选(含子部门)——筛选在前端做,名单本来就是一页全量。
22
24
  */
23
25
  export default function UsersPage() {
24
26
  const { t } = useTranslation();
@@ -33,8 +35,11 @@ export default function UsersPage() {
33
35
  const [createForm] = Form.useForm<{ username: string, password: string, displayName?: string, email?: string }>();
34
36
  const [pwdTarget, setPwdTarget] = useState<UserRow | null>(null);
35
37
  const [pwdForm] = Form.useForm<{ password: string }>();
38
+ const [deptId, setDeptId] = useState<number | null>(null);
36
39
 
37
40
  const usersQuery = useQuery({ queryKey: ["system", "users"], queryFn: systemUsersGet });
41
+ // 与部门页共用同一份缓存键:那边改完部门,这边的筛选树也跟着新
42
+ const deptQuery = useQuery({ queryKey: ["system", "departments"], queryFn: systemDepartmentsGet });
38
43
 
39
44
  const invalidate = () => queryClient.invalidateQueries({ queryKey: ["system", "users"] });
40
45
 
@@ -66,12 +71,34 @@ export default function UsersPage() {
66
71
  onError: e => message.error(explainError(e, t)),
67
72
  });
68
73
 
74
+ const deptRows = useMemo(() => deptQuery.data ?? [], [deptQuery.data]);
75
+ const deptTree = useMemo(() => buildTreeData(deptRows, d => d.name ?? `#${d.id}`), [deptRows]);
76
+ // 选中部门 + 全部子部门:筛「研发部」要能带出「研发部/前端组」的人
77
+ const scope = useMemo(() => (deptId == null ? null : subtreeIds(deptRows, [deptId])), [deptRows, deptId]);
78
+ const visibleUsers = useMemo(() => {
79
+ const all = usersQuery.data ?? [];
80
+ return scope ? all.filter(u => u.departments.some(d => scope.has(d.id))) : all;
81
+ }, [usersQuery.data, scope]);
82
+
69
83
  if (usersQuery.error && isForbidden(usersQuery.error))
70
84
  return <ForbiddenPanel t={t} error={usersQuery.error} />;
71
85
 
72
86
  const columns = [
73
87
  { title: t("system:users.columns.username"), dataIndex: "username" },
74
88
  { title: t("system:users.columns.displayName"), dataIndex: "display_name" },
89
+ { title: t("system:users.columns.position"), dataIndex: "position" },
90
+ {
91
+ title: t("system:users.columns.departments"),
92
+ dataIndex: "departments",
93
+ render: (departments: UserRow["departments"]) => departments.length === 0
94
+ ? <Typography.Text type="secondary">—</Typography.Text>
95
+ : departments.map(d => (
96
+ <Tag key={d.id} color={d.isMain ? "blue" : undefined}>
97
+ {d.name ?? `#${d.id}`}
98
+ {d.isMain ? ` · ${t("system:users.mainDept")}` : ""}
99
+ </Tag>
100
+ )),
101
+ },
75
102
  { title: t("system:users.columns.email"), dataIndex: "email" },
76
103
  {
77
104
  title: t("system:users.columns.roles"),
@@ -116,13 +143,28 @@ export default function UsersPage() {
116
143
 
117
144
  return (
118
145
  <Space direction="vertical" style={{ width: "100%", paddingTop: 16 }}>
119
- <Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
120
- {t("system:users.create")}
121
- </Button>
146
+ <Space wrap>
147
+ <Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
148
+ {t("system:users.create")}
149
+ </Button>
150
+ <TreeSelect
151
+ allowClear
152
+ showSearch
153
+ treeNodeFilterProp="title"
154
+ loading={deptQuery.isLoading}
155
+ style={{ width: 240 }}
156
+ placeholder={t("system:users.filterDept")}
157
+ treeData={deptTree}
158
+ // 树是按 antd Tree 口径拼的(key/title),TreeSelect 认的是 value —— 这里换名映射
159
+ fieldNames={{ label: "title", value: "key", children: "children" }}
160
+ value={deptId ?? undefined}
161
+ onChange={(v?: number) => setDeptId(v ?? null)}
162
+ />
163
+ </Space>
122
164
  <Table
123
165
  rowKey="id"
124
166
  loading={usersQuery.isLoading}
125
- dataSource={usersQuery.data ?? []}
167
+ dataSource={visibleUsers}
126
168
  columns={columns}
127
169
  pagination={{ hideOnSinglePage: true }}
128
170
  />
@@ -16,6 +16,8 @@ interface AppRow {
16
16
  env: string
17
17
  corp_name: string | null
18
18
  status: "active" | "disabled"
19
+ /** 0=管理员授权 / 1=成员授权:成员授权下这家企业的应用做不了组织架构同步 */
20
+ auth_mode: number
19
21
  installed_at: number
20
22
  update_time: number
21
23
  }
@@ -51,7 +53,8 @@ async function createInstall(): Promise<InstallInfo> {
51
53
  * 企微代开发后台(服务商运营面,superadmin 独占):
52
54
  * · 已授权企业 × 应用清单(同一企业测试/正式两个应用各一行);
53
55
  * · 启停(停用企业最后一个应用时后端同时吊销该企业会话);
54
- * · 开通入口(预授权码 10 分钟有效,每次点都重新换)。
56
+ * · 开通入口(点「生成开通链接」→ 后端调企微 get_customized_auth_url 换 qrcode_url,
57
+ * 企业管理员扫/开即授权,授权成功由 create_auth 回调落库)。
55
58
  */
56
59
  export default function WecomAdminPage() {
57
60
  const { t } = useTranslation();
@@ -119,6 +122,15 @@ export default function WecomAdminPage() {
119
122
  },
120
123
  { title: t("system:wecom.col.agent"), dataIndex: "agent_id" },
121
124
  { title: t("system:wecom.col.env"), dataIndex: "env", render: (v: string) => <Tag>{v}</Tag> },
125
+ {
126
+ // 权限三态:成员授权是「装了也同步不了」的硬约束(企微侧通讯录接口一律 60011),
127
+ // 摆在清单上,服务商不用点进去试一遍才知道
128
+ title: t("system:wecom.col.authMode"),
129
+ dataIndex: "auth_mode",
130
+ render: (v: number) => (v === 0
131
+ ? <Tag color="green">{t("system:wecom.authModeAdmin")}</Tag>
132
+ : <Tag color="orange">{t("system:wecom.authModeMember")}</Tag>),
133
+ },
122
134
  {
123
135
  title: t("system:wecom.col.status"),
124
136
  dataIndex: "status",
@@ -190,6 +202,9 @@ export default function WecomAdminPage() {
190
202
  )}
191
203
  </Card>
192
204
  <Card title={t("system:wecom.appsTitle")}>
205
+ <Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
206
+ {t("system:wecom.authModeHint")}
207
+ </Typography.Paragraph>
193
208
  <Table<AppRow>
194
209
  rowKey={row => `${row.suite_id}:${row.corp_id}:${row.agent_id}`}
195
210
  size="small"
@@ -11,7 +11,7 @@
11
11
  // ③ notification:join ON 值条件 + UNION ALL + 字面量列 {lit,as} 的真渲染(场景 41 同款)。
12
12
  //
13
13
  // 登录账号:fixtures 的 l1tester(src/_platform/fixtures/l1-test-user.sql,仅 oj test 灌入)。
14
- // 不用 seed 的 admin 等开发账号:初始口令 123456 会随用户在 dev 库改密漂移——
14
+ // 不用 seed 的 admin 等开发账号:初始口令 aA123456 会随用户在 dev 库改密漂移——
15
15
  // L1 绑 seed 账号曾实测 401。l1tester 为 additive 专用账号(must_change=0)。
16
16
  //
17
17
  // 运行:cd packages/cli/templates/api && ~/.local/share/oj/oj test -c config.yaml -d src -t tests