feat:[FL-189][给用户管理增加卡片视图]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-18 10:10:01 +08:00
parent 5796797120
commit 33db320c0e
2 changed files with 222 additions and 39 deletions
+208 -39
View File
@@ -5,12 +5,14 @@ import {
Button,
Card,
Checkbox,
Col,
Dropdown,
Empty,
Form,
Input,
Modal,
Popconfirm,
Row,
Select,
Space,
Spin,
@@ -20,7 +22,7 @@ import {
type CardProps,
type MenuProps,
} from "antd";
import { MoreOutlined } from "@ant-design/icons";
import { AppstoreOutlined, MoreOutlined, TableOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react";
@@ -28,6 +30,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties,
import { useAuth } from "@/components/auth-provider";
import { useToastFeedback } from "@/hooks/use-toast-feedback";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { useMobileDetection } from "@/hooks/use-mobile-detection";
import { readApiError } from "@/lib/api";
import type { RoleItem, RoleListResponse, UserListResponse, UserPublic } from "@/types/auth";
@@ -68,6 +71,7 @@ const USERS_TABLE_FALLBACK_RESERVE = 220;
export default function AdminUsersPage() {
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
const queryClient = useQueryClient();
const isMobile = useMobileDetection();
const [createForm] = Form.useForm<CreateUserValues>();
const [editUserForm] = Form.useForm<EditUserValues>();
@@ -88,10 +92,15 @@ export default function AdminUsersPage() {
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
const [tableScrollY, setTableScrollY] = useState(USERS_TABLE_MIN_SCROLL_Y);
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
const [viewMode, setViewMode] = useState<"table" | "card">(isMobile ? "card" : "table");
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
setViewMode(isMobile ? "card" : "table");
}, [isMobile]);
const canManage = hasPermission("user.manage");
const canReadRoles = hasPermission("role.read") || hasPermission("role.manage");
@@ -652,6 +661,97 @@ export default function AdminUsersPage() {
},
];
const renderUserCard = (userItem: UserPublic) => {
const updatingLoading = updatingStatusUserId === userItem.id;
const resetLoading = resettingUserId === userItem.id;
const deleteLoading = deletingUserId === userItem.id;
const savingLoading = savingUserId === userItem.id;
const rowBusy = updatingLoading || resetLoading || deleteLoading || savingLoading;
const moreMenuItems: MenuProps["items"] = [
{
key: "assign-roles",
label: "分配角色",
disabled: rowBusy,
onClick: () => openAssignRolesModal(userItem),
},
{
key: "toggle-status",
label: userItem.status === "active" ? "禁用" : "启用",
disabled: rowBusy || userItem.id === user?.id,
onClick: () => {
if (userItem.id === user?.id) {
setError("不能修改当前登录账号的状态");
return;
}
const nextStatus: "active" | "disabled" = userItem.status === "active" ? "disabled" : "active";
updateUserProfileMutation.mutate({ userId: userItem.id, payload: { status: nextStatus } });
},
},
{
key: "reset-password",
label: "重置密码",
disabled: rowBusy,
onClick: () => openResetPasswordModal(userItem),
},
];
return (
<AntCard
key={userItem.id}
size="small"
style={{ marginBottom: 12 }}
title={
<Space>
<Typography.Text strong>{userItem.username}</Typography.Text>
<Tag color={userItem.status === "active" ? "green" : "default"}>
{statusLabel(userItem.status)}
</Tag>
</Space>
}
extra={
<Dropdown menu={{ items: moreMenuItems }} trigger={["click"]}>
<Button size="small" disabled={rowBusy} icon={<MoreOutlined />} />
</Dropdown>
}
>
<Space direction="vertical" size={4} style={{ width: "100%" }}>
<div>
<Typography.Text type="secondary"> ID</Typography.Text>
<Typography.Text>{userItem.id}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text>{userItem.email || "-"}</Typography.Text>
</div>
<div style={{ marginTop: 8 }}>
<Space wrap>
<Button
size="small"
disabled={rowBusy}
onClick={() => openEditUserModal(userItem)}
>
</Button>
<Popconfirm
title={`确认删除用户 ${userItem.username}${userItem.id})?`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: deleteLoading }}
onConfirm={() => deleteUserMutation.mutate(userItem.id)}
disabled={rowBusy}
>
<Button danger size="small" loading={deleteLoading} disabled={rowBusy}>
</Button>
</Popconfirm>
</Space>
</div>
</Space>
</AntCard>
);
};
if (initializing) {
return (
<div className="flex min-h-[240px] items-center justify-center">
@@ -694,9 +794,29 @@ export default function AdminUsersPage() {
title="用户管理"
style={{ height: '100%' }}
extra={(
<Button type="primary" onClick={openCreateUserModal}>
</Button>
<Space>
{!isMobile && (
<Button.Group>
<Button
icon={<TableOutlined />}
type={viewMode === "table" ? "primary" : "default"}
onClick={() => setViewMode("table")}
>
</Button>
<Button
icon={<AppstoreOutlined />}
type={viewMode === "card" ? "primary" : "default"}
onClick={() => setViewMode("card")}
>
</Button>
</Button.Group>
)}
<Button type="primary" onClick={openCreateUserModal}>
</Button>
</Space>
)}
>
<Form layout="inline" style={{ rowGap: 12 }}>
@@ -733,41 +853,90 @@ export default function AdminUsersPage() {
</Form.Item>
</Form>
<div
ref={tableScrollAnchorRef}
className="admin-users-table-anchor mt-4"
style={{ "--admin-users-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
>
<Table<UserPublic>
rowKey="id"
dataSource={users}
columns={columns}
loading={usersQuery.isLoading || rolesQuery.isLoading}
tableLayout="fixed"
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: Math.max(usersQuery.data?.total ?? 0, 1),
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (total) => `${usersQuery.data?.total ?? 0}`,
hideOnSinglePage: false,
style: { marginBottom: 0 },
onChange: (page, pageSize) => {
setPagination({ current: page, pageSize });
},
}}
scroll={{ y: tableScrollY }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的用户。"
/>
),
}}
/>
</div>
{viewMode === "table" ? (
<div
ref={tableScrollAnchorRef}
className="admin-users-table-anchor mt-4"
style={{ "--admin-users-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
>
<Table<UserPublic>
rowKey="id"
dataSource={users}
columns={columns}
loading={usersQuery.isLoading || rolesQuery.isLoading}
tableLayout="fixed"
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: Math.max(usersQuery.data?.total ?? 0, 1),
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (total) => `${usersQuery.data?.total ?? 0}`,
hideOnSinglePage: false,
style: { marginBottom: 0 },
onChange: (page, pageSize) => {
setPagination({ current: page, pageSize });
},
}}
scroll={{ y: tableScrollY }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的用户。"
/>
),
}}
/>
</div>
) : (
<div className="mt-4">
{usersQuery.isLoading || rolesQuery.isLoading ? (
<div className="flex min-h-[240px] items-center justify-center">
<Spin tip="加载中..." />
</div>
) : users.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的用户。"
/>
) : (
<>
<Row gutter={[12, 12]}>
{users.map((userItem) => (
<Col key={userItem.id} xs={24} sm={24} md={12} lg={8} xl={6}>
{renderUserCard(userItem)}
</Col>
))}
</Row>
<div style={{ marginTop: 16, textAlign: "center" }}>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<Typography.Text type="secondary">
{usersQuery.data?.total ?? 0}
</Typography.Text>
<Space wrap>
<Button
disabled={pagination.current === 1}
onClick={() => setPagination((prev) => ({ ...prev, current: prev.current - 1 }))}
>
</Button>
<Typography.Text>
{pagination.current} / {Math.ceil((usersQuery.data?.total ?? 0) / pagination.pageSize)}
</Typography.Text>
<Button
disabled={pagination.current >= Math.ceil((usersQuery.data?.total ?? 0) / pagination.pageSize)}
onClick={() => setPagination((prev) => ({ ...prev, current: prev.current + 1 }))}
>
</Button>
</Space>
</Space>
</div>
</>
)}
</div>
)}
</AntCard>
<Modal
+14
View File
@@ -0,0 +1,14 @@
import { useEffect, useState } from "react";
import { Grid } from "antd";
export function useMobileDetection() {
const screens = Grid.useBreakpoint();
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
// Consider mobile if screen width is below md breakpoint (768px in Ant Design)
setIsMobile(screens.md === false);
}, [screens.md]);
return isMobile;
}