rwadurian/frontend/admin-web/src/app/(dashboard)/users/page.tsx

533 lines
19 KiB
TypeScript
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.

'use client';
import { useState, useCallback } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { toast, Button } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatNumber, formatRanking } from '@/utils/formatters';
import { useUsers } from '@/hooks';
import type { UserListItem } from '@/services/userService';
import styles from './users.module.scss';
// 骨架屏组件
const TableRowSkeleton = () => (
<div className={styles.users__tableRow}>
{Array.from({ length: 13 }).map((_, i) => (
<div key={i} className={styles.users__tableCellSkeleton}>
<div className={styles.users__skeleton} />
</div>
))}
</div>
);
// 空数据提示
const EmptyData = ({ message }: { message: string }) => (
<div className={styles.users__empty}>
<span>{message}</span>
</div>
);
// 错误提示
const ErrorMessage = ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
<div className={styles.users__error}>
<span>{message}</span>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
</Button>
)}
</div>
);
/**
* 用户管理页面
* 接入 admin-service 真实 API
*/
export default function UsersPage() {
const [keyword, setKeyword] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [selectedRows, setSelectedRows] = useState<string[]>([]);
// 用户状态/认种状态筛选
// 可选值: '' (全部) | 'ACTIVE' (正常) | 'FROZEN' (冻结) | 'adopted' (已认种) | 'not_adopted' (未认种)
const [statusFilter, setStatusFilter] = useState('');
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
});
// 根据 statusFilter 映射为 API 查询参数
// 'ACTIVE'/'FROZEN' → status 参数(后端 DTO 校验要求大写)
// 'adopted' → minAdoptions: 1personalAdoptionCount >= 1
// 'not_adopted' → maxAdoptions: 0personalAdoptionCount = 0
const filterParams = (() => {
switch (statusFilter) {
case 'ACTIVE':
case 'FROZEN':
return { status: statusFilter };
case 'adopted':
return { minAdoptions: 1 };
case 'not_adopted':
return { maxAdoptions: 0 };
default:
return {};
}
})();
// 使用 React Query hooks 获取用户列表
const {
data: usersData,
isLoading,
error,
refetch,
} = useUsers({
keyword: keyword || undefined,
...filterParams,
page: pagination.current,
pageSize: pagination.pageSize,
sortBy: 'registeredAt',
sortOrder: 'desc',
});
const users = usersData?.items ?? [];
const total = usersData?.total ?? 0;
const totalPages = usersData?.totalPages ?? 1;
// 全选处理
const handleSelectAll = useCallback((checked: boolean) => {
if (checked) {
setSelectedRows(users.map((user) => user.accountId));
} else {
setSelectedRows([]);
}
}, [users]);
// 单选处理
const handleSelectRow = useCallback((accountId: string, checked: boolean) => {
if (checked) {
setSelectedRows((prev) => [...prev, accountId]);
} else {
setSelectedRows((prev) => prev.filter((id) => id !== accountId));
}
}, []);
// 搜索处理
const handleSearch = useCallback((value: string) => {
setKeyword(value);
setPagination((prev) => ({ ...prev, current: 1 }));
}, []);
// 导出 Excel
const handleExport = useCallback(() => {
toast.success('导出功能开发中');
}, []);
// 生成分页按钮
const renderPaginationButtons = () => {
const buttons = [];
const maxVisible = 5;
// 首页按钮
buttons.push(
<button
key="first"
className={cn(styles.users__pageBtn, pagination.current === 1 && styles['users__pageBtn--disabled'])}
onClick={() => setPagination((prev) => ({ ...prev, current: 1 }))}
disabled={pagination.current === 1}
>
</button>
);
// 上一页按钮
buttons.push(
<button
key="prev"
className={cn(styles.users__pageBtn, pagination.current === 1 && styles['users__pageBtn--disabled'])}
onClick={() => setPagination((prev) => ({ ...prev, current: Math.max(1, prev.current - 1) }))}
disabled={pagination.current === 1}
>
</button>
);
// 页码按钮
let startPage = Math.max(1, pagination.current - Math.floor(maxVisible / 2));
const endPage = Math.min(totalPages, startPage + maxVisible - 1);
if (endPage - startPage < maxVisible - 1) {
startPage = Math.max(1, endPage - maxVisible + 1);
}
if (startPage > 1) {
buttons.push(
<button
key={1}
className={styles.users__pageBtn}
onClick={() => setPagination((prev) => ({ ...prev, current: 1 }))}
>
1
</button>
);
if (startPage > 2) {
buttons.push(
<span key="ellipsis1" className={cn(styles.users__pageBtn, styles['users__pageBtn--ellipsis'])}>
...
</span>
);
}
}
for (let i = startPage; i <= endPage; i++) {
buttons.push(
<button
key={i}
className={cn(
styles.users__pageBtn,
pagination.current === i && styles['users__pageBtn--active']
)}
onClick={() => setPagination((prev) => ({ ...prev, current: i }))}
>
{i}
</button>
);
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
buttons.push(
<span key="ellipsis2" className={cn(styles.users__pageBtn, styles['users__pageBtn--ellipsis'])}>
...
</span>
);
}
buttons.push(
<button
key={totalPages}
className={styles.users__pageBtn}
onClick={() => setPagination((prev) => ({ ...prev, current: totalPages }))}
>
{totalPages}
</button>
);
}
// 下一页按钮
buttons.push(
<button
key="next"
className={cn(
styles.users__pageBtn,
pagination.current === totalPages && styles['users__pageBtn--disabled']
)}
onClick={() => setPagination((prev) => ({ ...prev, current: Math.min(totalPages, prev.current + 1) }))}
disabled={pagination.current === totalPages}
>
</button>
);
// 末页按钮
buttons.push(
<button
key="last"
className={cn(
styles.users__pageBtn,
pagination.current === totalPages && styles['users__pageBtn--disabled']
)}
onClick={() => setPagination((prev) => ({ ...prev, current: totalPages }))}
disabled={pagination.current === totalPages}
>
</button>
);
return buttons;
};
// 获取状态显示
const getStatusClass = (user: UserListItem) => {
if (user.isOnline) return 'online';
if (user.status === 'frozen') return 'busy';
return 'offline';
};
return (
<PageContainer title="用户管理">
<div className={styles.users}>
{/* 页面标题和操作按钮 */}
<div className={styles.users__header}>
<h1 className={styles.users__title}></h1>
<div className={styles.users__actions}>
<button className={styles.users__actionBtn} onClick={handleExport}>
<Image src="/images/Container9.svg" width={16} height={16} alt="导出" />
<span className={styles.users__actionText}>Excel</span>
</button>
</div>
</div>
{/* 主内容卡片 */}
<div className={styles.users__card}>
{/* 搜索和筛选区域 */}
<div className={styles.users__filters}>
{/* 搜索框 */}
<div className={styles.users__searchBar}>
<div className={styles.users__searchIcon}>
<Image src="/images/Container11.svg" width={20} height={20} alt="搜索" />
</div>
<input
className={styles.users__searchInput}
placeholder="搜索账户ID、昵称"
type="text"
value={keyword}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{/* 高级筛选面板 */}
<div className={styles.users__filterPanel}>
<div
className={styles.users__filterHeader}
onClick={() => setShowFilters(!showFilters)}
>
<span className={styles.users__filterTitle}></span>
<div
className={cn(
styles.users__filterArrow,
showFilters && styles['users__filterArrow--expanded']
)}
>
<Image src="/images/Container12.svg" width={16} height={16} alt="展开" />
</div>
</div>
{showFilters && (
<div className={styles.users__filterContent}>
<select className={styles.users__paginationSelect}>
<option value=""></option>
<option value="guangdong">广</option>
<option value="zhejiang"></option>
</select>
<select className={styles.users__paginationSelect}>
<option value=""></option>
<option value="shenzhen"></option>
<option value="guangzhou">广</option>
</select>
<select
className={styles.users__paginationSelect}
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPagination((prev) => ({ ...prev, current: 1 }));
}}
>
<option value=""></option>
<option value="ACTIVE"></option>
<option value="FROZEN"></option>
<option value="adopted"></option>
<option value="not_adopted"></option>
</select>
</div>
)}
</div>
</div>
{/* 表格区域 */}
<div className={styles.users__table}>
{/* 表格头部 */}
<div className={styles.users__tableHeader}>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--checkbox'])}>
<input
type="checkbox"
className={styles.users__checkbox}
checked={selectedRows.length === users.length && users.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--id'])}>
<b></b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--avatar'])}>
<b></b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--nickname'])}>
<b></b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--phone'])}>
<b></b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--adoptions'])}>
<b></b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--teamTotal'])}>
<b></b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--province'])}>
<b></b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--city'])}>
<b></b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--referrer'])}>
<b></b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--ranking'])}>
<b></b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--actions'])}>
<b></b>
</div>
</div>
{/* 表格内容 */}
<div className={styles.users__tableBody}>
{isLoading ? (
// 加载状态显示骨架屏
Array.from({ length: pagination.pageSize }).map((_, i) => (
<TableRowSkeleton key={i} />
))
) : error ? (
// 错误状态 - 显示详细错误信息
<ErrorMessage
message={`请求失败: ${(error as Error)?.message || '服务器错误'}`}
onRetry={() => refetch()}
/>
) : users.length === 0 ? (
// 空数据状态 - 明确告知暂无数据
<EmptyData message="暂无用户数据,用户注册后会自动同步到此列表" />
) : (
// 正常显示数据
users.map((user) => (
<div
key={user.accountId}
className={cn(
styles.users__tableRow,
selectedRows.includes(user.accountId) && styles['users__tableRow--selected']
)}
>
{/* 复选框 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--checkbox'])}>
<input
type="checkbox"
className={styles.users__checkbox}
checked={selectedRows.includes(user.accountId)}
onChange={(e) => handleSelectRow(user.accountId, e.target.checked)}
/>
</div>
{/* 账户序号 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--id'])}>
{user.accountSequence || user.accountId}
</div>
{/* 头像 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--avatar'])}>
<div
className={styles.users__avatar}
style={{ backgroundImage: `url(${user.avatar || '/images/Data@2x.png'})` }}
>
<div
className={cn(
styles.users__avatarStatus,
styles[`users__avatarStatus--${getStatusClass(user)}`]
)}
/>
</div>
</div>
{/* 昵称 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--nickname'])}>
{user.nickname || '-'}
</div>
{/* 手机号 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--phone'])}>
{user.phoneNumberMasked || '-'}
</div>
{/* 账户认种量 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--adoptions'])}>
{formatNumber(user.personalAdoptions)}
</div>
{/* 团队总认种量 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--teamTotal'])}>
{formatNumber(user.teamAdoptions)}
</div>
{/* 团队本省认种量及占比 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--province'])}>
<span>{formatNumber(user.provincialAdoptions.count)}</span>
<span className={styles.users__percentage}>
({user.provincialAdoptions.percentage}%)
</span>
</div>
{/* 团队本市认种量及占比 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--city'])}>
<span>{formatNumber(user.cityAdoptions.count)}</span>
<span className={styles.users__percentage}>
({user.cityAdoptions.percentage}%)
</span>
</div>
{/* 推荐人序列号 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--referrer'])}>
{user.referrerId || '-'}
</div>
{/* 龙虎榜排名 */}
<div
className={cn(
styles.users__tableCell,
styles['users__tableCell--ranking'],
user.ranking && user.ranking <= 10
? styles['users__tableCell--gold']
: styles['users__tableCell--normal']
)}
>
{user.ranking ? formatRanking(user.ranking) : '-'}
</div>
{/* 操作 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--actions'])}>
<Link
href={`/users/${user.accountSequence || user.accountId}`}
className={styles.users__rowAction}
>
</Link>
</div>
</div>
))
)}
</div>
</div>
{/* 分页区域 */}
<div className={styles.users__pagination}>
<div className={styles.users__paginationInfo}>
<span className={styles.users__paginationLabel}>:</span>
<select
className={styles.users__paginationSelect}
value={pagination.pageSize}
onChange={(e) =>
setPagination({ current: 1, pageSize: Number(e.target.value) })
}
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
<span className={styles.users__paginationTotal}>
<span>{total}</span>
</span>
</div>
<div className={styles.users__paginationList}>{renderPaginationButtons()}</div>
</div>
</div>
</div>
</PageContainer>
);
}