baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
174
frontend/src/app/history/components.tsx
Normal file
174
frontend/src/app/history/components.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'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 } 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' },
|
||||
};
|
||||
|
||||
// --- 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);
|
||||
setState('done');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState('error');
|
||||
} 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 === 'packaging'}
|
||||
className="text-xs text-brand-orange hover:underline disabled:text-text-tertiary"
|
||||
>
|
||||
{state === 'packaging' ? '打包中…' : '下载交付包'}
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task, i) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
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">
|
||||
<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={`/tasks/${task.id}/confirm`}
|
||||
className="text-brand-orange hover:underline text-xs"
|
||||
>
|
||||
查看
|
||||
</Link>
|
||||
{task.status === 'approved' && <DownloadButton taskId={task.id} />}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</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-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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user