存量前端:生图分组/灯箱/重生控件/导入文案/驳回横幅+SSE+总核销表

- ImageStrategyGroup/ImageLightbox/RegenControls/ImportTextModal/
  RejectReasonBanner 新建组件
- tasks images/text/new + history + 配置/布局/SSE hooks 存量改动
- 总核销表进度更新

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-18 11:17:53 +08:00
parent d85dcd401b
commit 25f86b2f4a
20 changed files with 741 additions and 60 deletions

View File

@@ -15,7 +15,8 @@ const ROUTE_ROLES: Record<string, UserRole[]> = {
'/config': ['admin'],
'/review': ['admin', 'supervisor'],
'/history': ['admin', 'supervisor', 'operator'],
'/tasks/new': ['admin', 'operator'],
'/tasks/new': ['admin', 'supervisor', 'operator'],
'/fission': ['admin', 'supervisor', 'operator'],
'/dashboard': ['admin', 'supervisor', 'operator'],
};

View File

@@ -58,16 +58,22 @@ export function ProductImageUpload({
</button>
</div>
) : (
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="text-xs border border-dashed border-border-default rounded px-3 py-1
text-text-secondary hover:border-brand-orange hover:text-brand-orange
disabled:opacity-50 transition-colors"
>
{uploading ? '上传中…' : '+ 上传产品参考图'}
</button>
<div>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="text-xs border border-dashed border-border-default rounded px-3 py-1
text-text-secondary hover:border-brand-orange hover:text-brand-orange
disabled:opacity-50 transition-colors"
>
{uploading ? '上传中…' : '+ 上传产品参考图'}
</button>
<p className="text-xs text-text-tertiary mt-1 leading-relaxed">
/
</p>
</div>
)}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
<input

View File

