515 lines
18 KiB
TypeScript
515 lines
18 KiB
TypeScript
'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>
|
||
);
|
||
}
|