feat(pre-planting): Admin Web 预种管理页面 + Sidebar 入口

[2026-02-17] Admin Web 预种计划管理页面完整实现

新增文件:
- (dashboard)/pre-planting/page.tsx: 预种管理页面
  - 预种开关控制卡片(开启/关闭 + 状态徽章)
  - 四格统计卡片(总订单、总份数、总金额、合成树数)
  - Tab 切换:预种订单 / 用户持仓 / 合并记录
  - 订单表格:订单号、用户、份数、金额、状态标签、时间
  - 持仓表格:用户、累计份数、待合并进度、合成树数、省市
  - 合并表格:合并号、用户、树数、来源订单、合同状态、挖矿状态
  - 搜索过滤、刷新、加载/错误/空状态处理
- pre-planting.module.scss: 页面样式
  - 开关状态卡片(渐变背景,开/关不同主题色)
  - 统计网格(4列响应式)
  - Tab、表格、状态标签样式

修改文件:
- Sidebar.tsx: 新增"预种管理"菜单项(数据统计与系统维护之间)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-18 05:54:21 -08:00
parent 63ae7662a4
commit 765a4f41d3
3 changed files with 787 additions and 0 deletions

View File

@ -0,0 +1,475 @@
'use client';
/**
* [2026-02-17]
*
*
* - /
* -
* - /
* -
* -
*
* === ===
* - React Query (usePrePlanting* hooks)
* - SCSS Modules
* - PageContainer
*
* === ===
*
* "数据统计""系统维护"
*/
import { useState } from 'react';
import { Button } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatDateTime } from '@/utils/formatters';
import {
usePrePlantingConfig,
usePrePlantingStats,
usePrePlantingOrders,
usePrePlantingPositions,
usePrePlantingMerges,
useTogglePrePlantingConfig,
} from '@/hooks';
import type {
PrePlantingAdminOrder,
PrePlantingAdminPosition,
PrePlantingAdminMerge,
} from '@/services/prePlantingService';
import styles from './pre-planting.module.scss';
// Tab 类型定义
type TabKey = 'orders' | 'positions' | 'merges';
// 订单状态显示映射
const ORDER_STATUS_MAP: Record<string, { label: string; style: string }> = {
CREATED: { label: '待支付', style: 'created' },
PAID: { label: '已支付', style: 'paid' },
MERGED: { label: '已合并', style: 'merged' },
};
// 合同状态显示映射
const CONTRACT_STATUS_MAP: Record<string, { label: string; style: string }> = {
PENDING: { label: '待签约', style: 'pending' },
SIGNED: { label: '已签约', style: 'signed' },
EXPIRED: { label: '已过期', style: 'expired' },
};
/**
*
*/
export default function PrePlantingPage() {
// === Tab 与分页状态 ===
const [activeTab, setActiveTab] = useState<TabKey>('orders');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const pageSize = 20;
// === React Query Hooks ===
const { data: config, isLoading: configLoading } = usePrePlantingConfig();
const { data: stats, isLoading: statsLoading } = usePrePlantingStats();
const toggleConfig = useTogglePrePlantingConfig();
const ordersQuery = usePrePlantingOrders({
page,
pageSize,
keyword: activeTab === 'orders' ? keyword : undefined,
});
const positionsQuery = usePrePlantingPositions({
page,
pageSize,
keyword: activeTab === 'positions' ? keyword : undefined,
});
const mergesQuery = usePrePlantingMerges({
page,
pageSize,
keyword: activeTab === 'merges' ? keyword : undefined,
});
// === 开关切换 ===
const handleToggle = () => {
if (!config || toggleConfig.isPending) return;
toggleConfig.mutate(!config.isActive);
};
// === Tab 切换时重置分页 ===
const handleTabChange = (tab: TabKey) => {
setActiveTab(tab);
setPage(1);
setKeyword('');
};
// === 格式化数字(千分位) ===
const formatNumber = (n: number) =>
n.toLocaleString('en-US');
return (
<PageContainer title="预种管理">
<div className={styles.prePlanting}>
{/* 页面标题 */}
<div className={styles.prePlanting__header}>
<h1 className={styles.prePlanting__title}></h1>
</div>
{/* 预种开关卡片 */}
<div
className={cn(
styles.prePlanting__switchCard,
config?.isActive && styles['prePlanting__switchCard--active']
)}
>
<div className={styles.prePlanting__switchInfo}>
<span className={styles.prePlanting__switchIcon}>
{config?.isActive ? '🌱' : '⏸️'}
</span>
<div>
<div className={styles.prePlanting__switchTitle}>
<span
className={cn(
styles.prePlanting__switchBadge,
config?.isActive
? styles['prePlanting__switchBadge--on']
: styles['prePlanting__switchBadge--off']
)}
>
{configLoading ? '加载中' : config?.isActive ? '已开启' : '已关闭'}
</span>
</div>
<div className={styles.prePlanting__switchDesc}>
{config?.isActive
? '用户可正常购买预种份额3171 USDT/份)'
: '新用户不可购买;已有未凑满份额的用户可继续购买至 5 的倍数'}
</div>
</div>
</div>
<button
className={cn(
styles.prePlanting__switchBtn,
config?.isActive
? styles['prePlanting__switchBtn--on']
: styles['prePlanting__switchBtn--off']
)}
onClick={handleToggle}
disabled={configLoading || toggleConfig.isPending}
>
{toggleConfig.isPending
? '切换中...'
: config?.isActive
? '关闭预种'
: '开启预种'}
</button>
</div>
{/* 统计卡片 */}
<div className={styles.prePlanting__statsGrid}>
<div className={styles.prePlanting__statCard}>
<div className={styles.prePlanting__statValue}>
{statsLoading ? '-' : formatNumber(stats?.totalOrders ?? 0)}
</div>
<div className={styles.prePlanting__statLabel}></div>
</div>
<div className={styles.prePlanting__statCard}>
<div className={styles.prePlanting__statValue}>
{statsLoading ? '-' : formatNumber(stats?.totalPortions ?? 0)}
</div>
<div className={styles.prePlanting__statLabel}></div>
</div>
<div className={styles.prePlanting__statCard}>
<div className={styles.prePlanting__statValue}>
{statsLoading ? '-' : formatNumber(stats?.totalAmount ?? 0)}
</div>
<div className={styles.prePlanting__statLabel}> (USDT)</div>
</div>
<div className={styles.prePlanting__statCard}>
<div className={styles.prePlanting__statValue}>
{statsLoading ? '-' : formatNumber(stats?.totalTreesMerged ?? 0)}
</div>
<div className={styles.prePlanting__statLabel}></div>
</div>
</div>
{/* Tab 切换 */}
<div className={styles.prePlanting__tabs}>
<button
className={cn(
styles.prePlanting__tab,
activeTab === 'orders' && styles['prePlanting__tab--active']
)}
onClick={() => handleTabChange('orders')}
>
</button>
<button
className={cn(
styles.prePlanting__tab,
activeTab === 'positions' && styles['prePlanting__tab--active']
)}
onClick={() => handleTabChange('positions')}
>
</button>
<button
className={cn(
styles.prePlanting__tab,
activeTab === 'merges' && styles['prePlanting__tab--active']
)}
onClick={() => handleTabChange('merges')}
>
</button>
</div>
{/* 数据内容区 */}
<div className={styles.prePlanting__card}>
{/* 搜索栏 */}
<div className={styles.prePlanting__toolbar}>
<div className={styles.prePlanting__search}>
<input
type="text"
placeholder={
activeTab === 'orders'
? '搜索订单号或用户账号...'
: activeTab === 'positions'
? '搜索用户账号...'
: '搜索合并号或用户账号...'
}
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
if (activeTab === 'orders') ordersQuery.refetch();
else if (activeTab === 'positions') positionsQuery.refetch();
else mergesQuery.refetch();
}}
>
</Button>
</div>
{/* Tab 对应的表格 */}
{activeTab === 'orders' && (
<OrdersTable
data={ordersQuery.data?.items ?? []}
loading={ordersQuery.isLoading}
error={ordersQuery.error}
onRetry={() => ordersQuery.refetch()}
/>
)}
{activeTab === 'positions' && (
<PositionsTable
data={positionsQuery.data?.items ?? []}
loading={positionsQuery.isLoading}
error={positionsQuery.error}
onRetry={() => positionsQuery.refetch()}
/>
)}
{activeTab === 'merges' && (
<MergesTable
data={mergesQuery.data?.items ?? []}
loading={mergesQuery.isLoading}
error={mergesQuery.error}
onRetry={() => mergesQuery.refetch()}
/>
)}
</div>
</div>
</PageContainer>
);
}
// ============================================
// 子组件:订单表格
// ============================================
function OrdersTable({
data,
loading,
error,
onRetry,
}: {
data: PrePlantingAdminOrder[];
loading: boolean;
error: Error | null;
onRetry: () => void;
}) {
if (loading) return <div className={styles.prePlanting__loading}>...</div>;
if (error) {
return (
<div className={styles.prePlanting__error}>
<span>{error.message || '加载失败'}</span>
<Button variant="outline" size="sm" onClick={onRetry}></Button>
</div>
);
}
if (data.length === 0) return <div className={styles.prePlanting__empty}></div>;
return (
<table className={styles.prePlanting__table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th> (USDT)</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data.map((order) => {
const status = ORDER_STATUS_MAP[order.status] ?? { label: order.status, style: '' };
return (
<tr key={order.id}>
<td>{order.orderNo}</td>
<td>{order.accountSequence}</td>
<td>{order.portionCount}</td>
<td>{order.totalAmount.toLocaleString()}</td>
<td>
<span className={cn(styles.prePlanting__status, styles[`prePlanting__status--${status.style}`])}>
{status.label}
</span>
</td>
<td>{order.paidAt ? formatDateTime(order.paidAt) : '-'}</td>
<td>{formatDateTime(order.createdAt)}</td>
</tr>
);
})}
</tbody>
</table>
);
}
// ============================================
// 子组件:持仓表格
// ============================================
function PositionsTable({
data,
loading,
error,
onRetry,
}: {
data: PrePlantingAdminPosition[];
loading: boolean;
error: Error | null;
onRetry: () => void;
}) {
if (loading) return <div className={styles.prePlanting__loading}>...</div>;
if (error) {
return (
<div className={styles.prePlanting__error}>
<span>{error.message || '加载失败'}</span>
<Button variant="outline" size="sm" onClick={onRetry}></Button>
</div>
);
}
if (data.length === 0) return <div className={styles.prePlanting__empty}></div>;
return (
<table className={styles.prePlanting__table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data.map((pos) => (
<tr key={pos.id}>
<td>{pos.accountSequence}</td>
<td>{pos.totalPortions}</td>
<td>{pos.availablePortions} / 5</td>
<td>{pos.mergedPortions}</td>
<td>{pos.totalTreesMerged}</td>
<td>{pos.provinceCode && pos.cityCode ? `${pos.provinceCode} · ${pos.cityCode}` : '-'}</td>
<td>{pos.firstPurchaseAt ? formatDateTime(pos.firstPurchaseAt) : '-'}</td>
</tr>
))}
</tbody>
</table>
);
}
// ============================================
// 子组件:合并记录表格
// ============================================
function MergesTable({
data,
loading,
error,
onRetry,
}: {
data: PrePlantingAdminMerge[];
loading: boolean;
error: Error | null;
onRetry: () => void;
}) {
if (loading) return <div className={styles.prePlanting__loading}>...</div>;
if (error) {
return (
<div className={styles.prePlanting__error}>
<span>{error.message || '加载失败'}</span>
<Button variant="outline" size="sm" onClick={onRetry}></Button>
</div>
);
}
if (data.length === 0) return <div className={styles.prePlanting__empty}></div>;
return (
<table className={styles.prePlanting__table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data.map((merge) => {
const cStatus = CONTRACT_STATUS_MAP[merge.contractStatus] ?? {
label: merge.contractStatus,
style: '',
};
return (
<tr key={merge.id}>
<td>{merge.mergeNo}</td>
<td>{merge.accountSequence}</td>
<td>{merge.treeCount}</td>
<td>{merge.sourceOrderNos.length} </td>
<td>
<span className={cn(styles.prePlanting__status, styles[`prePlanting__status--${cStatus.style}`])}>
{cStatus.label}
</span>
</td>
<td>{merge.contractSignedAt ? formatDateTime(merge.contractSignedAt) : '-'}</td>
<td>{merge.miningEnabledAt ? formatDateTime(merge.miningEnabledAt) : '-'}</td>
<td>{formatDateTime(merge.mergedAt)}</td>
</tr>
);
})}
</tbody>
</table>
);
}