@@ -21,8 +21,8 @@ interface ProgressBarProps {
export function ProgressBar({ currentStep }: ProgressBarProps) {
const { role } = useAuthStore();
// 组长/管理员不走流程条
if (role === 'supervisor' || role === 'admin') return null;
// 管理员是配置者角色不走流程条supervisor 含运营全部操作,走流程时与 operator 一致显示
if (role === 'admin') return null;
return (
<div

View File

@@ -16,11 +16,14 @@ interface NavItem {
icon: string;
}
// 仅保留真实存在的路由(app/目录)。选文案/选图片是任务流程内步骤
// (经 /tasks/[id]/text|images 进入,需 task id,不做顶层导航)。
// 三并列顶层创作入口倩倩姐2026-06-15拍板"三并列入口"
// 文案创作=开新任务;图片排版=历史任务列表里挑一条进图片步骤(图片排版必须有 task id,
// 故落到 /history,每行提供"去排图"入口,不造无 task-id 的死链);裂变扩散=独立裂变页。
const NAV_ITEMS: NavItem[] = [
{ label: '工作台', href: '/dashboard', icon: '🏠' },
{ label: '开新任务', href: '/tasks/new', icon: '✏️' },
{ label: '文案创作', href: '/tasks/new', icon: '✏️' },
{ label: '图片排版', href: '/history', icon: '🖼️' },
{ label: '裂变扩散', href: '/fission', icon: '🌱' },
{ label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' },
{ label: '历史归档', href: '/history', icon: '🗂️' },
{ label: '配置中心', href: '/config', roles: ['admin'], icon: '⚙️' },

View File

@@ -9,7 +9,7 @@ export function ImageActionBar({ selectedCount, onProceed }: ImageActionBarProps
return (
<div className="sticky bottom-0 bg-surface-primary border-t border-border-default px-6 py-4 flex items-center justify-between mt-4">
<span className="text-sm text-text-secondary">
<strong className="text-text-primary">{selectedCount}</strong>
<strong className="text-text-primary">{selectedCount}</strong>
</span>
<button
onClick={onProceed}

View File

@@ -0,0 +1,57 @@
'use client';
// ImageLightbox — 点击挑图卡片放大看全图(解决"只看半截"ESC/点遮罩关闭
import { useEffect } from 'react';
interface ImageLightboxProps {
url: string | null;
caption?: string;
onClose: () => void;
}
export function ImageLightbox({ url, caption, onClose }: ImageLightboxProps) {
useEffect(() => {
if (!url) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
// 打开时锁滚动,关闭时恢复
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [url, onClose]);
if (!url) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="图片放大预览"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
>
<div className="relative max-h-full max-w-3xl" onClick={(e) => e.stopPropagation()}>
<button
onClick={onClose}
aria-label="关闭预览"
className="absolute -top-3 -right-3 z-10 h-9 w-9 rounded-full bg-white text-gray-700 shadow-lg hover:bg-gray-100"
>
</button>
{/* 原图按真实比例完整展示,不裁切 */}
<img
src={url}
alt={caption || '放大预览'}
className="max-h-[85vh] w-auto rounded-lg object-contain"
/>
{caption && (
<p className="mt-2 text-center text-sm text-white/90">{caption}</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
// ImageStrategyGroup — 按"套"(A/B/C)分组展示挑图,组内按 seq 固定6图序点图放大
import { ImageCandidate } from '@/types/index';
// 套别中文标签(三套正交叙事:痛点先行/场景先行/成分背书先行)
export const STRATEGY_LABEL: Record<string, string> = {
A: '套1 · 痛点先行',
B: '套2 · 场景先行',
C: '套3 · 成分背书先行',
};
// 角色中文名北哥固定6图序让"重试批次hook"变可读)
export const ROLE_LABEL: Record<string, string> = {
hook: '封面·痛点大字',
product_closeup: '产品特写',
ingredient: '成分背书',
texture: '质地展示',
applied_proof: '使用实证',
closer: '收尾导购',
pain: '痛点场景',
proof: '效果实证',
quality: '品质细节',
credit: '资质背书',
convert: '转化引导',
main: '主图',
};
interface ImageStrategyGroupProps {
strategy: string;
candidates: ImageCandidate[];
selectedImageIds: number[];
onToggle: (id: number) => void;
onZoom: (c: ImageCandidate) => void;
onRegen?: (strategy: string, role: string | undefined, label: string) => void;
}
export function ImageStrategyGroup({
strategy, candidates, selectedImageIds, onToggle, onZoom, onRegen,
}: ImageStrategyGroupProps) {
// 组内按 seq 升序北哥固定序seq 缺失的排末尾
const ordered = [...candidates].sort(
(a, b) => (a.seq ?? 999) - (b.seq ?? 999)
);
return (
<section className="mb-6">
<h3 className="mb-2 flex items-center text-sm font-semibold text-text-primary">
{STRATEGY_LABEL[strategy] || `${strategy}`}
<span className="ml-2 text-xs font-normal text-text-tertiary">
{ordered.length}
</span>
{onRegen && (
<button
onClick={() => onRegen(strategy, undefined, STRATEGY_LABEL[strategy] || `${strategy}`)}
className="ml-auto rounded-md border border-border-default px-2 py-0.5 text-xs font-normal text-text-secondary hover:border-brand-orange hover:text-brand-orange"
>
</button>
)}
</h3>
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
{ordered.map((c) => {
const selected = selectedImageIds.includes(c.candidate_id);
return (
<div
key={c.candidate_id}
className={`relative overflow-hidden rounded-xl border transition-all
${selected ? 'border-brand-orange ring-2 ring-brand-orange' : 'border-border-default'}`}
>
{/* 序号角标:组内第几张 */}
<span className="absolute left-1.5 top-1.5 z-10 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] font-semibold text-white">
{c.seq ?? '?'}
</span>
{onRegen && (
<button
onClick={() => onRegen(strategy, c.role, ROLE_LABEL[c.role] || c.role)}
aria-label={`重生 ${ROLE_LABEL[c.role] || c.role}`}
className="absolute bottom-9 right-1.5 z-10 rounded-md bg-black/55 px-1.5 py-0.5 text-[11px] text-white hover:bg-brand-orange"
>
</button>
)}
<img
src={c.url}
alt={ROLE_LABEL[c.role] || c.role}
onClick={() => onZoom(c)}
className="aspect-[3/4] w-full cursor-zoom-in object-cover"
/>
{c.ai_visual_score != null && (
<span
title={c.ai_visual_note || undefined}
className={`absolute right-1.5 top-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-semibold text-white
${c.ai_visual_score >= 80 ? 'bg-emerald-500' : c.ai_visual_score >= 60 ? 'bg-amber-500' : 'bg-gray-500'}`}
>
AI {Math.round(c.ai_visual_score)}
</span>
)}
<button
onClick={() => onToggle(c.candidate_id)}
aria-label={`${selected ? '取消选择' : '选择'} ${ROLE_LABEL[c.role] || c.role}`}
className={`w-full py-1.5 text-xs font-medium transition-colors
${selected ? 'bg-brand-orange text-white' : 'bg-surface-secondary text-text-secondary hover:bg-brand-orange/10'}`}
>
{ROLE_LABEL[c.role] || c.role}{selected ? ' ✓' : ''}
</button>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
/**
* ImportTextModal — 导入外部文案弹窗R4-c 轨B 补输入入口)
* 此前"导入外部文案"按钮直接建任务跳走,没给用户贴文案的地方。
* 这里让用户粘贴多条文案(用一行 "---" 分隔),确认后回调父组件:
* 父组件先建任务(track=import),再逐条调 import-text 端点,最后跳 text 页。
*/
import { useEffect, useState } from 'react';
interface Props {
open: boolean;
submitting: boolean;
onClose: () => void;
/** 返回切分后的多条文案,父组件负责建任务+逐条导入 */
onConfirm: (contents: string[]) => void;
}
const SPLIT_HINT = '多条文案用单独一行 --- 分隔;只贴一条则整体作为一条';
function splitContents(raw: string): string[] {
return raw
.split(/^\s*---\s*$/m)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
export function ImportTextModal({ open, submitting, onClose, onConfirm }: Props) {
const [raw, setRaw] = useState('');
// 每次打开清空上次残留内容,避免重新打开显示旧文案+旧计数
useEffect(() => { if (open) setRaw(''); }, [open]);
if (!open) return null;
const parts = splitContents(raw);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-label="导入外部文案"
onClick={onClose}
>
<div
className="bg-surface-primary rounded-2xl shadow-xl w-full max-w-lg p-6"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold text-text-primary mb-1"></h3>
<p className="text-xs text-text-tertiary mb-3 leading-relaxed">
AI {SPLIT_HINT}
</p>
<textarea
value={raw}
onChange={(e) => setRaw(e.target.value)}
rows={10}
placeholder={'第一条文案…\n---\n第二条文案…'}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm
focus:outline-none focus:border-brand-orange resize-y"
aria-label="粘贴文案内容"
/>
<div className="flex items-center justify-between mt-3">
<span className="text-xs text-text-secondary">
<strong className="text-text-primary">{parts.length}</strong>
</span>
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="text-sm px-4 py-2 rounded-lg border border-border-default text-text-secondary hover:bg-surface-tertiary"
>
</button>
<button
type="button"
disabled={parts.length === 0 || submitting}
onClick={() => onConfirm(parts)}
className="text-sm px-4 py-2 rounded-lg bg-brand-orange text-white font-medium
hover:bg-orange-600 disabled:opacity-50"
>
{submitting ? '导入中…' : `导入这 ${parts.length} 条 →`}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
// RegenControls — R2 重生弹窗:单张/单套重生 + 可选人工提示词
import { useState } from 'react';
export interface RegenTarget {
strategy: 'A' | 'B' | 'C';
role?: string; // 有=单张重生;无=整套重生
label: string; // 展示用(套名 或 角色名)
}
interface RegenControlsProps {
target: RegenTarget | null;
onClose: () => void;
onSubmit: (target: RegenTarget, customPrompt: string) => Promise<void>;
}
export function RegenControls({ target, onClose, onSubmit }: RegenControlsProps) {
const [prompt, setPrompt] = useState('');
const [busy, setBusy] = useState(false);
if (!target) return null;
async function handleSubmit() {
if (!target || busy) return;
setBusy(true);
try {
await onSubmit(target, prompt.trim());
setPrompt('');
onClose();
} finally {
setBusy(false);
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-2xl bg-white p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-1 text-base font-semibold text-text-primary">
{target.role ? '此张' : '整套'}{target.label}
</h3>
<p className="mb-3 text-xs text-text-tertiary">
</p>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
maxLength={200}
rows={3}
placeholder="例:标题字再大一点,背景换成浅米色,突出成分卖点"
className="mb-3 w-full resize-none rounded-lg border border-border-default p-2 text-sm focus:border-brand-orange focus:outline-none"
/>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-secondary"
>
</button>
<button
onClick={handleSubmit}
disabled={busy}
className="rounded-lg bg-brand-orange px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? '提交中…' : '开始重生'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
/**
* RejectReasonBanner — 打回原因横幅R4-b 审核回路死链修复)
* 被打回的任务回到 pending_selection用户重进挑选页时显示"为何被打回"
* 不再一脸懵。任务页/历史页可复用。
* 自取 task 详情判断 review_status==='rejected' 才显示。
*/
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { TaskListItem } from '@/types/dto.tasks';
interface Props {
taskId: number | null;
/** 外部已有 task 数据时直接传入,省一次请求 */
task?: TaskListItem | null;
}
export function RejectReasonBanner({ taskId, task: taskProp }: Props) {
const [task, setTask] = useState<TaskListItem | null>(taskProp ?? null);
useEffect(() => {
if (taskProp) { setTask(taskProp); return; }
if (!taskId) return;
let alive = true;
api.get<TaskListItem>(`/api/v1/tasks/${taskId}`)
.then((d) => { if (alive) setTask(d); })
.catch(() => { /* 取不到不显示,不阻塞页面 */ });
return () => { alive = false; };
}, [taskId, taskProp]);
if (!task || task.review_status !== 'rejected' || !task.reject_reason) return null;
return (
<div
className="mx-6 mt-4 bg-red-50 border border-red-200 rounded-xl px-4 py-3"
role="alert"
>
<p className="text-sm font-medium text-red-700">
<span aria-hidden="true"></span>
</p>
<p className="text-sm text-red-600 mt-1 leading-relaxed">
{task.reject_reason}
</p>
</div>
);
}

View File

@@ -9,6 +9,7 @@ interface TextCandidateCardProps {
candidate: TextCandidate;
selected: boolean;
onToggle: (id: number) => void;
onSaveEdit?: (id: number, content: string) => Promise<void>;
}
const BANNED_STATUS_STYLES = {
@@ -24,8 +25,27 @@ const VERDICT_STYLES: Record<string, string> = {
'不合格':'text-status-error',
};
export function TextCandidateCard({ candidate, selected, onToggle }: TextCandidateCardProps) {
export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit }: TextCandidateCardProps) {
const [expanded, setExpanded] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(candidate.content);
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
async function handleSave(e: React.MouseEvent) {
e.stopPropagation();
if (!onSaveEdit || saving || draft.trim() === '') return;
setSaving(true);
try {
await onSaveEdit(candidate.candidate_id, draft);
setEditing(false);
setJustSaved(true); // 飞轮节点反馈:改稿=最强信号已喂回
setTimeout(() => setJustSaved(false), 4000);
} finally {
setSaving(false);
}
}
return (
<article
onClick={() => onToggle(candidate.candidate_id)}
@@ -52,18 +72,62 @@ export function TextCandidateCard({ candidate, selected, onToggle }: TextCandida
)}
<div className="mt-1">
<span className="text-xs font-medium text-brand-orange mb-1 block">{candidate.angle_label}</span>
<p className={clsx(
'text-sm text-text-primary whitespace-pre-wrap',
!expanded && 'line-clamp-4'
)}>{candidate.content}</p>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
className="mt-1 text-xs text-brand-orange hover:underline"
>
{expanded ? '收起' : '展开全文'}
</button>
<span className="text-xs font-medium text-brand-orange mb-1 block">
{candidate.angle_label}
{(candidate.edited || justSaved) && (
<span className="ml-1.5 text-[10px] text-brand-green-dark bg-brand-green-light px-1.5 py-0.5 rounded-full">稿</span>
)}
</span>
{editing ? (
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
rows={6}
className="w-full text-sm border border-brand-orange/50 rounded-lg p-2 focus:outline-none focus:ring-1 focus:ring-brand-orange"
aria-label="改稿编辑框"
/>
) : (
<>
<p className={clsx(
'text-sm text-text-primary whitespace-pre-wrap',
!expanded && 'line-clamp-4'
)}>{candidate.content}</p>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
className="mt-1 text-xs text-brand-orange hover:underline"
>
{expanded ? '收起' : '展开全文'}
</button>
</>
)}
{/* D1 改稿入口(飞轮最强信号 text_edit +5 */}
{onSaveEdit && (
<div className="mt-2 flex items-center gap-2">
{editing ? (
<>
<button type="button" onClick={handleSave} disabled={saving}
className="text-xs bg-brand-orange text-white rounded-md px-2.5 py-1 hover:bg-orange-600 disabled:opacity-50">
{saving ? '保存中…' : '保存改稿'}
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); setEditing(false); setDraft(candidate.content); }}
className="text-xs text-text-secondary hover:underline"></button>
</>
) : (
<button type="button" onClick={(e) => { e.stopPropagation(); setDraft(candidate.content); setEditing(true); }}
className="text-xs text-brand-orange hover:underline"> 稿</button>
)}
</div>
)}
{/* 飞轮节点反馈:改稿即时提示已学习 */}
{justSaved && (
<p role="status" aria-live="polite" className="mt-1.5 text-[11px] text-brand-green-dark">
🌿
</p>
)}
</div>
{/* 违禁词状态 */}