baseline: Clover 独立仓库首次基线提交

将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

View 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>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
/**
* 历史归档 /history
* 角色:熟手 + 组长(管理员也能进)
* 数据GET /api/v1/tasks?status=approved,archived,rejected
*/
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { TaskListItem, PaginatedResponse } from '@/types';
import { getErrorAction } from '@/types/errors';
import { clsx } from 'clsx';
import { HistoryTable, HistorySkeleton, Pagination } from './components';
const HISTORY_STATUSES = ['approved', 'archived', 'rejected'];
const STATUS_LABELS: Record<string, string> = {
approved: '已通过', archived: '已归档', rejected: '被打回',
};
const PAGE_SIZE = 20;
export default function HistoryPage() {
const [tasks, setTasks] = useState<TaskListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('all');
const fetchHistory = useCallback(async (p: number, status: string) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ page: String(p), page_size: String(PAGE_SIZE) });
if (status !== 'all') params.append('status', status);
else HISTORY_STATUSES.forEach((s) => params.append('status', s));
const res = await api.get<PaginatedResponse<TaskListItem>>(`/api/v1/tasks?${params}`);
setTasks(res.items);
setTotal(res.total);
} catch (e: unknown) {
const ae = e as { code?: number };
setError(getErrorAction(ae.code ?? 50001).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchHistory(page, statusFilter); }, [page, statusFilter, fetchHistory]);
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-text-primary"></h1>
<span className="text-sm text-text-tertiary"> {total} </span>
</div>
{/* 状态筛选 */}
<div className="flex gap-2" role="group" aria-label="状态筛选">
{[{ key: 'all', label: '全部' }, ...HISTORY_STATUSES.map((s) => ({
key: s, label: STATUS_LABELS[s],
}))].map((opt) => (
<button
key={opt.key}
onClick={() => { setStatusFilter(opt.key); setPage(1); }}
className={clsx(
'px-3 py-1 rounded-full text-sm border transition-colors',
statusFilter === opt.key
? 'bg-brand-orange text-white border-brand-orange'
: 'border-border-default text-text-secondary hover:border-brand-orange'
)}
>
{opt.label}
</button>
))}
</div>
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{error}
<button className="ml-4 underline" onClick={() => fetchHistory(page, statusFilter)}>
</button>
</div>
)}
{loading ? <HistorySkeleton /> : tasks.length === 0 ? (
<div className="py-20 text-center text-text-tertiary text-sm"></div>
) : (
<HistoryTable tasks={tasks} />
)}
{totalPages > 1 && <Pagination page={page} total={totalPages} onChange={setPage} />}
</div>
);
}