533 lines
19 KiB
TypeScript
533 lines
19 KiB
TypeScript
'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: 1(personalAdoptionCount >= 1)
|
||
// 'not_adopted' → maxAdoptions: 0(personalAdoptionCount = 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>
|
||
);
|
||
}
|