Files
beige/frontend/src/components/tasks/TextCandidateCard.tsx
yangqianqian 25f86b2f4a 存量前端:生图分组/灯箱/重生控件/导入文案/驳回横幅+SSE+总核销表
- ImageStrategyGroup/ImageLightbox/RegenControls/ImportTextModal/
  RejectReasonBanner 新建组件
- tasks images/text/new + history + 配置/布局/SSE hooks 存量改动
- 总核销表进度更新

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:17:53 +08:00

181 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
// TextCandidateCard — 文案候选卡AI评委7维分 + 总评 + 违禁状态 + 导入角标
import { useState } from 'react';
import { TextCandidate } from '@/types/index';
import { ScoreDimBars } from './ScoreDimBars';
import { clsx } from 'clsx' ;
interface TextCandidateCardProps {
candidate: TextCandidate;
selected: boolean;
onToggle: (id: number) => void;
onSaveEdit?: (id: number, content: string) => Promise<void>;
}
const BANNED_STATUS_STYLES = {
pass: null,
auto_fixed: 'bg-green-50 border-brand-green text-brand-green-dark text-xs px-2 py-0.5 rounded-full',
soft_warn: 'bg-yellow-50 border-yellow-400 text-yellow-700 text-xs px-2 py-0.5 rounded-full',
hard_block: 'bg-red-50 border-red-400 text-status-error text-xs px-2 py-0.5 rounded-full',
};
const VERDICT_STYLES: Record<string, string> = {
'优秀': 'text-status-success font-medium',
'合格': 'text-text-secondary',
'不合格':'text-status-error',
};
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)}
aria-selected={selected}
aria-label={`文案:${candidate.angle_label},总分${candidate.score.total}${selected ? '已选中' : '未选中'}`}
className={clsx(
'relative border rounded-xl p-4 cursor-pointer transition-all',
selected
? 'border-brand-orange bg-brand-cream shadow-sm'
: 'border-border-default bg-surface-primary hover:border-brand-orange/50'
)}
>
{/* 角标:导入来源 */}
{candidate.source === 'import' && (
<span className="absolute top-2 right-2 text-xs bg-surface-tertiary text-text-secondary px-2 py-0.5 rounded-full">
</span>
)}
{/* 选中勾 */}
{selected && (
<span className="absolute top-2 left-2 w-5 h-5 bg-brand-orange text-white rounded-full flex items-center justify-center text-xs"
aria-hidden="true"></span>
)}
<div className="mt-1">
<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>
{/* 违禁词状态 */}
{candidate.banned_word_status !== 'pass' && (
<div className="mt-2">
<span className={BANNED_STATUS_STYLES[candidate.banned_word_status] ?? ''}>
{candidate.banned_word_status === 'auto_fixed' && '已自动改写'}
{candidate.banned_word_status === 'soft_warn' && '含敏感词,请检查'}
{candidate.banned_word_status === 'hard_block' && '含违禁词,已拦截'}
</span>
</div>
)}
{/* 七维分数条 */}
<ScoreDimBars score={candidate.score} passLine={85} />
{/* AI 评委总评verdict + summary旧数据空字符串时不渲染*/}
{candidate.summary && (
<p className="mt-2 text-xs text-text-secondary leading-relaxed">
{candidate.verdict && (
<span className={clsx('mr-1', VERDICT_STYLES[candidate.verdict] ?? 'text-text-secondary')}>
[{candidate.verdict}]
</span>
)}
{candidate.summary}
</p>
)}
</article>
);
}
/** 骨架屏占位 */
export function TextCandidateCardSkeleton() {
return (
<div className="border border-border-default rounded-xl p-4 animate-pulse space-y-2" aria-hidden="true">
<div className="h-3 bg-surface-tertiary rounded w-1/3" />
<div className="h-4 bg-surface-tertiary rounded w-full" />
<div className="h-4 bg-surface-tertiary rounded w-5/6" />
<div className="h-4 bg-surface-tertiary rounded w-4/6" />
<div className="mt-3 space-y-1">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-2 items-center">
<div className="h-2 bg-surface-tertiary rounded w-12" />
<div className="h-1.5 bg-surface-tertiary rounded flex-1" />
</div>
))}
</div>
</div>
);
}