View File

@ -0,0 +1,310 @@
/**
* [2026-02-17] 预种计划管理页面样式
*
* 包含开关状态卡片统计卡片Tab 切换数据表格
* 风格与全局管理后台一致
*/
@use '@/styles/variables' as *;
.prePlanting {
display: flex;
flex-direction: column;
gap: 24px;
// 页面标题
&__header {
display: flex;
justify-content: space-between;
align-items: center;
}
&__title {
font-size: 24px;
font-weight: 600;
color: $text-primary;
}
// 开关状态卡片
&__switchCard {
background: linear-gradient(135deg, #fff9e6 0%, #fff3cc 100%);
border: 1px solid #ffd666;
border-radius: 12px;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
&--active {
background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%);
border-color: #95de64;
}
}
&__switchInfo {
display: flex;
align-items: center;
gap: 12px;
}
&__switchIcon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: rgba(255, 255, 255, 0.8);
}
&__switchTitle {
font-size: 16px;
font-weight: 600;
color: $text-primary;
}
&__switchDesc {
font-size: 13px;
color: $text-secondary;
margin-top: 2px;
}
&__switchBadge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
margin-left: 8px;
&--on {
background: #52c41a;
color: white;
}
&--off {
background: #faad14;
color: white;
}
}
&__switchBtn {
padding: 8px 20px;
border-radius: 8px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&--on {
background: #ff4d4f;
color: white;
&:hover {
background: #ff7875;
}
}
&--off {
background: #52c41a;
color: white;
&:hover {
background: #73d13d;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// 统计卡片网格
&__statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
@media (max-width: 1200px) {
grid-template-columns: repeat(2, 1fr);
}
}
&__statCard {
background: $card-background;
border-radius: 12px;
padding: 20px;
box-shadow: $shadow-base;
text-align: center;
}
&__statValue {
font-size: 28px;
font-weight: 700;
color: $text-primary;
}
&__statLabel {
font-size: 13px;
color: $text-secondary;
margin-top: 4px;
}
// Tab 切换
&__tabs {
display: flex;
gap: 0;
border-bottom: 1px solid $border-color;
}
&__tab {
padding: 12px 24px;
font-size: 15px;
font-weight: 500;
color: $text-secondary;
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
transition: all 0.2s;
&:hover {
color: $text-primary;
}
&--active {
color: #d4af37;
border-bottom-color: #d4af37;
font-weight: 600;
}
}
// 数据卡片容器
&__card {
background: $card-background;
border-radius: 12px;
padding: 24px;
box-shadow: $shadow-base;
}
&__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
&__search {
display: flex;
gap: 8px;
align-items: center;
input {
width: 280px;
padding: 8px 12px;
border: 1px solid $border-color;
border-radius: 8px;
font-size: 14px;
outline: none;
&:focus {
border-color: #d4af37;
}
}
}
// 表格
&__table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid $border-color;
font-size: 14px;
}
th {
font-weight: 600;
color: $text-secondary;
background: #fafafa;
}
td {
color: $text-primary;
}
tr:hover td {
background: #fafafa;
}
}
// 状态标签
&__status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
&--created {
background: #fff7e6;
color: #d48806;
}
&--paid {
background: #f6ffed;
color: #389e0d;
}
&--merged {
background: #fff9e6;
color: #d4af37;
}
&--pending {
background: #fff7e6;
color: #d48806;
}
&--signed {
background: #f6ffed;
color: #389e0d;
}
&--expired {
background: #f5f5f5;
color: #8c8c8c;
}
}
// 加载 / / 错误 状态
&__loading,
&__empty,
&__error {
text-align: center;
padding: 48px 24px;
color: $text-secondary;
font-size: 14px;
}
&__error {
color: #ff4d4f;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
// 分页
&__pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}

