存量前端:生图分组/灯箱/重生控件/导入文案/驳回横幅+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:
@@ -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'],
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '⚙️' },
|
||||
|
||||
@@ -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}
|
||||
|
||||
57
frontend/src/components/tasks/ImageLightbox.tsx
Normal file
57
frontend/src/components/tasks/ImageLightbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
frontend/src/components/tasks/ImageStrategyGroup.tsx
Normal file
112
frontend/src/components/tasks/ImageStrategyGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/tasks/ImportTextModal.tsx
Normal file
86
frontend/src/components/tasks/ImportTextModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/tasks/RegenControls.tsx
Normal file
75
frontend/src/components/tasks/RegenControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/tasks/RejectReasonBanner.tsx
Normal file
46
frontend/src/components/tasks/RejectReasonBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{/* 违禁词状态 */}
|
||||
|
||||
Reference in New Issue
Block a user