feat: show fission records in history
This commit is contained in:
@@ -183,10 +183,13 @@ def list_tasks(
|
|||||||
):
|
):
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from app.models.product import Product
|
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)
|
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
|
||||||
if status:
|
if task_statuses:
|
||||||
# 支持多状态(?status=approved&status=rejected),单值也兼容
|
# 支持多状态(?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:
|
if product_id is not None:
|
||||||
q = q.filter(GenerationTask.product_id == product_id)
|
q = q.filter(GenerationTask.product_id == product_id)
|
||||||
# 日期筛选:date 形参非法 → 40001(不静默吞成全量,防运营误判)
|
# 日期筛选:date 形参非法 → 40001(不静默吞成全量,防运营误判)
|
||||||
@@ -200,19 +203,99 @@ def list_tasks(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
from app.core.response import raise_param_error
|
from app.core.response import raise_param_error
|
||||||
raise_param_error("date_from/date_to 需为 YYYY-MM-DD 格式")
|
raise_param_error("date_from/date_to 需为 YYYY-MM-DD 格式")
|
||||||
total = q.count()
|
if not include_fission:
|
||||||
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
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
|
||||||
|
pids = {t.product_id for t in items if t.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 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
|
# product_name 一次性批量查(防 N+1):收集本页 product_id → id→name dict
|
||||||
pids = {t.product_id for t in items if t.product_id}
|
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] = {}
|
name_map: dict[int, str] = {}
|
||||||
if pids:
|
if pids:
|
||||||
for p in db.query(Product.id, Product.name).filter(Product.id.in_(pids)).all():
|
for p in db.query(Product.id, Product.name).filter(Product.id.in_(pids)).all():
|
||||||
name_map[p.id] = p.name
|
name_map[p.id] = p.name
|
||||||
rows = []
|
rows = []
|
||||||
for t in items:
|
for t in task_items:
|
||||||
d = _fmt_task(t)
|
d = _fmt_task(t)
|
||||||
|
d["item_type"] = "task"
|
||||||
d["product_name"] = name_map.get(t.product_id)
|
d["product_name"] = name_map.get(t.product_id)
|
||||||
rows.append(d)
|
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))
|
return ok(paginate(rows, total, page, page_size))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const STATUS_LABEL: Record<string, { text: string; color: string }> = {
|
|||||||
archived: { text: '已归档', color: 'text-gray-500 bg-gray-100' },
|
archived: { text: '已归档', color: 'text-gray-500 bg-gray-100' },
|
||||||
rejected: { text: '被打回', color: 'text-red-500 bg-red-50' },
|
rejected: { text: '被打回', color: 'text-red-500 bg-red-50' },
|
||||||
failed: { text: '生成失败', color: 'text-red-600 bg-red-50' },
|
failed: { text: '生成失败', color: 'text-red-600 bg-red-50' },
|
||||||
|
fission: { text: '裂变', color: 'text-orange-600 bg-orange-50' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- DownloadButton --- 触发打包→轮询→下载 zip
|
// --- 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 ---
|
// --- HistoryTable ---
|
||||||
export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
||||||
return (
|
return (
|
||||||
@@ -120,12 +155,12 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<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"
|
className="text-brand-orange hover:underline text-xs"
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
</Link>
|
</Link>
|
||||||
{task.status !== 'failed' && (
|
{task.item_type !== 'fission' && task.status !== 'failed' && (
|
||||||
<Link
|
<Link
|
||||||
href={`/tasks/${task.id}/images`}
|
href={`/tasks/${task.id}/images`}
|
||||||
className="text-brand-orange hover:underline text-xs"
|
className="text-brand-orange hover:underline text-xs"
|
||||||
@@ -134,6 +169,7 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{task.status === 'approved' && <DownloadButton taskId={task.id} />}
|
{task.status === 'approved' && <DownloadButton taskId={task.id} />}
|
||||||
|
{task.item_type === 'fission' && <FissionDownloadButton fissionId={task.id} />}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import { clsx } from 'clsx';
|
|||||||
import { HistoryTable, HistorySkeleton, Pagination } from './components';
|
import { HistoryTable, HistorySkeleton, Pagination } from './components';
|
||||||
import { HistoryFilters } from './HistoryFilters';
|
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> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
approved: '已通过', archived: '已归档', rejected: '被打回', failed: '生成失败',
|
approved: '已通过', archived: '已归档', rejected: '被打回', failed: '生成失败', fission: '裂变',
|
||||||
};
|
};
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const STATUS_LABELS: Record<TaskStatus, { label: string; color: string }> = {
|
|||||||
rejected: { label: '已打回', color: 'text-status-error' },
|
rejected: { label: '已打回', color: 'text-status-error' },
|
||||||
archived: { label: '已归档', color: 'text-text-tertiary' },
|
archived: { label: '已归档', color: 'text-text-tertiary' },
|
||||||
failed: { label: '生成失败', color: 'text-status-error' },
|
failed: { label: '生成失败', color: 'text-status-error' },
|
||||||
|
fission: { label: '裂变', color: 'text-brand-orange' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RecentTasksBar() {
|
export function RecentTasksBar() {
|
||||||
@@ -47,7 +48,9 @@ export function RecentTasksBar() {
|
|||||||
{recentTasks.slice(0, 4).map((task) => {
|
{recentTasks.slice(0, 4).map((task) => {
|
||||||
const statusInfo = STATUS_LABELS[task.status];
|
const statusInfo = STATUS_LABELS[task.status];
|
||||||
return (
|
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}`}
|
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">
|
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>
|
<p className="text-sm font-medium text-text-primary line-clamp-1">{task.theme}</p>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
// 任务
|
// 任务
|
||||||
export type TaskStatus =
|
export type TaskStatus =
|
||||||
| 'pending' | 'generating' | 'pending_selection'
|
| 'pending' | 'generating' | 'pending_selection'
|
||||||
| 'pending_review' | 'approved' | 'rejected' | 'archived' | 'failed';
|
| 'pending_review' | 'approved' | 'rejected' | 'archived' | 'failed' | 'fission';
|
||||||
|
|
||||||
export interface TaskListItem {
|
export interface TaskListItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
item_type?: 'task' | 'fission';
|
||||||
product_id: number;
|
product_id: number;
|
||||||
product_name?: string | null; // M4: 历史页"产品"列,list_tasks 批量 JOIN 填充
|
product_name?: string | null; // M4: 历史页"产品"列,list_tasks 批量 JOIN 填充
|
||||||
theme: string;
|
theme: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user