View File

@ -35,6 +35,8 @@ const topMenuItems: MenuItem[] = [
{ key: 'withdrawals', icon: '/images/Container5.svg', label: '提现审核', path: '/withdrawals' }, { key: 'withdrawals', icon: '/images/Container5.svg', label: '提现审核', path: '/withdrawals' },
{ key: 'system-transfer', icon: '/images/Container5.svg', label: '系统划转', path: '/system-transfer' }, { key: 'system-transfer', icon: '/images/Container5.svg', label: '系统划转', path: '/system-transfer' },
{ key: 'statistics', icon: '/images/Container5.svg', label: '数据统计', path: '/statistics' }, { key: 'statistics', icon: '/images/Container5.svg', label: '数据统计', path: '/statistics' },
// [2026-02-17] 新增预种计划管理3171 USDT/份预种开关 + 订单/持仓/合并查询)
{ key: 'pre-planting', icon: '/images/Container3.svg', label: '预种管理', path: '/pre-planting' },
{ key: 'maintenance', icon: '/images/Container6.svg', label: '系统维护', path: '/maintenance' }, { key: 'maintenance', icon: '/images/Container6.svg', label: '系统维护', path: '/maintenance' },
{ key: 'settings', icon: '/images/Container6.svg', label: '系统设置', path: '/settings' }, { key: 'settings', icon: '/images/Container6.svg', label: '系统设置', path: '/settings' },
]; ];