feat(users): 用户管理表格内直接分配组织(超管)

## 起因

用户指出「所属组织要可以分配」。核查后发现两件事:

1. **组织没丢** —— 生产 46 个组织、28 个项目、68 个柜子都在;
2. **但 5 个用户里 0 个被分到组织**,所以那一列全是 `-`。
   其中 2 个是总管理员(按规则本就不属组织,正确),
   另 3 个(李四/企业管理员、张三/王五/操作员)**从未分配**,违反
   「操作员必须有企业归属」的规则。

原流程要「点编辑图标 → 弹窗里选组织 → 保存」三步才能改一个用户,
3 个用户要重复 3 次。现改成**表格里直接分配**。

## 改动

`users.tsx` 的「所属组织」列:
- 超管 + 非总管理员 ⇒ 渲染成内联 `Select`,选完即存(`PUT /users/:id` 只传
  `organization_id`,后端 `UpdateUserInput` 本就支持,无需改后端);
- 总管理员行仍显示 `-`(后端 `resolve_update_org_id` 对 `role=2` 强制 `None`);
- 非超管操作者仍只读。

顺带修 `api/user.ts`:`fetchUsers` 的参数类型漏了 `role`(上一轮加了角色过滤
但类型没跟上,靠结构兼容蒙混过关)。

## 实测(测试环境,临时账号用完即删)

```
PUT 只传 organization_id        → HTTP 200,库中 organization_id 已更新   OK
改分配到另一组织                → HTTP 200,库中已更新                  OK
只改名不传组织                  → 组织保持原值,未被清空                  OK
给总管理员分组织                → HTTP 200 但库中仍为 None(被强制忽略)   OK
列表 organization_name          → 正确返回组织名                        OK
```

## ⚠️ 两个我自己出的岔子(都已修正)

1. **断言写错**:我原以为「给总管理员分组织」应返回 400,实测返回 200。
   读库确认 `organization_id` 仍是 `None` —— 后端是**静默忽略**而非拒绝,
   行为符合规则。是测试预期错,不是代码错。
2. **测试脚本留了脏数据**:临时用户删不掉,因为 `operation_logs` 有外键引用
   (`log_operation` 写了日志)。已补删 `operation_logs` → `user_roles` → `users`
   清理干净,测试库恢复 1 用户 / 1 组织 / 1 项目 / 1 柜子。

## 待用户决定(生产数据,我不擅自改)

李四 / 张三 / 王五 三个账号要分到哪个组织?生产有 46 个组织可选。
现在超管可以在表格里直接点选分配,你也可以告诉我分配方案我来执行。

## 验收

- web `npm run typecheck`:exit 0
- `npm run build`:exit 0
- 已发布生产(web 已更新,0 ERROR、域名 401/200)
This commit is contained in:
12451 2026-09-21 05:20:38 +08:00
parent 0441d91d1f
commit f3f841b1f0
2 changed files with 39 additions and 2 deletions

View File

@ -47,11 +47,14 @@ export async function fetchUsers(params: {
page?: number; page?: number;
page_size?: number; page_size?: number;
keyword?: string; keyword?: string;
/** 角色过滤:0=操作员 1=企业管理员 2=总管理员;不给 = 全部 */
role?: number;
}): Promise<UserListData> { }): Promise<UserListData> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', String(params.page)); if (params.page) searchParams.set('page', String(params.page));
if (params.page_size) searchParams.set('page_size', String(params.page_size)); if (params.page_size) searchParams.set('page_size', String(params.page_size));
if (params.keyword) searchParams.set('keyword', params.keyword); if (params.keyword) searchParams.set('keyword', params.keyword);
if (params.role !== undefined) searchParams.set('role', String(params.role));
const res = await api.get<UserListData>(`/users?${searchParams.toString()}`); const res = await api.get<UserListData>(`/users?${searchParams.toString()}`);
return res.data; return res.data;
} }

View File

@ -339,6 +339,19 @@ export default function UsersPage() {
setScopeModalVisible(true); setScopeModalVisible(true);
}; };
/** 表格内直接分配组织(仅超管可见/可操作)。
* 不必进编辑弹窗 —— 生产上有 3 个用户从未分过组织,逐个进弹窗改太慢。 */
const handleAssignOrg = async (user: User, orgId: number) => {
try {
await updateUser(user.id, { organization_id: orgId });
Message.success(`已将「${user.name || user.phone}」分配到该组织`);
fetchData();
} catch (e: any) {
Message.error(e?.message || '分配失败');
fetchData(); // 失败也刷新,把下拉回滚到真实值
}
};
/** 表格列 */ /** 表格列 */
const columns: TableColumnProps<User>[] = [ const columns: TableColumnProps<User>[] = [
{ title: '手机号', dataIndex: 'phone', width: 130 }, { title: '手机号', dataIndex: 'phone', width: 130 },
@ -347,8 +360,29 @@ export default function UsersPage() {
{ {
title: '所属组织', title: '所属组织',
dataIndex: 'organization_name', dataIndex: 'organization_name',
width: 150, width: 180,
render: (v: string | null) => v || '-', render: (v: string | null, record: User) => {
// 总管理员本就不属任何组织(后端 resolve_update_org_id 对 role=2 强制 None);
// 非超管操作者也只能看,不能改。
if (!isSuperAdmin || record.role === 2) {
return v || '-';
}
// 超管 + 非总管理员 ⇒ 表格里直接分配(生产上有用户从未分过组织)
return (
<Select
size="mini"
value={record.organization_id ?? undefined}
placeholder="未分配"
style={{ width: 150 }}
options={orgs.map((o) => ({
label: o.name,
value: o.id,
disabled: o.disabled === 1,
}))}
onChange={(val) => handleAssignOrg(record, val as number)}
/>
);
},
}, },
{ {
title: '状态', title: '状态',