@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
@@ -7,7 +7,8 @@
7
7
  "oplog": "操作日志",
8
8
  "settings": "站点设置",
9
9
  "loginMethods": "登录方式",
10
- "wecom": "企业微信代开发"
10
+ "wecom": "企微集成",
11
+ "departments": "组织架构"
11
12
  },
12
13
  "common": {
13
14
  "confirm": "确认",
@@ -16,7 +17,9 @@
16
17
  "saved": "已保存",
17
18
  "search": "查询",
18
19
  "reset": "重置",
19
- "create": "新建"
20
+ "create": "新建",
21
+ "edit": "编辑",
22
+ "delete": "删除"
20
23
  },
21
24
  "users": {
22
25
  "create": "建号",
@@ -32,8 +35,12 @@
32
35
  "email": "邮箱",
33
36
  "roles": "角色",
34
37
  "status": "账号状态(禁用/首登改密)",
35
- "actions": "操作"
36
- }
38
+ "actions": "操作",
39
+ "position": "职务",
40
+ "departments": "所属部门"
41
+ },
42
+ "mainDept": "主部门",
43
+ "filterDept": "按部门筛选(含子部门)"
37
44
  },
38
45
  "userRoles": {
39
46
  "title": "成员资格",
@@ -50,6 +57,46 @@
50
57
  "titleKey": "标题键",
51
58
  "readonlyHint": "只读视图:角色与绑定由解决方案工具链(upsertSolutionPerms)管理"
52
59
  },
60
+ "departments": {
61
+ "create": "新建部门",
62
+ "edit": "编辑部门",
63
+ "view": "查看",
64
+ "createHint": "组织架构是各模块共享的基线数据;企微/LDAP 只是写入它的适配器",
65
+ "members": "成员",
66
+ "memberCount": "成员",
67
+ "detach": "转为本地维护",
68
+ "detachHint": "转为本地维护后,同步不再改写该部门,改名/移动/删除全部开放",
69
+ "readonlyHint": "该部门由外部源同步维护,结构字段只读",
70
+ "unnamed": "(未命名)",
71
+ "hidden": "已失去可见范围",
72
+ "removed": "源侧已删除",
73
+ "root": "(顶层)",
74
+ "selectUser": "选择用户",
75
+ "addMember": "添加成员",
76
+ "removeMember": "移出",
77
+ "noMembers": "暂无成员",
78
+ "confirmDelete": "删除该部门?其下成员关联会一并清除",
79
+ "confirmRemoveMember": "把该用户移出本部门?",
80
+ "yes": "是",
81
+ "no": "否",
82
+ "origin": {
83
+ "local": "本站",
84
+ "wecom": "企业微信",
85
+ "ldap": "LDAP",
86
+ "sso": "SSO"
87
+ },
88
+ "columns": {
89
+ "name": "部门名称",
90
+ "nameEn": "英文名",
91
+ "parent": "上级部门"
92
+ },
93
+ "memberColumns": {
94
+ "user": "用户",
95
+ "main": "主部门",
96
+ "leader": "负责人",
97
+ "actions": "操作"
98
+ }
99
+ },
53
100
  "oplog": {
54
101
  "filterAction": "操作 (action)",
55
102
  "filterUsername": "用户名(前缀匹配)",
@@ -79,7 +126,7 @@
79
126
  },
80
127
  "wecom": {
81
128
  "title": "开通企业",
82
- "hint": "点「生成开通链接」后把链接或二维码发给企业管理员,由其确认授权;确认后企业侧的回调会写入本表。链接 10 分钟内有效且只能用一次,过期后二维码会置灰,需重新生成。",
129
+ "hint": "点「生成开通链接」把链接或二维码发给企业管理员,由其扫码/打开授权;授权成功后本系统经 create_auth 回调自动登记该企业(落库 wecom_app_profile)。链接由企微 get_customized_auth_url 生成,有效期以企微返回为准(可达 10 天),过期后二维码置灰,可重新生成。",
83
130
  "appsTitle": "已开通企业",
84
131
  "genInstall": "生成开通链接",
85
132
  "installOk": "已生成,请把链接发给企业管理员",
@@ -93,7 +140,8 @@
93
140
  "env": "环境",
94
141
  "status": "状态",
95
142
  "installedAt": "开通时间",
96
- "action": "操作"
143
+ "action": "操作",
144
+ "authMode": "授权模式"
97
145
  },
98
146
  "statusActive": "启用",
99
147
  "statusDisabled": "停用",
@@ -103,7 +151,61 @@
103
151
  "enable": "启用",
104
152
  "disableConfirm": "停用该应用?企业成员将无法用它登录。",
105
153
  "enableConfirm": "启用该应用?",
106
- "empty": "暂无已开通企业"
154
+ "empty": "暂无已开通企业",
155
+ "authModeAdmin": "管理员授权",
156
+ "authModeMember": "成员授权",
157
+ "authModeHint": "「成员授权」模式下应用没有通讯录权限,该企业的组织架构无法同步——需企业管理员在企微后台改为「管理员授权」后重新授权。",
158
+ "sync": {
159
+ "updatedAt": "企微组织架构更新于 {{time}}",
160
+ "neverSynced": "尚未同步过企微组织架构",
161
+ "auto": "自动同步",
162
+ "syncNow": "立即同步",
163
+ "retry": "重试",
164
+ "resume": "继续同步",
165
+ "cancel": "取消同步",
166
+ "syncing": "正在读取企微组织架构…",
167
+ "syncingProgress": "正在同步:已处理 {{done}}/{{total}} 个部门",
168
+ "done": "同步完成:{{depts}} 个部门 / {{members}} 名成员",
169
+ "paused": "同步已暂停(已处理 {{depts}} 个部门),点「继续同步」接着往下",
170
+ "skipped": "{{count}} 个部门因权限被跳过(可见范围外的部门属正常)",
171
+ "cancelled": "已取消同步",
172
+ "ago": {
173
+ "justNow": "刚刚",
174
+ "minutes": "{{count}} 分钟前",
175
+ "hours": "{{count}} 小时前",
176
+ "days": "{{count}} 天前"
177
+ },
178
+ "memberAuth": {
179
+ "title": "该企业为「成员授权」模式,应用没有通讯录权限",
180
+ "hint": "请企业管理员在企微后台把授权模式改为「管理员授权」后重新授权,才能同步组织架构。"
181
+ },
182
+ "frozen": {
183
+ "title": "该企业已取消授权或应用已停用,同步已冻结",
184
+ "hint": "下方为最后一次同步的数据快照;请联系服务商重新开通后再同步。"
185
+ },
186
+ "err": {
187
+ "permission": {
188
+ "title": "应用缺少通讯录权限(错误码 {{code}})",
189
+ "hint": "企业管理员:企微后台 → 应用管理 → 本应用 → 权限,勾选「通讯录」相关权限并提交。"
190
+ },
191
+ "rateLimited": {
192
+ "title": "企微接口限流(错误码 {{code}})",
193
+ "hint": "已自动退避,稍后点「继续同步」即可。"
194
+ },
195
+ "ipWhitelist": {
196
+ "title": "服务器出口 IP 不在企微白名单(错误码 {{code}})",
197
+ "hint": "由服务商运维在企微后台补充可信 IP。"
198
+ },
199
+ "network": {
200
+ "title": "无法连接企业微信({{code}})",
201
+ "hint": "请检查服务器网络与企微接口可达性后重试。"
202
+ },
203
+ "unknown": {
204
+ "title": "同步失败(错误码 {{code}})",
205
+ "hint": "请联系服务商排查后重试。"
206
+ }
207
+ }
208
+ }
107
209
  },
108
210
  "err": {
109
211
  "forbidden": "无权限",
@@ -125,5 +227,9 @@
125
227
  "ldap_base_dn": "LDAP Base DN",
126
228
  "ldap_uid_attr": "LDAP UID 属性",
127
229
  "ldap_email_attr": "LDAP 邮箱属性"
230
+ },
231
+ "perm": {
232
+ "platform_org_read_all": "组织架构全量读权限",
233
+ "auth_wecom_sync": "企业微信组织架构同步"
128
234
  }
129
235
  }
@@ -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
+ }