@oj-module/cli 0.2.47 → 0.2.49

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 (101) hide show
  1. package/devkit/README.md +1 -1
  2. package/devkit/SKILL.md +23 -4
  3. package/devkit/framework-dev.md +2 -2
  4. package/devkit/manual.md +221 -14
  5. package/devkit/module-dev-and-cdn-playground.md +2 -2
  6. package/devkit/wecom-daikaifa.md +242 -14
  7. package/package.json +2 -2
  8. package/shell-dist/assets/runtime.js +2 -2
  9. package/shell-dist/index.html +2 -2
  10. package/shell-dist/versions.json +3 -3
  11. package/src/__tests__/build-backend.test.ts +80 -0
  12. package/src/__tests__/templates-tenant-scope.test.ts +7 -2
  13. package/src/build.ts +50 -0
  14. package/src/init.ts +1 -1
  15. package/src/market/__tests__/bootstrap.test.ts +1 -1
  16. package/src/market/__tests__/setup.test.ts +7 -7
  17. package/src/market/bootstrap.ts +1 -1
  18. package/templates/README.md +1 -1
  19. package/templates/init.include.yaml +12 -0
  20. package/templates/openapi.json +2863 -549
  21. package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
  22. package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
  23. package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
  24. package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
  25. package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
  26. package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
  27. package/templates/src/_platform/api/manifest.yaml +3 -0
  28. package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
  29. package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
  30. package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
  31. package/templates/src/_platform/api/org/departments/api.ts +51 -0
  32. package/templates/src/_platform/api/org/is-member/api.ts +33 -0
  33. package/templates/src/_platform/api/org/members/api.ts +55 -0
  34. package/templates/src/_platform/api/org/picker/api.ts +59 -0
  35. package/templates/src/_platform/api/org/resolve/api.ts +38 -0
  36. package/templates/src/_platform/api/org/tree/api.ts +37 -0
  37. package/templates/src/_platform/api/org/user/api.ts +46 -0
  38. package/templates/src/_platform/api/schema.yaml +48 -0
  39. package/templates/src/_platform/api/seed.mysql.sql +10 -5
  40. package/templates/src/_platform/api/seed.pg.sql +10 -5
  41. package/templates/src/_platform/api/seed.sql +13 -5
  42. package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
  43. package/templates/src/_platform/web/__client/api.ts +170 -1
  44. package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
  45. package/templates/src/auth/api/_shared/session.ts +5 -1
  46. package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
  47. package/templates/src/auth/api/_shared/wecom/client.ts +280 -32
  48. package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
  49. package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
  50. package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
  51. package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
  52. package/templates/src/auth/api/_shared/wecom/login.ts +188 -5
  53. package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
  54. package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
  55. package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
  56. package/templates/src/auth/api/_shared/wecom/sync.ts +501 -0
  57. package/templates/src/auth/api/manifest.yaml +3 -0
  58. package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
  59. package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
  60. package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
  61. package/templates/src/auth/api/schema.yaml +31 -0
  62. package/templates/src/auth/api/wecom/admin/api.ts +3 -1
  63. package/templates/src/auth/api/wecom/api.ts +155 -35
  64. package/templates/src/auth/api/wecom/sync/api.ts +199 -0
  65. package/templates/src/auth/web/__client/api.schemas.ts +97 -0
  66. package/templates/src/auth/web/__client/api.ts +73 -1
  67. package/templates/src/login/web/locales/en-US.json +2 -0
  68. package/templates/src/login/web/locales/zh-CN.json +2 -0
  69. package/templates/src/login/web/pages/login.tsx +39 -3
  70. package/templates/src/notification/api/seed.sql +1 -1
  71. package/templates/src/system/api/departments/api.ts +142 -0
  72. package/templates/src/system/api/departments/detach/api.ts +34 -0
  73. package/templates/src/system/api/departments/members/api.ts +142 -0
  74. package/templates/src/system/api/departments/move/api.ts +39 -0
  75. package/templates/src/system/api/users/api.ts +45 -5
  76. package/templates/src/system/api/users/profile/api.ts +44 -0
  77. package/templates/src/system/web/__client/api.schemas.ts +143 -0
  78. package/templates/src/system/web/__client/api.ts +293 -1
  79. package/templates/src/system/web/entry.ts +6 -0
  80. package/templates/src/system/web/locales/en-US.json +112 -6
  81. package/templates/src/system/web/locales/zh-CN.json +112 -6
  82. package/templates/src/system/web/pages/departments/index.tsx +346 -0
  83. package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
  84. package/templates/src/system/web/pages/departments/tree.ts +66 -0
  85. package/templates/src/system/web/pages/errors.ts +1 -1
  86. package/templates/src/system/web/pages/users/index.tsx +49 -7
  87. package/templates/src/system/web/pages/wecom/index.tsx +14 -0
  88. package/templates/tests/l1-builder.test.ts +1 -1
  89. package/templates/tests/org-departments.spec.ts +311 -0
  90. package/templates/tests/org-read.spec.ts +469 -0
  91. package/templates/tests/rbac-contacts.spec.ts +13 -3
  92. package/templates/tests/rbac-seed.spec.ts +11 -0
  93. package/templates/tests/seed-role-accounts.spec.ts +6 -2
  94. package/templates/tests/wecom-admin.spec.ts +1 -0
  95. package/templates/tests/wecom-connector.spec.ts +146 -10
  96. package/templates/tests/wecom-crypt.spec.ts +12 -3
  97. package/templates/tests/wecom-endpoints.spec.ts +247 -19
  98. package/templates/tests/wecom-org-sync.spec.ts +598 -0
  99. package/templates/tests/wecom-sync.spec.ts +286 -0
  100. package/vendor/host-versions.json +3 -3
  101. package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
