feat: redesign users page with left-tree right-detail layout
- Left sidebar: tree menu showing all users grouped by role (admin/legal/business) and status (active/disabled) - Right panel: selected user's detail with role, status toggle, and account info - Create/edit moved to Drawer Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+527
-226
@@ -1,7 +1,28 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Card, Empty, Form, Input, Select, Space, Switch, Table, Tag, Typography, message } from "antd";
|
||||
import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
|
||||
import {
|
||||
CaretDownOutlined,
|
||||
CaretRightOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Tag,
|
||||
Tree,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { formatRoleLabel } from "@/lib/roles";
|
||||
@@ -19,89 +40,184 @@ type UserFormValues = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
const roleOptions = [
|
||||
{ value: "admin", label: "管理员" },
|
||||
{ value: "legal", label: "法务用户" },
|
||||
{ value: "business", label: "业务用户" },
|
||||
];
|
||||
const roleLabelMap: Record<string, string> = {
|
||||
admin: "管理员",
|
||||
legal: "法务用户",
|
||||
business: "业务用户",
|
||||
member: "业务用户",
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "active", label: "启用" },
|
||||
{ value: "disabled", label: "停用" },
|
||||
];
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
active: "启用",
|
||||
disabled: "停用",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
const ALL_KEY = "__all__";
|
||||
|
||||
export default function UsersPage() {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const [form] = Form.useForm<UserFormValues>();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [statusChangingId, setStatusChangingId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<UserItem[]>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([ALL_KEY]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const editingUser = useMemo(() => items.find((item) => item.id === editingId) ?? null, [editingId, items]);
|
||||
const selectedUser = useMemo(() => items.find((item) => item.id === selectedId) ?? null, [items, selectedId]);
|
||||
|
||||
const resetFormDefaults = useCallback(() => {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ role: "business", status: "active" });
|
||||
form.setFieldsValue({ role: "business", status: "active", password: undefined });
|
||||
}, [form]);
|
||||
|
||||
const loadUsers = useCallback(
|
||||
async (nextPage = page, nextPageSize = pageSize) => {
|
||||
if (currentUser?.role !== "admin") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listUsers(nextPage, nextPageSize);
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "获取用户列表失败");
|
||||
return;
|
||||
}
|
||||
setItems(result.data.items);
|
||||
setPage(result.data.page);
|
||||
setPageSize(result.data.page_size);
|
||||
setTotal(result.data.total);
|
||||
} catch {
|
||||
messageApi.error("用户列表加载失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentUser?.role, messageApi, page, pageSize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadUsers();
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingUser) {
|
||||
resetFormDefaults();
|
||||
const loadUsers = useCallback(async () => {
|
||||
if (currentUser?.role !== "admin") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
username: editingUser.username,
|
||||
display_name: editingUser.display_name,
|
||||
password: "",
|
||||
role: editingUser.role === "member" ? "business" : editingUser.role,
|
||||
status: editingUser.status,
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listUsers(1, 1000);
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "获取用户列表失败");
|
||||
return;
|
||||
}
|
||||
setItems(result.data.items);
|
||||
if (!selectedId && result.data.items.length > 0) {
|
||||
setSelectedId(result.data.items[0].id);
|
||||
}
|
||||
} catch {
|
||||
messageApi.error("用户列表加载失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentUser?.role, messageApi, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery.trim()) return items;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return items.filter(
|
||||
(item) =>
|
||||
(item.display_name ?? "").toLowerCase().includes(q) ||
|
||||
(item.username ?? "").toLowerCase().includes(q) ||
|
||||
(item.role ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [items, searchQuery]);
|
||||
|
||||
const treeData = useMemo((): DataNode[] => {
|
||||
const roles = Array.from(new Set(items.map((i) => i.role === "member" ? "business" : i.role).filter(Boolean)));
|
||||
const statuses = Array.from(new Set(items.map((i) => i.status).filter(Boolean)));
|
||||
|
||||
const roleChildren: DataNode[] = roles.map((role) => {
|
||||
const usersInRole = filteredItems.filter((i) => (i.role === "member" ? "business" : i.role) === role);
|
||||
return {
|
||||
key: `role:${role}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{roleLabelMap[role] ?? role}</span>
|
||||
<Tag color={role === "admin" ? "blue" : role === "legal" ? "purple" : "default"} style={{ margin: 0, fontSize: 11 }}>{usersInRole.length}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: usersInRole.map((u) => ({
|
||||
key: u.id,
|
||||
title: (
|
||||
<div className="user-tree-item">
|
||||
<UserOutlined />
|
||||
<span className="user-tree-name">{u.display_name || u.username}</span>
|
||||
<Tag color={u.status === "active" ? "green" : "default"} style={{ fontSize: 10, marginLeft: 4 }}>
|
||||
{statusLabelMap[u.status]?.slice(0, 1) || u.status}
|
||||
</Tag>
|
||||
</div>
|
||||
),
|
||||
isLeaf: true,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}, [editingUser, form, resetFormDefaults]);
|
||||
|
||||
const statusChildren: DataNode[] = statuses.map((status) => {
|
||||
const usersInStatus = filteredItems.filter((i) => i.status === status);
|
||||
return {
|
||||
key: `status:${status}`,
|
||||
title: (
|
||||
<Space>
|
||||
<span>{statusLabelMap[status] || status}</span>
|
||||
<Tag color={status === "active" ? "green" : "default"} style={{ margin: 0, fontSize: 11 }}>{usersInStatus.length}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: usersInStatus.map((u) => ({
|
||||
key: u.id,
|
||||
title: (
|
||||
<div className="user-tree-item">
|
||||
<UserOutlined />
|
||||
<span className="user-tree-name">{u.display_name || u.username}</span>
|
||||
<Tag color={(u.role === "member" ? "business" : u.role) === "admin" ? "blue" : (u.role === "member" ? "business" : u.role) === "legal" ? "purple" : "default"} style={{ fontSize: 10, marginLeft: 4 }}>
|
||||
{roleLabelMap[u.role === "member" ? "business" : u.role]?.slice(0, 2) || u.role}
|
||||
</Tag>
|
||||
</div>
|
||||
),
|
||||
isLeaf: true,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
key: ALL_KEY,
|
||||
title: (
|
||||
<Space>
|
||||
<span>全部用户</span>
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{filteredItems.length}</Tag>
|
||||
</Space>
|
||||
),
|
||||
selectable: false,
|
||||
children: [
|
||||
{ key: "group-role", title: <span className="tree-group-title">按角色</span>, selectable: false, children: roleChildren },
|
||||
{ key: "group-status", title: <span className="tree-group-title">按状态</span>, selectable: false, children: statusChildren },
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [filteredItems]);
|
||||
|
||||
const handleTreeSelect = (_selectedKeys: React.Key[], info: { node: DataNode }) => {
|
||||
const key = info.node.key as string;
|
||||
if (key.startsWith("group-") || key === ALL_KEY) return;
|
||||
setSelectedId(key);
|
||||
};
|
||||
|
||||
const handleOpenDrawer = (user?: UserItem) => {
|
||||
if (user) {
|
||||
setEditingId(user.id);
|
||||
} else {
|
||||
setEditingId(null);
|
||||
resetFormDefaults();
|
||||
}
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setDrawerOpen(false);
|
||||
setEditingId(null);
|
||||
resetFormDefaults();
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: UserFormValues) => {
|
||||
setSubmitting(true);
|
||||
@@ -129,9 +245,8 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
messageApi.success(editingUser ? "用户已更新" : "用户已创建");
|
||||
setEditingId(null);
|
||||
resetFormDefaults();
|
||||
await loadUsers(page, pageSize);
|
||||
handleCloseDrawer();
|
||||
await loadUsers();
|
||||
} catch {
|
||||
messageApi.error("保存用户失败,请稍后重试");
|
||||
} finally {
|
||||
@@ -155,10 +270,7 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
messageApi.success(checked ? "用户已启用" : "用户已停用");
|
||||
await loadUsers(page, pageSize);
|
||||
if (editingId === item.id) {
|
||||
setEditingId(item.id);
|
||||
}
|
||||
await loadUsers();
|
||||
} catch {
|
||||
messageApi.error("更新用户状态失败,请稍后重试");
|
||||
} finally {
|
||||
@@ -166,175 +278,364 @@ export default function UsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination: TablePaginationConfig) => {
|
||||
const nextPage = pagination.current ?? 1;
|
||||
const nextPageSize = pagination.pageSize ?? pageSize;
|
||||
setLoading(true);
|
||||
void loadUsers(nextPage, nextPageSize);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<UserItem> = [
|
||||
{
|
||||
title: "姓名",
|
||||
dataIndex: "display_name",
|
||||
key: "display_name",
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: "账号",
|
||||
dataIndex: "username",
|
||||
key: "username",
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
title: "角色",
|
||||
dataIndex: "role",
|
||||
key: "role",
|
||||
width: 140,
|
||||
render: (value: string) => (
|
||||
<Tag color={value === "admin" ? "blue" : value === "legal" ? "purple" : "default"}>{formatRoleLabel(value)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 180,
|
||||
render: (value: string, item) => (
|
||||
<Space>
|
||||
<Tag color={value === "active" ? "green" : "default"}>{value === "active" ? "启用" : "停用"}</Tag>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={value === "active"}
|
||||
loading={statusChangingId === item.id}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
onChange={(checked) => void handleToggleStatus(item, checked)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 180,
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 120,
|
||||
render: (_, item) => (
|
||||
<Button size="small" onClick={() => setEditingId(item.id)}>
|
||||
编辑
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (currentUser?.role !== "admin") {
|
||||
return (
|
||||
<main className="page-shell review-history-page">
|
||||
<main className="page-shell users-page">
|
||||
{contextHolder}
|
||||
<Card className="review-list-card">
|
||||
<Alert title="仅管理员可管理试点用户账号。" type="warning" showIcon />
|
||||
</Card>
|
||||
<div style={{ padding: 48, textAlign: "center" }}>
|
||||
<Text type="secondary">仅管理员可管理用户。</Text>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page-shell review-page-grid">
|
||||
<main className="page-shell users-page">
|
||||
{contextHolder}
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<section className="review-page-hero">
|
||||
<div className="review-page-hero-copy">
|
||||
<span className="landing-badge">User Management</span>
|
||||
<Title level={2}>管理试点成员账号、角色和启停状态。</Title>
|
||||
<Text type="secondary">让管理员可以把同一套试点环境里的法务用户、业务用户和管理员账号明确分开。</Text>
|
||||
</div>
|
||||
<Card className="review-side-card" variant="borderless">
|
||||
<span className="landing-panel-eyebrow">Pilot Roles</span>
|
||||
<Title level={4} style={{ margin: 0, color: "inherit" }}>
|
||||
最小角色集
|
||||
</Title>
|
||||
<ul className="review-page-tip-list">
|
||||
<li>管理员:负责模板、规则、知识库、试点申请和治理后台。</li>
|
||||
<li>法务用户:优先进入合同审查、审查记录和法律问答链路。</li>
|
||||
<li>业务用户:优先进入合同生成、审查结果跟进和试点使用链路。</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<div className="chat-layout-grid chat-layout-grid-history">
|
||||
<Card className="review-surface-card" title={editingUser ? "编辑用户" : "新建用户"}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} initialValues={{ role: "business", status: "active" }}>
|
||||
<Form.Item
|
||||
label="登录账号"
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: "请输入登录账号" },
|
||||
{ type: "email", message: "请输入有效邮箱" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如:user@example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item label="显示名称" name="display_name" rules={[{ required: true, message: "请输入显示名称" }]}>
|
||||
<Input placeholder="例如:法务负责人 / 业务专员" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={editingUser ? "新密码" : "初始密码"}
|
||||
name="password"
|
||||
rules={
|
||||
editingUser
|
||||
? [{ min: 6, message: "密码至少 6 位" }]
|
||||
: [
|
||||
{ required: true, message: "请输入初始密码" },
|
||||
{ min: 6, message: "密码至少 6 位" },
|
||||
]
|
||||
}
|
||||
>
|
||||
<Input.Password placeholder={editingUser ? "留空则保持原密码" : "请输入初始密码"} />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色" name="role" rules={[{ required: true, message: "请选择角色" }]}>
|
||||
<Select options={roleOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select options={statusOptions} />
|
||||
</Form.Item>
|
||||
<Space style={{ width: "100%" }}>
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
{editingUser ? "保存用户" : "创建用户"}
|
||||
</Button>
|
||||
{editingUser ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
resetFormDefaults();
|
||||
}}
|
||||
>
|
||||
取消编辑
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="review-surface-card" title="用户列表">
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有用户。" /> }}
|
||||
pagination={{ current: page, pageSize, total, showSizeChanger: true }}
|
||||
onChange={handleTableChange}
|
||||
scroll={{ x: 900 }}
|
||||
<div className="user-layout">
|
||||
{/* 左侧树形菜单 */}
|
||||
<aside className="user-sidebar">
|
||||
<div className="user-sidebar-header">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined style={{ color: "#8c8c8c" }} />}
|
||||
placeholder="搜索用户..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="user-search"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="user-sidebar-tree">
|
||||
{loading ? (
|
||||
<div className="user-loading"><Spin /></div>
|
||||
) : (
|
||||
<Tree
|
||||
showIcon={false}
|
||||
switcherIcon={({ expanded }) => expanded ? <CaretDownOutlined style={{ fontSize: 12 }} /> : <CaretRightOutlined style={{ fontSize: 12 }} />}
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
selectedKeys={selectedId ? [selectedId] : []}
|
||||
onExpand={setExpandedKeys}
|
||||
onSelect={handleTreeSelect}
|
||||
blockNode
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="user-sidebar-actions">
|
||||
<Button type="primary" icon={<PlusOutlined />} block onClick={() => handleOpenDrawer()}>
|
||||
新建用户
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右侧用户详情 */}
|
||||
<section className="user-detail">
|
||||
{loading ? (
|
||||
<div className="user-loading-panel">
|
||||
<Spin size="large" />
|
||||
<Text type="secondary">加载中...</Text>
|
||||
</div>
|
||||
) : selectedUser ? (
|
||||
<div className="user-detail-content">
|
||||
{/* 用户头部 */}
|
||||
<header className="user-detail-header">
|
||||
<div className="user-header-row">
|
||||
<div className="user-header-info">
|
||||
<Space>
|
||||
<UserOutlined style={{ fontSize: 32, color: "#1677ff" }} />
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>{selectedUser.display_name}</Title>
|
||||
<Text type="secondary">{selectedUser.username}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
<Space size={8}>
|
||||
<Switch
|
||||
checked={selectedUser.status === "active"}
|
||||
loading={statusChangingId === selectedUser.id}
|
||||
onChange={(checked) => void handleToggleStatus(selectedUser, checked)}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
/>
|
||||
<Button onClick={() => handleOpenDrawer(selectedUser)}>
|
||||
编辑
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="user-header-meta">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Tag color={selectedUser.role === "admin" ? "blue" : selectedUser.role === "legal" ? "purple" : "default"}>
|
||||
{formatRoleLabel(selectedUser.role)}
|
||||
</Tag>
|
||||
<Tag color={selectedUser.status === "active" ? "green" : "default"}>
|
||||
{selectedUser.status === "active" ? "启用中" : "已停用"}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text type="secondary">创建时间:{formatDateTime(selectedUser.created_at)}</Text>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 用户信息 */}
|
||||
<div className="user-section">
|
||||
<div className="user-section-header">
|
||||
<Text strong>账号信息</Text>
|
||||
</div>
|
||||
<div className="user-info-grid">
|
||||
<div className="user-info-item">
|
||||
<Text type="secondary">登录账号</Text>
|
||||
<Text>{selectedUser.username}</Text>
|
||||
</div>
|
||||
<div className="user-info-item">
|
||||
<Text type="secondary">显示名称</Text>
|
||||
<Text>{selectedUser.display_name}</Text>
|
||||
</div>
|
||||
<div className="user-info-item">
|
||||
<Text type="secondary">角色</Text>
|
||||
<Text>{formatRoleLabel(selectedUser.role)}</Text>
|
||||
</div>
|
||||
<div className="user-info-item">
|
||||
<Text type="secondary">状态</Text>
|
||||
<Text>{selectedUser.status === "active" ? "启用" : "停用"}</Text>
|
||||
</div>
|
||||
<div className="user-info-item">
|
||||
<Text type="secondary">创建时间</Text>
|
||||
<Text>{formatDateTime(selectedUser.created_at)}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="user-empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请从左侧选择用户查看详情">
|
||||
<Button type="primary" onClick={() => handleOpenDrawer()}>
|
||||
新建用户
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 新建/编辑用户抽屉 */}
|
||||
<Drawer
|
||||
title={editingUser ? "编辑用户" : "新建用户"}
|
||||
open={drawerOpen}
|
||||
onClose={handleCloseDrawer}
|
||||
width={480}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} initialValues={{ role: "business", status: "active" }}>
|
||||
<Form.Item
|
||||
label="登录账号"
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: "请输入登录账号" },
|
||||
{ type: "email", message: "请输入有效邮箱" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如:user@example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item label="显示名称" name="display_name" rules={[{ required: true, message: "请输入显示名称" }]}>
|
||||
<Input placeholder="例如:法务负责人 / 业务专员" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={editingUser ? "新密码" : "初始密码"}
|
||||
name="password"
|
||||
rules={editingUser
|
||||
? [{ min: 6, message: "密码至少 6 位" }]
|
||||
: [
|
||||
{ required: true, message: "请输入初始密码" },
|
||||
{ min: 6, message: "密码至少 6 位" },
|
||||
]
|
||||
}
|
||||
>
|
||||
<Input.Password placeholder={editingUser ? "留空则保持原密码" : "请输入初始密码"} />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色" name="role" rules={[{ required: true, message: "请选择角色" }]}>
|
||||
<Select options={[
|
||||
{ value: "admin", label: "管理员" },
|
||||
{ value: "legal", label: "法务用户" },
|
||||
{ value: "business", label: "业务用户" },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select options={[
|
||||
{ value: "active", label: "启用" },
|
||||
{ value: "disabled", label: "停用" },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
{editingUser ? "保存用户" : "创建用户"}
|
||||
</Button>
|
||||
<Button onClick={handleCloseDrawer}>取消</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
<style jsx global>{`
|
||||
.user-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 64px);
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.user-sidebar {
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.user-search {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.user-search .ant-input {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.user-sidebar-tree {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.user-sidebar-tree .ant-tree {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-sidebar-tree .ant-tree-node-content-wrapper {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.user-sidebar-tree .ant-tree-node-selected {
|
||||
background: #e6f4ff !important;
|
||||
}
|
||||
|
||||
.user-tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.user-tree-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.tree-group-title {
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.user-sidebar-actions {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.user-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.user-detail {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.user-loading-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.user-detail-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.user-detail-header {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.user-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-header-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.user-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.user-info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user