feat: show fission records in history

This commit is contained in:
yangqianqian
2026-07-01 14:48:18 +08:00
parent 8c863efb7d
commit e26f53608b
5 changed files with 135 additions and 12 deletions

View File

@@ -183,10 +183,13 @@ def list_tasks(
):
from datetime import datetime, timedelta
from app.models.product import Product
include_fission = bool(status and "fission" in status)
task_statuses = [s for s in (status or []) if s != "fission"]
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
if status:
if task_statuses:
# 支持多状态(?status=approved&status=rejected),单值也兼容
q = q.filter(GenerationTask.status.in_(status))
q = q.filter(GenerationTask.status.in_(task_statuses))
if product_id is not None:
q = q.filter(GenerationTask.product_id == product_id)
# 日期筛选date 形参非法 → 40001(不静默吞成全量,防运营误判)
@@ -200,6 +203,7 @@ def list_tasks(
except ValueError:
from app.core.response import raise_param_error
raise_param_error("date_from/date_to 需为 YYYY-MM-DD 格式")
if not include_fission:
total = q.count()
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# product_name 一次性批量查(防 N+1):收集本页 product_id → id→name dict
@@ -211,10 +215,89 @@ def list_tasks(
rows = []
for t in items:
d = _fmt_task(t)
d["item_type"] = "task"
d["product_name"] = name_map.get(t.product_id)
rows.append(d)
return ok(paginate(rows, total, page, page_size))
from app.models.fission import FissionTask, FissionNote
task_items = q.all() if task_statuses else []
fission_q = db.query(FissionTask).filter(FissionTask.workspace_id == current_user.workspace_id)
try:
if date_from:
fission_q = fission_q.filter(FissionTask.created_at >= datetime.fromisoformat(date_from))
if date_to:
_end = datetime.fromisoformat(date_to) + timedelta(days=1)
fission_q = fission_q.filter(FissionTask.created_at < _end)
except ValueError:
from app.core.response import raise_param_error
raise_param_error("date_from/date_to 需为 YYYY-MM-DD 格式")
fission_items = fission_q.all()
source_ids = {f.source_task_id for f in fission_items if f.source_task_id}
source_tasks: dict[int, GenerationTask] = {}
if source_ids:
for t in db.query(GenerationTask).filter(GenerationTask.id.in_(source_ids)).all():
if t.workspace_id == current_user.workspace_id:
source_tasks[t.id] = t
if product_id is not None:
fission_items = [
f for f in fission_items
if f.source_task_id and source_tasks.get(f.source_task_id)
and source_tasks[f.source_task_id].product_id == product_id
]
# product_name 一次性批量查(防 N+1):收集本页 product_id → id→name dict
pids = {t.product_id for t in task_items if t.product_id}
pids.update(
t.product_id for t in source_tasks.values()
if t.product_id and (product_id is None or t.product_id == product_id)
)
name_map: dict[int, str] = {}
if pids:
for p in db.query(Product.id, Product.name).filter(Product.id.in_(pids)).all():
name_map[p.id] = p.name
rows = []
for t in task_items:
d = _fmt_task(t)
d["item_type"] = "task"
d["product_name"] = name_map.get(t.product_id)
rows.append(d)
for f in fission_items:
source = source_tasks.get(f.source_task_id) if f.source_task_id else None
notes = db.query(FissionNote).filter(
FissionNote.fission_id == f.id,
FissionNote.workspace_id == current_user.workspace_id,
).all()
image_count = 0
for n in notes:
import json
try:
images = json.loads(n.images_json) if n.images_json else []
except (TypeError, ValueError):
images = []
image_count += sum(1 for im in images if isinstance(im, dict) and im.get("url") and not im.get("error"))
rows.append({
"id": f.id,
"item_type": "fission",
"product_id": source.product_id if source else 0,
"product_name": name_map.get(source.product_id) if source else None,
"theme": (source.theme if source and source.theme else f"裂变笔记 #{f.id}"),
"status": "fission",
"created_at": f.created_at.isoformat(),
"text_count": len(notes),
"image_count": image_count,
"review_status": None,
"reject_reason": None,
"reviewed_at": None,
})
rows.sort(key=lambda x: x["created_at"], reverse=True)
total = len(rows)
start = (page - 1) * page_size
rows = rows[start:start + page_size]
return ok(paginate(rows, total, page, page_size))
@router.get("/{task_id}")
def get_task(

View File

@@ -18,6 +18,7 @@ const STATUS_LABEL: Record<string, { text: string; color: string }> = {
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
@@ -73,6 +74,40 @@ function DownloadButton({ taskId }: { taskId: number }) {
);
}
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 (
@@ -120,12 +155,12 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<Link
href={`/tasks/${task.id}/confirm`}
href={task.item_type === 'fission' ? `/fission/${task.id}/notes` : `/tasks/${task.id}/confirm`}
className="text-brand-orange hover:underline text-xs"
>
</Link>
{task.status !== 'failed' && (
{task.item_type !== 'fission' && task.status !== 'failed' && (
<Link
href={`/tasks/${task.id}/images`}
className="text-brand-orange hover:underline text-xs"
@@ -134,6 +169,7 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
</Link>
)}
{task.status === 'approved' && <DownloadButton taskId={task.id} />}
{task.item_type === 'fission' && <FissionDownloadButton fissionId={task.id} />}
</div>
</td>
</tr>

View File

@@ -13,9 +13,9 @@ import { clsx } from 'clsx';
import { HistoryTable, HistorySkeleton, Pagination } from './components';
import { HistoryFilters } from './HistoryFilters';
const HISTORY_STATUSES = ['approved', 'archived', 'rejected', 'failed'];
const HISTORY_STATUSES = ['approved', 'archived', 'rejected', 'failed', 'fission'];
const STATUS_LABELS: Record<string, string> = {
approved: '已通过', archived: '已归档', rejected: '被打回', failed: '生成失败',
approved: '已通过', archived: '已归档', rejected: '被打回', failed: '生成失败', fission: '裂变',
};
const PAGE_SIZE = 20;

View File

@@ -20,6 +20,7 @@ const STATUS_LABELS: Record<TaskStatus, { label: string; color: string }> = {
rejected: { label: '已打回', color: 'text-status-error' },
archived: { label: '已归档', color: 'text-text-tertiary' },
failed: { label: '生成失败', color: 'text-status-error' },
fission: { label: '裂变', color: 'text-brand-orange' },
};
export function RecentTasksBar() {
@@ -47,7 +48,9 @@ export function RecentTasksBar() {
{recentTasks.slice(0, 4).map((task) => {
const statusInfo = STATUS_LABELS[task.status];
return (
<Link key={task.id} href={`/tasks/${task.id}/text`}
<Link
key={task.id}
href={task.item_type === 'fission' ? `/fission/${task.id}/notes` : `/tasks/${task.id}/text`}
aria-label={`任务:${task.theme},状态:${statusInfo.label}`}
className="flex-1 border border-border-default rounded-xl p-3 bg-surface-primary hover:border-brand-orange transition-colors">
<p className="text-sm font-medium text-text-primary line-clamp-1">{task.theme}</p>

View File

@@ -3,10 +3,11 @@
// 任务
export type TaskStatus =
| 'pending' | 'generating' | 'pending_selection'
| 'pending_review' | 'approved' | 'rejected' | 'archived' | 'failed';
| 'pending_review' | 'approved' | 'rejected' | 'archived' | 'failed' | 'fission';
export interface TaskListItem {
id: number;
item_type?: 'task' | 'fission';
product_id: number;
product_name?: string | null; // M4: 历史页"产品"列,list_tasks 批量 JOIN 填充
theme: string;