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

515 lines
18 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';
/**
* 合同管理页面
* [2026-02-05] 新增:全局合同查询、筛选、批量下载功能
* 回滚方式:删除此目录即可
*/
import { useState, useEffect, useCallback } from 'react';
import { Button, toast } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatNumber } from '@/utils/formatters';
import {
contractService,
CONTRACT_STATUS_LABELS,
type ContractsListResponse,
type ContractStatisticsResponse,
type ContractQueryParams,
} from '@/services/contractService';
import { PROVINCE_CODE_NAMES } from '@/types';
import styles from './contracts.module.scss';
// 获取合同状态对应的样式类
const getStatusStyleClass = (status: string): string => {
switch (status) {
case 'SIGNED':
return styles.contracts__statusGreen;
case 'PENDING':
case 'SCROLLED':
case 'ACKNOWLEDGED':
return styles.contracts__statusYellow;
case 'EXPIRED':
case 'TIMEOUT':
return styles.contracts__statusRed;
default:
return styles.contracts__statusGray;
}
};
// 省份列表(用于筛选)- 只使用 6 位代码(数据库中存的是 6 位)
const PROVINCE_OPTIONS = Object.entries(PROVINCE_CODE_NAMES)
.filter(([code]) => code.length === 6)
.map(([code, name]) => ({
value: code,
label: name,
}));
/**
* 合同管理页面
*/
export default function ContractsPage() {
// 筛选状态
const [filters, setFilters] = useState<ContractQueryParams>({
page: 1,
pageSize: 20,
provinceCode: '',
cityCode: '',
status: '',
signedAfter: '',
signedBefore: '',
orderBy: 'signedAt',
orderDir: 'desc',
});
// 数据状态
const [data, setData] = useState<ContractsListResponse | null>(null);
const [statistics, setStatistics] = useState<ContractStatisticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [statsLoading, setStatsLoading] = useState(true);
// 增量下载状态
const [lastDownloadTime, setLastDownloadTime] = useState<string | null>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('contract_last_download_time');
}
return null;
});
// 加载合同列表
const loadContracts = useCallback(async () => {
setLoading(true);
try {
const result = await contractService.getContracts({
...filters,
provinceCode: filters.provinceCode || undefined,
cityCode: filters.cityCode || undefined,
status: filters.status || undefined,
signedAfter: filters.signedAfter || undefined,
signedBefore: filters.signedBefore || undefined,
});
setData(result);
} catch (error) {
console.error('获取合同列表失败:', error);
toast.error('获取合同列表失败');
} finally {
setLoading(false);
}
}, [filters]);
// 加载统计信息
const loadStatistics = useCallback(async () => {
setStatsLoading(true);
try {
const result = await contractService.getStatistics({
provinceCode: filters.provinceCode || undefined,
cityCode: filters.cityCode || undefined,
});
setStatistics(result);
} catch (error) {
console.error('获取统计信息失败:', error);
} finally {
setStatsLoading(false);
}
}, [filters.provinceCode, filters.cityCode]);
useEffect(() => {
loadContracts();
}, [loadContracts]);
useEffect(() => {
loadStatistics();
}, [loadStatistics]);
// 格式化日期
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('zh-CN');
};
// 格式化金额
const formatAmount = (amount: number) => {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// 下载单个合同
const handleDownloadContract = (orderNo: string) => {
const downloadUrl = contractService.getDownloadUrl(orderNo);
window.open(downloadUrl, '_blank');
};
// 批量下载状态
const [batchDownloading, setBatchDownloading] = useState(false);
const [batchProgress, setBatchProgress] = useState(0);
// 轮询任务状态直到完成
const pollTaskUntilComplete = async (taskNo: string): Promise<string | null> => {
const maxAttempts = 120; // 最多轮询2分钟
for (let i = 0; i < maxAttempts; i++) {
try {
const status = await contractService.getBatchDownloadStatus(taskNo);
setBatchProgress(status.progress);
if (status.status === 'COMPLETED' && status.resultFileUrl) {
return status.resultFileUrl;
} else if (status.status === 'FAILED') {
throw new Error('任务处理失败');
}
// 等待1秒后继续轮询
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error('查询任务状态失败:', error);
throw error;
}
}
throw new Error('任务处理超时');
};
// 使用 File System Access API 让用户选择保存位置Chrome/Edge 支持)
const saveFileWithPicker = async (url: string, suggestedName: string): Promise<boolean> => {
try {
// 检查浏览器是否支持 File System Access API
if ('showSaveFilePicker' in window) {
// 先下载文件内容
const response = await fetch(url);
const blob = await response.blob();
// 弹出保存对话框让用户选择位置
const fileHandle = await (window as unknown as { showSaveFilePicker: (options: unknown) => Promise<FileSystemFileHandle> }).showSaveFilePicker({
suggestedName,
types: [{
description: 'ZIP 压缩文件',
accept: { 'application/zip': ['.zip'] },
}],
});
// 写入文件
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
return true;
}
} catch (error) {
// 用户取消或浏览器不支持,回退到普通下载
console.log('File System Access API 不可用或用户取消,使用普通下载');
}
return false;
};
// 批量下载(创建下载任务并等待完成后自动下载)
const handleBatchDownload = async () => {
setBatchDownloading(true);
setBatchProgress(0);
try {
toast.info('正在准备批量下载,请稍候...');
const result = await contractService.createBatchDownload({
filters: {
signedAfter: filters.signedAfter || undefined,
signedBefore: filters.signedBefore || undefined,
provinceCode: filters.provinceCode || undefined,
cityCode: filters.cityCode || undefined,
},
});
// 轮询等待任务完成
const downloadUrl = await pollTaskUntilComplete(result.taskNo);
if (downloadUrl) {
// 生成文件名
const today = new Date().toISOString().split('T')[0].replace(/-/g, '');
const suggestedName = `contracts_${today}.zip`;
// 优先使用 File System Access API 让用户选择保存位置
toast.info('请选择保存位置...');
const saved = await saveFileWithPicker(downloadUrl, suggestedName);
if (saved) {
toast.success('文件保存成功!');
} else {
// 浏览器不支持或用户取消,使用备用方法
const link = document.createElement('a');
link.href = downloadUrl;
link.download = suggestedName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success('下载已开始');
}
// 保存当前时间作为最后下载时间
const now = new Date().toISOString();
localStorage.setItem('contract_last_download_time', now);
setLastDownloadTime(now);
}
} catch (error) {
console.error('批量下载失败:', error);
toast.error('批量下载失败,请重试');
} finally {
setBatchDownloading(false);
setBatchProgress(0);
}
};
// 增量下载(只下载上次之后签署的合同)
const handleIncrementalDownload = async () => {
if (!lastDownloadTime) {
toast.error('未找到上次下载记录,请先执行全量下载');
return;
}
setBatchDownloading(true);
setBatchProgress(0);
try {
toast.info('正在准备增量下载,请稍候...');
const result = await contractService.createBatchDownload({
filters: {
signedAfter: lastDownloadTime,
provinceCode: filters.provinceCode || undefined,
cityCode: filters.cityCode || undefined,
},
});
// 轮询等待任务完成
const downloadUrl = await pollTaskUntilComplete(result.taskNo);
if (downloadUrl) {
// 生成文件名
const today = new Date().toISOString().split('T')[0].replace(/-/g, '');
const suggestedName = `contracts_incremental_${today}.zip`;
// 优先使用 File System Access API 让用户选择保存位置
toast.info('请选择保存位置...');
const saved = await saveFileWithPicker(downloadUrl, suggestedName);
if (saved) {
toast.success('文件保存成功!');
} else {
// 浏览器不支持或用户取消,使用备用方法
const link = document.createElement('a');
link.href = downloadUrl;
link.download = suggestedName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success('下载已开始');
}
// 更新最后下载时间
const now = new Date().toISOString();
localStorage.setItem('contract_last_download_time', now);
setLastDownloadTime(now);
}
} catch (error) {
console.error('增量下载失败:', error);
toast.error('增量下载失败,请重试');
} finally {
setBatchDownloading(false);
setBatchProgress(0);
}
};
// 重置筛选
const handleResetFilters = () => {
setFilters({
page: 1,
pageSize: 20,
provinceCode: '',
cityCode: '',
status: '',
signedAfter: '',
signedBefore: '',
orderBy: 'signedAt',
orderDir: 'desc',
});
};
// 翻页
const handlePageChange = (newPage: number) => {
setFilters(prev => ({ ...prev, page: newPage }));
};
return (
<PageContainer title="合同管理">
<div className={styles.contracts}>
{/* 统计卡片 */}
<div className={styles.contracts__statsGrid}>
<div className={styles.contracts__statCard}>
<span className={styles.contracts__statLabel}></span>
<span className={styles.contracts__statValue}>
{statsLoading ? '...' : formatNumber(statistics?.totalContracts || 0)}
</span>
</div>
<div className={styles.contracts__statCard}>
<span className={styles.contracts__statLabel}></span>
<span className={cn(styles.contracts__statValue, styles.contracts__statusGreen)}>
{statsLoading ? '...' : formatNumber(statistics?.signedContracts || 0)}
</span>
</div>
<div className={styles.contracts__statCard}>
<span className={styles.contracts__statLabel}></span>
<span className={cn(styles.contracts__statValue, styles.contracts__statusYellow)}>
{statsLoading ? '...' : formatNumber(statistics?.pendingContracts || 0)}
</span>
</div>
<div className={styles.contracts__statCard}>
<span className={styles.contracts__statLabel}></span>
<span className={cn(styles.contracts__statValue, styles.contracts__statusRed)}>
{statsLoading ? '...' : formatNumber(statistics?.expiredContracts || 0)}
</span>
</div>
</div>
{/* 筛选栏 */}
<div className={styles.contracts__filters}>
<div className={styles.contracts__filterRow}>
<div className={styles.contracts__filterItem}>
<label>:</label>
<select
value={filters.provinceCode}
onChange={(e) => setFilters(prev => ({ ...prev, provinceCode: e.target.value, cityCode: '', page: 1 }))}
>
<option value=""></option>
{PROVINCE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className={styles.contracts__filterItem}>
<label>:</label>
<select
value={filters.status}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value, page: 1 }))}
>
<option value=""></option>
<option value="SIGNED"></option>
<option value="PENDING"></option>
<option value="EXPIRED"></option>
</select>
</div>
<div className={styles.contracts__filterItem}>
<label>:</label>
<input
type="date"
value={filters.signedAfter?.split('T')[0] || ''}
onChange={(e) => setFilters(prev => ({ ...prev, signedAfter: e.target.value ? `${e.target.value}T00:00:00.000Z` : '', page: 1 }))}
/>
</div>
<div className={styles.contracts__filterItem}>
<label>:</label>
<input
type="date"
value={filters.signedBefore?.split('T')[0] || ''}
onChange={(e) => setFilters(prev => ({ ...prev, signedBefore: e.target.value ? `${e.target.value}T23:59:59.999Z` : '', page: 1 }))}
/>
</div>
<Button variant="outline" size="sm" onClick={handleResetFilters}>
</Button>
</div>
<div className={styles.contracts__actions}>
<Button variant="primary" onClick={handleBatchDownload} disabled={batchDownloading}>
{batchDownloading ? `打包中 ${batchProgress}%...` : '批量下载 ZIP'}
</Button>
{lastDownloadTime && (
<Button variant="outline" onClick={handleIncrementalDownload} disabled={batchDownloading}>
: {new Date(lastDownloadTime).toLocaleString('zh-CN')}
</Button>
)}
</div>
</div>
{/* 合同列表 */}
<div className={styles.contracts__table}>
<div className={styles.contracts__tableHeader}>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
<div className={styles.contracts__tableCell}></div>
</div>
{loading ? (
<div className={styles.contracts__loading}>...</div>
) : data && data.items.length > 0 ? (
data.items.map((contract) => (
<div key={contract.orderNo} className={styles.contracts__tableRow}>
<div className={styles.contracts__tableCell}>{contract.contractNo}</div>
<div className={styles.contracts__tableCell}>{contract.orderNo}</div>
<div className={styles.contracts__tableCell}>
<div>{contract.userRealName || '未实名'}</div>
<div className={styles.contracts__tableSubtext}>{contract.accountSequence}</div>
</div>
<div className={styles.contracts__tableCell}>{formatNumber(contract.treeCount)}</div>
<div className={styles.contracts__tableCell}>{formatAmount(contract.totalAmount)}</div>
<div className={styles.contracts__tableCell}>
{contract.provinceName} / {contract.cityName}
</div>
<div className={cn(styles.contracts__tableCell, getStatusStyleClass(contract.status))}>
{CONTRACT_STATUS_LABELS[contract.status] || contract.status}
</div>
<div className={styles.contracts__tableCell}>
{contract.signedAt ? formatDate(contract.signedAt) : '-'}
</div>
<div className={styles.contracts__tableCell}>
{contract.status === 'SIGNED' && contract.signedPdfUrl && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadContract(contract.orderNo)}
>
</Button>
)}
</div>
</div>
))
) : (
<div className={styles.contracts__empty}></div>
)}
</div>
{/* 分页 */}
{data && data.totalPages > 1 && (
<div className={styles.contracts__pagination}>
<Button
variant="outline"
size="sm"
disabled={filters.page === 1}
onClick={() => handlePageChange(filters.page! - 1)}
>
</Button>
<span className={styles.contracts__pageInfo}>
{filters.page} / {data.totalPages} {data.total}
</span>
<Button
variant="outline"
size="sm"
disabled={filters.page === data.totalPages}
onClick={() => handlePageChange(filters.page! + 1)}
>
</Button>
</div>
)}
</div>
</PageContainer>
);
}