cangku/frontend/src/pages/Settings/UserManagement.jsx

517 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useState } from 'react';
import { Avatar, Button, Form, Input, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
import { AppstoreOutlined, EditOutlined, LockOutlined, PlusOutlined, ReloadOutlined, SyncOutlined, DeleteOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { SycmCard } from '../../components/Common';
import {
createUser,
deleteUser,
getDepartments,
getUserPagePermissions,
getUsers,
resetPassword,
syncDingTalkUsers,
updateUser,
updateUserAdminRole,
updateUserPagePermissions,
} from '../../api/admin';
const { Text } = Typography;
const { Search } = Input;
const ROLE_LABELS = {
admin: '管理员',
analyst: '分析师',
user: '用户',
guest: '访客',
};
const SOURCE_META = {
local: { color: 'default', text: '本地账号' },
dingtalk: { color: 'blue', text: '钉钉' },
google: { color: 'red', text: 'Google' },
};
const UserManagement = () => {
const [users, setUsers] = useState([]);
const [departments, setDepartments] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [switchingAdminId, setSwitchingAdminId] = useState(null);
const [modalVisible, setModalVisible] = useState(false);
const [resetVisible, setResetVisible] = useState(false);
const [permissionVisible, setPermissionVisible] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [resetUser, setResetUser] = useState(null);
const [permissionUser, setPermissionUser] = useState(null);
const [pagePermissions, setPagePermissions] = useState([]);
const [permissionSaving, setPermissionSaving] = useState(false);
const [keyword, setKeyword] = useState('');
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const [form] = Form.useForm();
const [resetForm] = Form.useForm();
const departmentOptions = useMemo(
() =>
departments
.filter((item) => item.status === 'active')
.map((item) => ({
label: item.name,
value: item.id,
})),
[departments]
);
const fetchDepartments = async () => {
try {
const data = await getDepartments();
setDepartments(Array.isArray(data) ? data : []);
} catch (error) {
message.error(error.response?.data?.error || '获取部门列表失败');
}
};
const fetchUsers = async (page = pagination.current, pageSize = pagination.pageSize, nextKeyword = keyword) => {
try {
setLoading(true);
const res = await getUsers({
page,
size: pageSize,
keyword: nextKeyword,
});
setUsers(res.data || []);
setPagination({
current: res.page || page,
pageSize: res.size || pageSize,
total: res.total || 0,
});
} catch (error) {
message.error(error.response?.data?.error || '获取用户列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDepartments();
fetchUsers(1, 10, '');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const openCreateModal = () => {
setEditingUser(null);
form.resetFields();
form.setFieldsValue({
role: 'user',
status: 'active',
});
setModalVisible(true);
};
const openEditModal = (record) => {
setEditingUser(record);
form.setFieldsValue({
username: record.username,
name: record.name,
email: record.email,
mobile: record.mobile,
jobTitle: record.jobTitle,
role: record.role,
departmentId: record.department_id || undefined,
status: record.status,
});
setModalVisible(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
if (editingUser) {
await updateUser(editingUser.id, values);
message.success('用户信息已更新');
} else {
await createUser(values);
message.success('用户已创建');
}
setModalVisible(false);
fetchUsers();
} catch (error) {
if (!error?.errorFields) {
message.error(error.response?.data?.error || '保存用户失败');
}
} finally {
setSaving(false);
}
};
const handleDelete = async (id) => {
try {
await deleteUser(id);
message.success('用户已删除');
fetchUsers();
} catch (error) {
message.error(error.response?.data?.error || '删除用户失败');
}
};
const handleToggleAdminRole = async (record) => {
const nextIsAdmin = record.role !== 'admin';
try {
setSwitchingAdminId(record.id);
const res = await updateUserAdminRole(record.id, { isAdmin: nextIsAdmin });
message.success(res.message || (nextIsAdmin ? '已授予管理员权限' : '已取消管理员权限'));
fetchUsers(pagination.current, pagination.pageSize, keyword);
} catch (error) {
message.error(error.response?.data?.error || '更新管理员权限失败');
} finally {
setSwitchingAdminId(null);
}
};
const openPagePermissionModal = async (record) => {
try {
setPermissionUser(record);
const data = await getUserPagePermissions(record.id);
setPagePermissions(Array.isArray(data.permissions) ? data.permissions : []);
setPermissionVisible(true);
} catch (error) {
message.error(error.response?.data?.error || '获取用户可见页面失败');
}
};
const handlePagePermissionToggle = (pageKey, userAllowed) => {
setPagePermissions((prev) =>
prev.map((item) => (item.pageKey === pageKey ? { ...item, userAllowed, effectiveAllowed: Boolean(item.baseAllowed) || userAllowed } : item))
);
};
const handleSavePagePermissions = async () => {
if (!permissionUser) {
return;
}
try {
setPermissionSaving(true);
const res = await updateUserPagePermissions(permissionUser.id, {
permissions: pagePermissions.map((item) => ({
pageKey: item.pageKey,
allowed: Boolean(item.userAllowed),
})),
});
setPagePermissions(Array.isArray(res.permissions) ? res.permissions : []);
message.success(res.message || '用户可见页面已更新');
setPermissionVisible(false);
} catch (error) {
message.error(error.response?.data?.error || '更新用户可见页面失败');
} finally {
setPermissionSaving(false);
}
};
const openResetModal = (record) => {
setResetUser(record);
resetForm.resetFields();
setResetVisible(true);
};
const handleResetPassword = async () => {
try {
const values = await resetForm.validateFields();
setSaving(true);
await resetPassword(resetUser.id, { newPassword: values.newPassword });
message.success('密码已重置');
setResetVisible(false);
} catch (error) {
if (!error?.errorFields) {
message.error(error.response?.data?.error || '重置密码失败');
}
} finally {
setSaving(false);
}
};
const handleSync = async () => {
try {
setSyncing(true);
const res = await syncDingTalkUsers();
message.success(`同步完成,共处理 ${res.total || 0} 位钉钉用户`);
await Promise.all([fetchUsers(1, pagination.pageSize, keyword), fetchDepartments()]);
} catch (error) {
message.error(error.response?.data?.error || '同步钉钉用户失败');
} finally {
setSyncing(false);
}
};
const columns = [
{
title: '用户',
key: 'user',
width: 280,
render: (_, record) => (
<Space align="start">
<Avatar src={record.avatarUrl} icon={<UserOutlined />} size={42} />
<div>
<div style={{ fontWeight: 600, color: 'var(--sycm-text-strong)' }}>{record.name || record.username}</div>
<div style={{ color: 'var(--sycm-text-tertiary)', fontSize: 13 }}>{record.username}</div>
<div style={{ color: 'var(--sycm-text-tertiary)', fontSize: 13 }}>{record.email}</div>
</div>
</Space>
),
},
{
title: '来源',
dataIndex: 'authSource',
width: 110,
render: (value) => {
const meta = SOURCE_META[value] || { color: 'default', text: value || '未知' };
return <Tag color={meta.color}>{meta.text}</Tag>;
},
},
{
title: '部门信息',
key: 'department',
width: 220,
render: (_, record) => (
<div>
<div>{record.departmentNames || record.departmentName || '-'}</div>
{record.jobTitle && <Text type="secondary">{record.jobTitle}</Text>}
</div>
),
},
{
title: '角色 / 状态',
key: 'roleStatus',
width: 160,
render: (_, record) => (
<Space direction="vertical" size={4}>
<Tag color={record.role === 'admin' ? 'gold' : 'default'}>{ROLE_LABELS[record.role] || record.role}</Tag>
<Tag color={record.status === 'active' ? 'success' : 'default'}>{record.status === 'active' ? '启用' : '停用'}</Tag>
</Space>
),
},
{
title: '联系信息',
key: 'contact',
width: 180,
render: (_, record) => (
<div>
<div>{record.mobile || '-'}</div>
{(record.dingtalkUserId || record.googleSub) && (
<Text type="secondary" style={{ fontSize: 12 }}>
{record.dingtalkUserId ? `钉钉ID: ${record.dingtalkUserId}` : `Google: ${record.googleSub}`}
</Text>
)}
</div>
),
},
{
title: '最后登录',
dataIndex: 'lastLoginAt',
width: 170,
render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作',
key: 'actions',
width: 320,
fixed: 'right',
render: (_, record) => (
<Space wrap>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
编辑
</Button>
<Button type="link" size="small" icon={<AppstoreOutlined />} onClick={() => openPagePermissionModal(record)}>
页面权限
</Button>
<Popconfirm
title={record.role === 'admin' ? '确认取消该用户的管理员权限?' : '确认授予该用户管理员权限?'}
description={record.role === 'admin' ? '取消后该用户将变为普通用户。' : '授予后该用户将拥有系统管理权限。'}
onConfirm={() => handleToggleAdminRole(record)}
>
<Button
type="link"
size="small"
icon={<SafetyCertificateOutlined />}
loading={switchingAdminId === record.id}
>
{record.role === 'admin' ? '取消管理员' : '设为管理员'}
</Button>
</Popconfirm>
<Button type="link" size="small" icon={<LockOutlined />} onClick={() => openResetModal(record)}>
重置密码
</Button>
<Popconfirm title="确定删除该用户吗?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div className="settings-tab-page">
<SycmCard bodyStyle={{ padding: 16 }}>
<div style={{ display: 'flex', gap: 12, justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap' }}>
<Search
placeholder="搜索账号、姓名、邮箱、手机号、部门"
allowClear
onSearch={(value) => {
setKeyword(value);
fetchUsers(1, pagination.pageSize, value);
}}
style={{ width: 320 }}
/>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => fetchUsers()}>
刷新
</Button>
<Button icon={<SyncOutlined />} loading={syncing} onClick={handleSync}>
同步钉钉用户
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
新增用户
</Button>
</Space>
</div>
<Table
rowKey="id"
columns={columns}
dataSource={users}
loading={loading}
scroll={{ x: 1420 }}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
onChange: (page, pageSize) => fetchUsers(page, pageSize),
}}
/>
</SycmCard>
<Modal
title={editingUser ? '编辑用户' : '新增用户'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleSave}
confirmLoading={saving}
width={560}
>
<Form form={form} layout="vertical" style={{ marginTop: 20 }}>
<Form.Item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input disabled={Boolean(editingUser)} />
</Form.Item>
{!editingUser && (
<Form.Item label="初始密码" name="password" rules={[{ required: true, message: '请输入初始密码' }]}>
<Input.Password />
</Form.Item>
)}
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input />
</Form.Item>
<Form.Item label="邮箱" name="email" rules={[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '邮箱格式不正确' }]}>
<Input />
</Form.Item>
<Form.Item label="手机号" name="mobile">
<Input />
</Form.Item>
<Form.Item label="职位" name="jobTitle">
<Input />
</Form.Item>
<Form.Item label="所属部门" name="departmentId">
<Select allowClear options={departmentOptions} placeholder="请选择部门" />
</Form.Item>
<Space style={{ width: '100%' }} size={16}>
<Form.Item label="角色" name="role" style={{ flex: 1 }} rules={[{ required: true, message: '请选择角色' }]}>
<Select
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item label="状态" name="status" style={{ flex: 1 }} rules={[{ required: true, message: '请选择状态' }]}>
<Select
options={[
{ value: 'active', label: '启用' },
{ value: 'inactive', label: '停用' },
]}
/>
</Form.Item>
</Space>
</Form>
</Modal>
<Modal
title={permissionUser ? `页面权限 - ${permissionUser.name || permissionUser.username}` : '页面权限'}
open={permissionVisible}
onCancel={() => setPermissionVisible(false)}
onOk={handleSavePagePermissions}
confirmLoading={permissionSaving}
width={680}
>
<div style={{ marginTop: 12 }}>
<Text type="secondary">
这里配置用户级额外可见页面。即使所属部门关闭了某个页面,只要在这里单独开通,该用户仍会在侧边栏看到该页面。
</Text>
<div style={{ marginTop: 20, display: 'grid', gap: 12 }}>
{pagePermissions.map((item) => (
<div
key={item.pageKey}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '12px 14px',
borderRadius: 8,
background: 'var(--sycm-bg-subtle)',
}}
>
<div>
<Space size={8} wrap>
<span style={{ fontWeight: 600 }}>{item.pageName}</span>
{item.baseAllowed ? <Tag color="green">默认可见</Tag> : <Tag>默认不可见</Tag>}
{item.userAllowed ? <Tag color="blue">单独开通</Tag> : null}
</Space>
<div>
<Text type="secondary">{item.path}</Text>
</div>
</div>
<Switch
checked={Boolean(item.userAllowed)}
checkedChildren="开通"
unCheckedChildren="未开通"
onChange={(value) => handlePagePermissionToggle(item.pageKey, value)}
/>
</div>
))}
</div>
</div>
</Modal>
<Modal
title={resetUser ? `重置密码 - ${resetUser.name || resetUser.username}` : '重置密码'}
open={resetVisible}
onCancel={() => setResetVisible(false)}
onOk={handleResetPassword}
confirmLoading={saving}
>
<Form form={resetForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码至少 6 位' }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserManagement;