298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|