- ImageStrategyGroup/ImageLightbox/RegenControls/ImportTextModal/ RejectReasonBanner 新建组件 - tasks images/text/new + history + 配置/布局/SSE hooks 存量改动 - 总核销表进度更新 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
181 lines
7.0 KiB
TypeScript
181 lines
7.0 KiB
TypeScript
'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>
|
||
);
|
||
}
|