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:
2026-06-01 23:35:53 +08:00
parent 38885f3a3f
commit 36f4474ad6
+527 -226
View File
@@ -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>
);
}