241 lines
9.5 KiB
TypeScript
241 lines
9.5 KiB
TypeScript
'use client';
|
||
/**
|
||
* HistoryTable / HistorySkeleton / Pagination / DownloadButton
|
||
* 历史归档页子组件(按约束拆出 ≤100行)
|
||
*
|
||
* C1修复:下载走 fetch+blob,不用 window.open。
|
||
* window.open 无法附带 Authorization header,会 401。
|
||
* 改法:api.getBlob 拿二进制流 → URL.createObjectURL → a.click → revoke。
|
||
*/
|
||
import { useState, Fragment } from 'react';
|
||
import Link from 'next/link';
|
||
import { clsx } from 'clsx';
|
||
import { TaskListItem } from '@/types';
|
||
import { api } from '@/lib/api';
|
||
|
||
const STATUS_LABEL: Record<string, { text: string; color: string }> = {
|
||
approved: { text: '已通过', color: 'text-green-600 bg-green-50' },
|
||
archived: { text: '已归档', color: 'text-gray-500 bg-gray-100' },
|
||
rejected: { text: '被打回', color: 'text-red-500 bg-red-50' },
|
||
failed: { text: '生成失败', color: 'text-red-600 bg-red-50' },
|
||
fission: { text: '裂变', color: 'text-orange-600 bg-orange-50' },
|
||
};
|
||
|
||
// --- DownloadButton --- 触发打包→轮询→下载 zip
|
||
function DownloadButton({ taskId }: { taskId: number }) {
|
||
const [state, setState] = useState<'idle' | 'packaging' | 'done' | 'error'>('idle');
|
||
const [pkgId, setPkgId] = useState<number | null>(null);
|
||
|
||
async function handleDownload() {
|
||
setState('packaging');
|
||
try {
|
||
// 1. 触发打包
|
||
const pkg = await api.post<{ delivery_package_id: number }>(`/api/v1/tasks/${taskId}/package`, {});
|
||
const id = pkg.delivery_package_id;
|
||
setPkgId(id);
|
||
// 2. 轮询 status(最多20次,每2s)
|
||
for (let i = 0; i < 20; i++) {
|
||
await new Promise((r) => setTimeout(r, 2000));
|
||
const info = await api.get<{ status: string }>(`/api/v1/delivery-packages/${id}/download`);
|
||
if (info.status === 'ready' || info.status === 'downloaded') {
|
||
// 3. fetch+blob 下载(带 Authorization header,避免 window.open 401)
|
||
const blob = await api.getBlob(`/api/v1/delivery-packages/${id}/download-file`);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `delivery-${id}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
await api.post(`/api/v1/delivery-packages/${id}/mark-downloaded`);
|
||
setState('done');
|
||
return;
|
||
}
|
||
}
|
||
setState('error');
|
||
} catch { setState('error'); }
|
||
}
|
||
|
||
if (state === 'done') return (
|
||
<span className="text-xs text-green-600" title="文件已保存到浏览器默认下载目录(通常是「下载」文件夹)">
|
||
✓ 已下载到「下载」文件夹
|
||
</span>
|
||
);
|
||
if (state === 'error') return <span className="text-xs text-red-500">失败,刷新重试</span>;
|
||
return (
|
||
<button
|
||
onClick={handleDownload}
|
||
disabled={state === 'packaging'}
|
||
className="text-xs text-brand-orange hover:underline disabled:text-text-tertiary"
|
||
>
|
||
{state === 'packaging' ? '打包中…' : '下载交付包'}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function FissionDownloadButton({ fissionId }: { fissionId: number }) {
|
||
const [state, setState] = useState<'idle' | 'downloading' | 'done' | 'error'>('idle');
|
||
|
||
async function handleDownload() {
|
||
setState('downloading');
|
||
try {
|
||
const blob = await api.getBlob(`/api/v1/fission/${fissionId}/download`);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `fission-${fissionId}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
setState('done');
|
||
} catch {
|
||
setState('error');
|
||
}
|
||
}
|
||
|
||
if (state === 'done') return <span className="text-xs text-green-600">✓ 已下载</span>;
|
||
if (state === 'error') return <span className="text-xs text-red-500">失败,刷新重试</span>;
|
||
return (
|
||
<button
|
||
onClick={handleDownload}
|
||
disabled={state === 'downloading'}
|
||
className="text-xs text-brand-orange hover:underline disabled:text-text-tertiary"
|
||
>
|
||
{state === 'downloading' ? '下载中…' : '下载裂变包'}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// --- HistoryTable ---
|
||
export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
||
return (
|
||
<div className="overflow-x-auto rounded-lg border border-border-default">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-surface-secondary text-text-secondary">
|
||
<th className="px-4 py-3 text-left font-medium">任务</th>
|
||
<th className="px-4 py-3 text-left font-medium">产品</th>
|
||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||
<th className="px-4 py-3 text-left font-medium">文案</th>
|
||
<th className="px-4 py-3 text-left font-medium">图片</th>
|
||
<th className="px-4 py-3 text-left font-medium">创建时间</th>
|
||
<th className="px-4 py-3 text-left font-medium">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{tasks.map((task, i) => (
|
||
<Fragment key={task.id}>
|
||
<tr
|
||
className={clsx(
|
||
'border-t border-border-default hover:bg-surface-tertiary transition-colors',
|
||
i % 2 === 0 ? 'bg-white' : 'bg-surface-primary'
|
||
)}
|
||
>
|
||
<td className="px-4 py-3 text-text-primary font-medium max-w-xs truncate">
|
||
{task.theme}
|
||
</td>
|
||
<td className="px-4 py-3 text-text-secondary">
|
||
{task.product_name ?? '-'}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span className={clsx(
|
||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||
STATUS_LABEL[task.status]?.color ?? 'text-gray-500 bg-gray-100'
|
||
)}>
|
||
{STATUS_LABEL[task.status]?.text ?? task.status}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-text-secondary">{task.text_count} 条</td>
|
||
<td className="px-4 py-3 text-text-secondary">{task.image_count} 张</td>
|
||
<td className="px-4 py-3 text-text-tertiary">
|
||
{new Date(task.created_at).toLocaleDateString('zh-CN')}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-3">
|
||
<Link
|
||
href={task.item_type === 'fission' ? `/fission/${task.id}/notes` : `/tasks/${task.id}/confirm`}
|
||
className="text-brand-orange hover:underline text-xs"
|
||
>
|
||
查看
|
||
</Link>
|
||
{task.item_type !== 'fission' && task.status !== 'failed' && (
|
||
<Link
|
||
href={`/tasks/${task.id}/images`}
|
||
className="text-brand-orange hover:underline text-xs"
|
||
>
|
||
排图
|
||
</Link>
|
||
)}
|
||
{task.status === 'approved' && <DownloadButton taskId={task.id} />}
|
||
{task.item_type === 'fission' && <FissionDownloadButton fissionId={task.id} />}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{/* 被打回任务:原因独占一行展示,运营见"为何被打回"可重做(R8历史复用) */}
|
||
{task.status === 'rejected' && task.reject_reason && (
|
||
<tr className={i % 2 === 0 ? 'bg-white' : 'bg-surface-primary'}>
|
||
<td colSpan={7} className="px-4 pb-3 pt-0">
|
||
<div className="rounded-md bg-red-50 border border-red-200 px-3 py-2 text-xs text-red-600">
|
||
<span className="font-medium">打回原因:</span>{task.reject_reason}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- HistorySkeleton ---
|
||
export function HistorySkeleton() {
|
||
return (
|
||
<div className="rounded-lg border border-border-default overflow-hidden animate-pulse">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<div key={i} className="flex gap-4 px-4 py-3 border-t border-border-default first:border-t-0">
|
||
<div className="h-4 bg-gray-200 rounded flex-1" />
|
||
<div className="h-4 bg-gray-200 rounded w-20" />
|
||
<div className="h-4 bg-gray-200 rounded w-16" />
|
||
<div className="h-4 bg-gray-200 rounded w-12" />
|
||
<div className="h-4 bg-gray-200 rounded w-12" />
|
||
<div className="h-4 bg-gray-200 rounded w-24" />
|
||
<div className="h-4 bg-gray-200 rounded w-8" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- Pagination ---
|
||
export function Pagination({
|
||
page, total, onChange,
|
||
}: { page: number; total: number; onChange: (p: number) => void }) {
|
||
return (
|
||
<div className="flex items-center justify-center gap-2" aria-label="分页">
|
||
<button
|
||
disabled={page <= 1}
|
||
onClick={() => onChange(page - 1)}
|
||
className="px-3 py-1 rounded border border-border-default text-sm disabled:opacity-40 hover:bg-surface-tertiary"
|
||
aria-label="上一页"
|
||
>
|
||
上一页
|
||
</button>
|
||
<span className="text-sm text-text-secondary px-2">
|
||
第 {page} / {total} 页
|
||
</span>
|
||
<button
|
||
disabled={page >= total}
|
||
onClick={() => onChange(page + 1)}
|
||
className="px-3 py-1 rounded border border-border-default text-sm disabled:opacity-40 hover:bg-surface-tertiary"
|
||
aria-label="下一页"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|