C1: 新增轻量任务看板/board(按状态分列只读视图)
- board/components.tsx: BOARD_COLUMNS(4列:生成中/待审核/已通过/已打回)+TaskCard(只读点击进详情)+BoardColumn+BoardSkeleton - board/page.tsx: 复用 GET /api/v1/tasks?status=...(跟随角色不写前端权限),failed/archived不进看板 - Sidebar: 加「任务看板」📋入口;顺修两项同href的重复key warning(改key=label) - 卡片跳转:进行中/待审核→confirm,已通过/打回→images(路由均已存在) 独立agent交叉复核6项全过(状态全覆盖/多status参数与history一致/无前端权限/无死链/字段判空/边界安全),无must-fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
100
frontend/src/app/board/components.tsx
Normal file
100
frontend/src/app/board/components.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
/**
|
||||||
|
* 任务看板子组件:BoardColumn(状态列)+ TaskCard(任务卡,只读,点击进详情)
|
||||||
|
* C1(倩倩姐2026-06-18拍板「做轻量看板」):按状态分列只读视图,无拖拽。
|
||||||
|
* 复用 history 的状态色 + Link 跳转风格,保持视觉一致不割裂。
|
||||||
|
*/
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { TaskListItem } from '@/types';
|
||||||
|
|
||||||
|
// 列定义:4 列主流程,每列收哪些 status(倩倩姐拍板 failed/archived 不进看板)
|
||||||
|
export interface ColumnDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
statuses: string[];
|
||||||
|
accent: string; // 列头强调色
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOARD_COLUMNS: ColumnDef[] = [
|
||||||
|
{ key: 'generating', label: '生成中',
|
||||||
|
statuses: ['pending', 'generating', 'pending_selection'],
|
||||||
|
accent: 'text-blue-600 bg-blue-50' },
|
||||||
|
{ key: 'review', label: '待审核',
|
||||||
|
statuses: ['pending_review'], accent: 'text-amber-600 bg-amber-50' },
|
||||||
|
{ key: 'approved', label: '已通过',
|
||||||
|
statuses: ['approved'], accent: 'text-green-600 bg-green-50' },
|
||||||
|
{ key: 'rejected', label: '已打回',
|
||||||
|
statuses: ['rejected'], accent: 'text-red-500 bg-red-50' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 卡片点击目标:生成中/待审核进 confirm 看文案,其余进 images 排图/查看
|
||||||
|
function cardHref(task: TaskListItem): string {
|
||||||
|
if (['pending', 'generating', 'pending_selection', 'pending_review'].includes(task.status)) {
|
||||||
|
return `/tasks/${task.id}/confirm`;
|
||||||
|
}
|
||||||
|
return `/tasks/${task.id}/images`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskCard --- 单个任务卡,点击进详情
|
||||||
|
export function TaskCard({ task }: { task: TaskListItem }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={cardHref(task)}
|
||||||
|
aria-label={`任务 ${task.theme}`}
|
||||||
|
className="block rounded-lg border border-border-default bg-white p-3 hover:border-brand-orange hover:shadow-sm transition-all"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-text-primary line-clamp-2">{task.theme}</p>
|
||||||
|
<p className="mt-1 text-xs text-text-secondary">{task.product_name ?? '—'}</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-text-tertiary">
|
||||||
|
<span>{task.text_count} 文</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{task.image_count} 图</span>
|
||||||
|
<span className="ml-auto">{new Date(task.created_at).toLocaleDateString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
{/* 被打回任务:列内直接显示原因摘要,运营一眼看到为何被打回 */}
|
||||||
|
{task.status === 'rejected' && task.reject_reason && (
|
||||||
|
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-xs text-red-600 line-clamp-2">
|
||||||
|
{task.reject_reason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BoardColumn --- 状态列:列头 + 计数 + 卡片堆叠
|
||||||
|
export function BoardColumn({ col, tasks }: { col: ColumnDef; tasks: TaskListItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-w-[240px] flex-1 rounded-xl bg-surface-secondary p-3">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium', col.accent)}>
|
||||||
|
{col.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-tertiary">{tasks.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 overflow-y-auto">
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-xs text-text-tertiary">暂无任务</p>
|
||||||
|
) : (
|
||||||
|
tasks.map((t) => <TaskCard key={t.id} task={t} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BoardSkeleton --- 加载骨架
|
||||||
|
export function BoardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 animate-pulse">
|
||||||
|
{BOARD_COLUMNS.map((c) => (
|
||||||
|
<div key={c.key} className="flex-1 min-w-[240px] rounded-xl bg-surface-secondary p-3">
|
||||||
|
<div className="mb-3 h-5 w-16 rounded bg-gray-200" />
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="mb-2 h-20 rounded-lg bg-gray-200" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
frontend/src/app/board/page.tsx
Normal file
79
frontend/src/app/board/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
/**
|
||||||
|
* 任务看板 /board
|
||||||
|
* C1(倩倩姐2026-06-18拍板「做轻量看板」):按状态分列只读视图,点卡片进任务,无拖拽。
|
||||||
|
* 数据:GET /api/v1/tasks?status=...(跟随角色——运营看自己/组长管理员看全工作区,
|
||||||
|
* 权限逻辑与 history/review 一致,复用后端 list_tasks 不改接口)。
|
||||||
|
* failed/archived 不进看板(倩倩姐拍板),留在历史归档页看。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { TaskListItem, PaginatedResponse } from '@/types';
|
||||||
|
import { getErrorAction } from '@/types/errors';
|
||||||
|
import { BOARD_COLUMNS, BoardColumn, BoardSkeleton } from './components';
|
||||||
|
|
||||||
|
// 看板需要的全部状态(4 列并集),一次拉齐再前端分组
|
||||||
|
const BOARD_STATUSES = BOARD_COLUMNS.flatMap((c) => c.statuses);
|
||||||
|
const PAGE_SIZE = 100; // 看板一屏尽量全展示,进行中任务通常不会超百量级
|
||||||
|
|
||||||
|
export default function BoardPage() {
|
||||||
|
const [tasks, setTasks] = useState<TaskListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: '1', page_size: String(PAGE_SIZE) });
|
||||||
|
BOARD_STATUSES.forEach((s) => params.append('status', s));
|
||||||
|
const res = await api.get<PaginatedResponse<TaskListItem>>(`/api/v1/tasks?${params}`);
|
||||||
|
setTasks(res.items);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const ae = e as { code?: number };
|
||||||
|
setError(getErrorAction(ae.code ?? 50001).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||||
|
|
||||||
|
// 按列分组:每列收自己 statuses 命中的任务
|
||||||
|
const byColumn = BOARD_COLUMNS.map((col) => ({
|
||||||
|
col,
|
||||||
|
items: tasks.filter((t) => col.statuses.includes(t.status)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 h-full">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold text-text-primary">任务看板</h1>
|
||||||
|
<button
|
||||||
|
onClick={fetchTasks}
|
||||||
|
className="text-sm text-brand-orange hover:underline"
|
||||||
|
aria-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={fetchTasks}>重试</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<BoardSkeleton />
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-4 flex-1 overflow-x-auto">
|
||||||
|
{byColumn.map(({ col, items }) => (
|
||||||
|
<BoardColumn key={col.key} col={col} tasks={items} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ interface NavItem {
|
|||||||
// 故落到 /history,每行提供"去排图"入口,不造无 task-id 的死链);裂变扩散=独立裂变页。
|
// 故落到 /history,每行提供"去排图"入口,不造无 task-id 的死链);裂变扩散=独立裂变页。
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ label: '工作台', href: '/dashboard', icon: '🏠' },
|
{ label: '工作台', href: '/dashboard', icon: '🏠' },
|
||||||
|
{ label: '任务看板', href: '/board', icon: '📋' },
|
||||||
{ label: '文案创作', href: '/tasks/new', icon: '✏️' },
|
{ label: '文案创作', href: '/tasks/new', icon: '✏️' },
|
||||||
{ label: '图片排版', href: '/history', icon: '🖼️' },
|
{ label: '图片排版', href: '/history', icon: '🖼️' },
|
||||||
{ label: '裂变扩散', href: '/fission', icon: '🌱' },
|
{ label: '裂变扩散', href: '/fission', icon: '🌱' },
|
||||||
@@ -47,7 +48,7 @@ export function Sidebar() {
|
|||||||
const active = pathname === item.href || pathname.startsWith(item.href + '/');
|
const active = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.label}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
|||||||
Reference in New Issue
Block a user