Files
beige/frontend/src/app/history/components.tsx
2026-07-01 14:48:18 +08:00

241 lines
9.5 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';
/**
* 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>
);
}