Files
deerflow-code/frontend-web/src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx
T
2026-05-20 15:21:23 +08:00

298 lines
12 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.
import { useState } from 'react';
import {
CheckCircle2, ChevronRight,
ExternalLink, ListTree, FileText,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { buildIntranetSourceUrl } from '@/core/config';
import type { AIMaterial, CompletedIntervention } from '../../types/ai-writing';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
import { ScoreBar } from './ScoreBar';
import { CollapseTransition } from './CollapseTransition';
interface Props {
record: CompletedIntervention;
}
export function CompletedInterventionCard({ record }: Props) {
const [expanded, setExpanded] = useState(false);
if (record.pausePoint === 'material_confirm') {
return <MaterialCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'outline_confirm') {
return <OutlineCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'draft_confirm') {
return <DraftCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'review_confirm') {
return <ReviewCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
return null;
}
// ── 素材确认历史卡 ────────────────────────────────────────────────────────────
function MaterialCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const count = record.materials?.length ?? 0;
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<FileText className="size-3.5 text-blue-500 shrink-0" />
<span className="flex-1 font-medium">
{count === 0 && record.action !== 're_search' ? (
'已在无素材的情况下继续写作'
) : (
<>
已确认{' '}
<span className="text-muted-foreground font-normal">{count} 条素材</span>
</>
)}
</span>
{count > 0 && (
<ChevronRight
className={cn(
'size-3 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
)}
</button>
<CollapseTransition open={expanded && !!record.materials && record.materials.length > 0}>
<div className="px-2.5 pb-2.5 pt-1.5 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
{record.materials?.map((m) => (
<CompletedMaterialItem key={m.id} material={m} />
))}
</div>
</CollapseTransition>
</div>
);
}
function relevanceLabel(score: number): string {
if (score >= 0.8) return '高相关';
if (score >= 0.5) return '中相关';
return '低相关';
}
function CompletedMaterialItem({ material }: { material: AIMaterial }) {
const [open, setOpen] = useState(false);
const pct = Math.round(material.relevanceScore * 100);
const barColor =
material.relevanceScore >= 0.8 ? 'bg-green-500'
: material.relevanceScore >= 0.5 ? 'bg-orange-400'
: 'bg-red-400';
const hasDetail = !!material.content || !!material.recUuid;
return (
<div className="rounded border border-border/50 bg-background overflow-hidden">
<button
type="button"
onClick={hasDetail ? () => setOpen(v => !v) : undefined}
className={cn(
'w-full px-2.5 py-1.5 space-y-1 text-left',
hasDetail && 'hover:bg-muted/30 transition-colors',
)}
>
<div className="flex items-start gap-1.5">
<span className="flex-1 font-medium leading-snug line-clamp-2">{material.title}</span>
{hasDetail && (
<ChevronRight
className={cn(
'mt-0.5 size-3 shrink-0 text-muted-foreground transition-transform duration-200',
open && 'rotate-90',
)}
/>
)}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="truncate max-w-[100px]">{material.source}</span>
{material.publishedAt && <><span className="text-muted-foreground/40">·</span><span>{material.publishedAt}</span></>}
<div className="flex flex-1 items-center gap-1 min-w-0">
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${pct}%` }} />
</div>
<span className={cn('shrink-0 text-[10px]', barColor.replace('bg-', 'text-'))}>{pct}%</span>
<span className="text-muted-foreground/60 shrink-0 text-[10px]">({relevanceLabel(material.relevanceScore)})</span>
</div>
</div>
</button>
<CollapseTransition open={open && hasDetail}>
<div className="px-2.5 pb-2 pt-1.5 space-y-1.5 border-t border-border/40">
{material.content && (
<p className="text-muted-foreground leading-relaxed line-clamp-6">
{material.content.slice(0, 300)}
{material.content.length > 300 && '…'}
</p>
)}
{material.recUuid && (
<button
type="button"
onClick={() => window.open(buildIntranetSourceUrl(material.recUuid), '_blank', 'noopener,noreferrer')}
className="inline-flex items-center gap-1 text-primary hover:underline"
>
查看原文
<ExternalLink className="size-2.5" />
</button>
)}
</div>
</CollapseTransition>
</div>
);
}
// ── 大纲确认历史卡 ────────────────────────────────────────────────────────────
function OutlineCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const sectionCount = record.outline?.sections.length ?? 0;
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="flex-1 font-medium">
已确认大纲
{record.outline?.title && (
<span className="ml-1 text-muted-foreground font-normal">
《{record.outline.title}》
</span>
)}
<span className="ml-1 text-muted-foreground font-normal">共 {sectionCount} 章节</span>
</span>
{record.outline && (
<ChevronRight
className={cn(
'size-3 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
)}
</button>
<CollapseTransition open={expanded && !!record.outline}>
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
{record.outline && <OutlineCard outline={record.outline} readOnly />}
</div>
</CollapseTransition>
</div>
);
}
// ── 草稿确认历史卡 ────────────────────────────────────────────────────────────
function DraftCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const title = record.draftTitle ? `《${record.draftTitle}》` : '草稿';
const wordCount = record.draftWordCount ?? 0;
const hasReview = !!record.reviewResult;
const actionLabel =
record.action === 'finalize' ? '已确认定稿' :
record.action === 'user_revise' ? '已要求修改' :
'已提交编辑审核';
const actionColor =
record.action === 'finalize' ? 'text-green-600 dark:text-green-400' :
record.action === 'user_revise' ? 'text-orange-500 dark:text-orange-400' :
'text-blue-500 dark:text-blue-400';
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={hasReview ? onToggle : undefined}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left',
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
)}
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<span className={cn('font-medium shrink-0', actionColor)}>{actionLabel}</span>
<span className="text-muted-foreground flex-1 truncate">
{title}
{wordCount > 0 && <span className="ml-1">({wordCount} 字)</span>}
</span>
{hasReview && (
<ChevronRight
className={cn(
'size-3 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
)}
</button>
<CollapseTransition open={expanded && !!record.reviewResult}>
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
{record.reviewResult && <ReviewCard review={record.reviewResult} />}
</div>
</CollapseTransition>
</div>
);
}
// ── 编辑审核确认历史卡 ────────────────────────────────────────────────────────
function ReviewCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const isForce = record.action === 'force_finalize';
const hasReview = !!record.reviewResult;
const actionLabel = isForce ? '已强制定稿(忽略编辑意见)' : '已采纳编辑意见,重新写作';
const actionColor = isForce ? 'text-orange-500 dark:text-orange-400' : 'text-blue-500 dark:text-blue-400';
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={hasReview ? onToggle : undefined}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left',
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
)}
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<span className={cn('font-medium flex-1', actionColor)}>{actionLabel}</span>
{hasReview && record.reviewResult && (
<span className="text-muted-foreground tabular-nums shrink-0">
{record.reviewResult.overallScore} / 100
</span>
)}
{hasReview && (
<ChevronRight
className={cn(
'size-3 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
)}
</button>
<CollapseTransition open={expanded && !!record.reviewResult}>
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
{record.reviewResult && (
<>
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
</>
)}
</div>
</CollapseTransition>
</div>
);
}