@@ -0,0 +1,346 @@
1
+ import type { SystemDepartmentsGetData, SystemDepartmentsMembersGetData } from "../../__client/api";
2
+
3
+ import { PlusOutlined } from "@ant-design/icons";
4
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import {
6
+ App,
7
+ Button,
8
+ Form,
9
+ Input,
10
+ Modal,
11
+ Popconfirm,
12
+ Select,
13
+ Space,
14
+ Table,
15
+ Tag,
16
+ Tree,
17
+ TreeSelect,
18
+ Typography,
19
+ } from "antd";
20
+ import { useMemo, useState } from "react";
21
+ import { useTranslation } from "react-i18next";
22
+
23
+ import {
24
+ systemDepartmentsDelete,
25
+ systemDepartmentsDetachPost,
26
+ systemDepartmentsGet,
27
+ systemDepartmentsMembersDelete,
28
+ systemDepartmentsMembersGet,
29
+ systemDepartmentsMembersPost,
30
+ systemDepartmentsMembersPut,
31
+ systemDepartmentsMovePut,
32
+ systemDepartmentsPost,
33
+ systemDepartmentsPut,
34
+ systemUsersGet,
35
+ } from "../../__client/api";
36
+ import { explainError, isForbidden } from "../errors";
37
+ import { ForbiddenPanel } from "../forbidden";
38
+ import { WecomSyncBar } from "./sync-bar";
39
+ import { buildTreeData, subtreeIds } from "./tree";
40
+
41
+ type DeptRow = SystemDepartmentsGetData[number];
42
+ type MemberRow = SystemDepartmentsMembersGetData[number];
43
+
44
+ /** 归属权 → 人话标签(local = 本站可改;其余 = 适配器管辖,改前须「转为本地维护」) */
45
+ function originTag(origin: string, t: (k: string, opts?: { defaultValue?: string }) => string): React.ReactNode {
46
+ if (origin === "local")
47
+ return <Tag>{t("system:departments.origin.local")}</Tag>;
48
+ return <Tag color="blue">{t(`system:departments.origin.${origin}`, { defaultValue: origin })}</Tag>;
49
+ }
50
+
51
+ /** 部门管理页:树形增删改 + 成员维护 + 转为本地维护(D14 保底出口)。 */
52
+ export default function DepartmentsPage() {
53
+ const { t } = useTranslation();
54
+ const { message } = App.useApp();
55
+ const queryClient = useQueryClient();
56
+
57
+ const [editOpen, setEditOpen] = useState(false);
58
+ const [editing, setEditing] = useState<DeptRow | null>(null);
59
+ const [form] = Form.useForm<{ name: string, nameEn?: string, parentId?: number | null, orderNo?: number }>();
60
+ const [memberDept, setMemberDept] = useState<DeptRow | null>(null);
61
+ const [addUid, setAddUid] = useState<number | null>(null);
62
+
63
+ const deptsQuery = useQuery({ queryKey: ["system", "departments"], queryFn: systemDepartmentsGet });
64
+ const usersQuery = useQuery({ queryKey: ["system", "users"], queryFn: systemUsersGet });
65
+ const membersQuery = useQuery({
66
+ queryKey: ["system", "departments", "members", memberDept?.id],
67
+ queryFn: () => systemDepartmentsMembersGet({ id: memberDept!.id }),
68
+ enabled: memberDept !== null,
69
+ });
70
+
71
+ const rows = useMemo(() => deptsQuery.data ?? [], [deptsQuery.data]);
72
+ const invalidateAll = () => {
73
+ queryClient.invalidateQueries({ queryKey: ["system", "departments"] });
74
+ queryClient.invalidateQueries({ queryKey: ["system", "departments", "members"] });
75
+ };
76
+ const onError = (e: unknown) => message.error(explainError(e, t));
77
+
78
+ const createMut = useMutation({
79
+ mutationFn: (input: { name: string, nameEn?: string | null, parentId?: number | null, orderNo?: number }) => systemDepartmentsPost(input),
80
+ onSuccess: () => {
81
+ message.success(t("system:common.saved"));
82
+ setEditOpen(false);
83
+ form.resetFields();
84
+ invalidateAll();
85
+ },
86
+ onError,
87
+ });
88
+
89
+ const updateMut = useMutation({
90
+ mutationFn: (input: { id: number, body: { name?: string, nameEn?: string | null, orderNo?: number } }) => systemDepartmentsPut({ query: { id: input.id }, body: input.body }),
91
+ onSuccess: () => {
92
+ message.success(t("system:common.saved"));
93
+ setEditOpen(false);
94
+ invalidateAll();
95
+ },
96
+ onError,
97
+ });
98
+
99
+ const moveMut = useMutation({
100
+ mutationFn: (input: { id: number, parentId: number | null }) => systemDepartmentsMovePut({ query: { id: input.id }, body: { parentId: input.parentId } }),
101
+ onSuccess: invalidateAll,
102
+ onError,
103
+ });
104
+
105
+ const deleteMut = useMutation({
106
+ mutationFn: (id: number) => systemDepartmentsDelete({ id }),
107
+ onSuccess: invalidateAll,
108
+ onError,
109
+ });
110
+
111
+ const detachMut = useMutation({
112
+ mutationFn: (id: number) => systemDepartmentsDetachPost({ id }),
113
+ onSuccess: () => {
114
+ message.success(t("system:common.saved"));
115
+ invalidateAll();
116
+ },
117
+ onError,
118
+ });
119
+
120
+ const addMemberMut = useMutation({
121
+ mutationFn: (input: { id: number, userId: number }) => systemDepartmentsMembersPost({ query: { id: input.id }, body: { userId: input.userId } }),
122
+ onSuccess: () => {
123
+ setAddUid(null);
124
+ invalidateAll();
125
+ },
126
+ onError,
127
+ });
128
+
129
+ const updateMemberMut = useMutation({
130
+ mutationFn: (input: { id: number, userId: number, body: { isLeader?: boolean, isMain?: boolean } }) => systemDepartmentsMembersPut({ query: { id: input.id, userId: input.userId }, body: input.body }),
131
+ onSuccess: invalidateAll,
132
+ onError,
133
+ });
134
+
135
+ const removeMemberMut = useMutation({
136
+ mutationFn: (input: { id: number, userId: number }) => systemDepartmentsMembersDelete({ id: input.id, userId: input.userId }),
137
+ onSuccess: invalidateAll,
138
+ onError,
139
+ });
140
+
141
+ // 父级选择器:排除自己及其子孙(选了会被后端判环 400,不如不给)
142
+ const parentOptions = useMemo(() => {
143
+ if (!editing)
144
+ return rows.map(d => ({ title: d.name ?? `#${d.id}`, value: d.id }));
145
+ const banned = subtreeIds(rows, [editing.id]);
146
+ return rows.filter(d => !banned.has(d.id)).map(d => ({ title: d.name ?? `#${d.id}`, value: d.id }));
147
+ }, [rows, editing]);
148
+
149
+ const treeData = useMemo(() => buildTreeData(rows, (d) => {
150
+ const editable = d.origin === "local";
151
+ return (
152
+ <Space size={4} wrap>
153
+ <Typography.Text strong>{d.name ?? t("system:departments.unnamed")}</Typography.Text>
154
+ {originTag(d.origin, t)}
155
+ <Typography.Text type="secondary">{`${t("system:departments.memberCount")} ${d.memberCount}`}</Typography.Text>
156
+ {!d.syncVisible ? <Tag color="orange">{t("system:departments.hidden")}</Tag> : null}
157
+ {!d.deleted ? null : <Tag color="red">{t("system:departments.removed")}</Tag>}
158
+ <Button
159
+ size="small"
160
+ type="link"
161
+ onClick={() => {
162
+ setEditing(d);
163
+ form.setFieldsValue({ name: d.name ?? "", nameEn: d.nameEn ?? undefined, parentId: d.parentId, orderNo: d.orderNo });
164
+ setEditOpen(true);
165
+ }}
166
+ >
167
+ {editable ? t("system:common.edit") : t("system:departments.view")}
168
+ </Button>
169
+ <Button
170
+ size="small"
171
+ type="link"
172
+ onClick={() => {
173
+ setMemberDept(d);
174
+ setAddUid(null);
175
+ }}
176
+ >
177
+ {t("system:departments.members")}
178
+ </Button>
179
+ {editable
180
+ ? (
181
+ <Popconfirm title={t("system:departments.confirmDelete")} onConfirm={() => deleteMut.mutate(d.id)}>
182
+ <Button size="small" type="link" danger>{t("system:common.delete")}</Button>
183
+ </Popconfirm>
184
+ )
185
+ : (
186
+ <Popconfirm
187
+ title={t("system:departments.detach")}
188
+ description={t("system:departments.detachHint")}
189
+ onConfirm={() => detachMut.mutate(d.id)}
190
+ >
191
+ <Button size="small" type="link">{t("system:departments.detach")}</Button>
192
+ </Popconfirm>
193
+ )}
194
+ </Space>
195
+ );
196
+ }), [rows, t, form, deleteMut, detachMut]);
197
+
198
+ if (deptsQuery.error && isForbidden(deptsQuery.error))
199
+ return <ForbiddenPanel t={t} error={deptsQuery.error} />;
200
+
201
+ const memberColumns = [
202
+ {
203
+ title: t("system:departments.memberColumns.user"),
204
+ dataIndex: "username",
205
+ render: (_: unknown, row: MemberRow) => (row.displayName ? `${row.displayName}(${row.username})` : row.username),
206
+ },
207
+ {
208
+ title: t("system:departments.memberColumns.main"),
209
+ dataIndex: "isMain",
210
+ render: (isMain: boolean, row: MemberRow) => (
211
+ <Popconfirm
212
+ title={t("system:departments.memberColumns.main")}
213
+ onConfirm={() => updateMemberMut.mutate({ id: row.deptId, userId: row.userId, body: { isMain: !isMain } })}
214
+ >
215
+ <Tag color={isMain ? "green" : undefined}>{isMain ? t("system:departments.yes") : t("system:departments.no")}</Tag>
216
+ </Popconfirm>
217
+ ),
218
+ },
219
+ {
220
+ title: t("system:departments.memberColumns.leader"),
221
+ dataIndex: "isLeader",
222
+ render: (isLeader: boolean, row: MemberRow) => (
223
+ <Popconfirm
224
+ title={t("system:departments.memberColumns.leader")}
225
+ onConfirm={() => updateMemberMut.mutate({ id: row.deptId, userId: row.userId, body: { isLeader: !isLeader } })}
226
+ >
227
+ <Tag color={isLeader ? "blue" : undefined}>{isLeader ? t("system:departments.yes") : t("system:departments.no")}</Tag>
228
+ </Popconfirm>
229
+ ),
230
+ },
231
+ {
232
+ title: t("system:departments.memberColumns.actions"),
233
+ key: "actions",
234
+ render: (_: unknown, row: MemberRow) => (
235
+ <Popconfirm
236
+ title={t("system:departments.confirmRemoveMember")}
237
+ onConfirm={() => removeMemberMut.mutate({ id: row.deptId, userId: row.userId })}
238
+ >
239
+ <Button size="small" type="link" danger>{t("system:departments.removeMember")}</Button>
240
+ </Popconfirm>
241
+ ),
242
+ },
243
+ ];
244
+
245
+ return (
246
+ <Space direction="vertical" style={{ width: "100%", paddingTop: 16 }}>
247
+ {/* 企微来源的组织架构:顶部常驻状态条 + 立即同步(没装企微的站点这里什么都不渲染) */}
248
+ <WecomSyncBar onFinished={() => queryClient.invalidateQueries({ queryKey: ["system"] })} />
249
+ <Space>
250
+ <Button
251
+ type="primary"
252
+ icon={<PlusOutlined />}
253
+ onClick={() => {
254
+ setEditing(null);
255
+ form.resetFields();
256
+ setEditOpen(true);
257
+ }}
258
+ >
259
+ {t("system:departments.create")}
260
+ </Button>
261
+ <Typography.Text type="secondary">{t("system:departments.createHint")}</Typography.Text>
262
+ </Space>
263
+
264
+ <Tree blockNode defaultExpandAll treeData={treeData} />
265
+
266
+ <Modal
267
+ title={editing ? t("system:departments.edit") : t("system:departments.create")}
268
+ open={editOpen}
269
+ onCancel={() => setEditOpen(false)}
270
+ confirmLoading={createMut.isPending || updateMut.isPending}
271
+ onOk={() => form.submit()}
272
+ >
273
+ <Form
274
+ form={form}
275
+ layout="vertical"
276
+ onFinish={(values) => {
277
+ const parentId = values.parentId ?? null;
278
+ if (!editing) {
279
+ createMut.mutate({ name: values.name, nameEn: values.nameEn ?? null, parentId, orderNo: values.orderNo });
280
+ return;
281
+ }
282
+ if (editing.origin === "local")
283
+ updateMut.mutate({ id: editing.id, body: { name: values.name, nameEn: values.nameEn ?? null, orderNo: values.orderNo } });
284
+ if (parentId !== editing.parentId)
285
+ moveMut.mutate({ id: editing.id, parentId });
286
+ }}
287
+ >
288
+ <Form.Item name="name" label={t("system:departments.columns.name")} rules={[{ required: true }]}>
289
+ <Input disabled={editing !== null && editing.origin !== "local"} />
290
+ </Form.Item>
291
+ <Form.Item name="nameEn" label={t("system:departments.columns.nameEn")}>
292
+ <Input disabled={editing !== null && editing.origin !== "local"} />
293
+ </Form.Item>
294
+ <Form.Item name="parentId" label={t("system:departments.columns.parent")}>
295
+ <TreeSelect allowClear treeData={parentOptions} placeholder={t("system:departments.root")} />
296
+ </Form.Item>
297
+ </Form>
298
+ {editing && editing.origin !== "local"
299
+ ? (
300
+ <Space>
301
+ <Typography.Text type="secondary">{t("system:departments.readonlyHint")}</Typography.Text>
302
+ <Button size="small" type="link" onClick={() => detachMut.mutate(editing.id)}>
303
+ {t("system:departments.detach")}
304
+ </Button>
305
+ </Space>
306
+ )
307
+ : null}
308
+ </Modal>
309
+
310
+ <Modal
311
+ width={720}
312
+ title={`${t("system:departments.members")}:${memberDept?.name ?? ""}`}
313
+ open={memberDept !== null}
314
+ onCancel={() => setMemberDept(null)}
315
+ footer={null}
316
+ >
317
+ <Space style={{ marginBottom: 12 }}>
318
+ <Select
319
+ style={{ minWidth: 240 }}
320
+ placeholder={t("system:departments.selectUser")}
321
+ value={addUid ?? undefined}
322
+ onChange={v => setAddUid(v)}
323
+ options={(usersQuery.data ?? []).map(u => ({ label: u.display_name ? `${u.display_name}(${u.username})` : u.username, value: u.id }))}
324
+ />
325
+ <Button
326
+ type="primary"
327
+ disabled={addUid === null || !memberDept}
328
+ loading={addMemberMut.isPending}
329
+ onClick={() => memberDept && addUid !== null && addMemberMut.mutate({ id: memberDept.id, userId: addUid })}
330
+ >
331
+ {t("system:departments.addMember")}
332
+ </Button>
333
+ </Space>
334
+ <Table
335
+ rowKey="userId"
336
+ size="small"
337
+ loading={membersQuery.isLoading}
338
+ dataSource={membersQuery.data ?? []}
339
+ columns={memberColumns}
340
+ pagination={{ hideOnSinglePage: true }}
341
+ locale={{ emptyText: t("system:departments.noMembers") }}
342
+ />
343
+ </Modal>
344
+ </Space>
345
+ );
346
+ }
@@ -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;