712 lines
28 KiB
TypeScript
712 lines
28 KiB
TypeScript
import { useRef, useState } from 'react';
|
||
import {
|
||
PauseCircle, CheckCircle2, RotateCcw, Search,
|
||
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
|
||
Trash2, BookOpen, Sparkles,
|
||
} from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { cn } from '@/lib/utils';
|
||
import type {
|
||
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
|
||
ProgressEvent, UserInterventionPayload,
|
||
BlockedSection, SectionDecisionKind,
|
||
SkillSearchStep,
|
||
} from '../../types/ai-writing';
|
||
import { MaterialsCard } from './MaterialsCard';
|
||
import { MaterialsToolbar } from './MaterialsToolbar';
|
||
import { OutlineCard } from './OutlineCard';
|
||
import { ReviewCard } from './ReviewCard';
|
||
import { SkillStepsCard } from './SkillStepsCard';
|
||
|
||
interface InterventionCardProps {
|
||
pauseEvent: ProgressEvent;
|
||
materials: AIMaterial[];
|
||
outline: AIOutline | null;
|
||
reviewResult: ReviewResult | null;
|
||
draftTitle: string;
|
||
revisionCount: number;
|
||
skillSearchSteps?: SkillSearchStep[];
|
||
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
|
||
skillSearchInProgress?: boolean;
|
||
onSubmit: (payload: UserInterventionPayload) => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
export function InterventionCard({
|
||
pauseEvent, materials, outline, reviewResult,
|
||
draftTitle, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
|
||
}: InterventionCardProps) {
|
||
const pausePoint = pauseEvent.pausePoint;
|
||
|
||
if (pausePoint === 'material_confirm') {
|
||
return (
|
||
<MaterialConfirmCard
|
||
materials={materials}
|
||
skillSearchSteps={skillSearchSteps ?? []}
|
||
skillSearchInProgress={skillSearchInProgress}
|
||
onSubmit={onSubmit}
|
||
disabled={disabled}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (pausePoint === 'outline_confirm') {
|
||
return (
|
||
<OutlineConfirmCard
|
||
outline={outline ?? pauseEvent.outline ?? null}
|
||
onSubmit={onSubmit}
|
||
disabled={disabled}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (pausePoint === 'draft_confirm') {
|
||
return (
|
||
<DraftConfirmCard
|
||
draftTitle={draftTitle}
|
||
revisionCount={revisionCount}
|
||
reviewResult={reviewResult}
|
||
onSubmit={onSubmit}
|
||
disabled={disabled}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (pausePoint === 'section_help') {
|
||
return (
|
||
<SectionHelpCard
|
||
blockedSections={pauseEvent.blockedSections ?? []}
|
||
onSubmit={onSubmit}
|
||
disabled={disabled}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (pausePoint === 'review_confirm') {
|
||
return (
|
||
<ReviewConfirmCard
|
||
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
|
||
revisionCount={revisionCount}
|
||
onSubmit={onSubmit}
|
||
disabled={disabled}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
|
||
<PauseCircle className="size-3.5 text-yellow-500 shrink-0" />
|
||
等待用户确认({pausePoint})…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||
|
||
function makeDefaultSelection(issues: ReviewIssue[]): Set<number> {
|
||
return new Set(issues.map((_, i) => i));
|
||
}
|
||
|
||
function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number>): string {
|
||
const selected = issues.filter((_, i) => selectedIndices.has(i));
|
||
if (selected.length === 0) return '';
|
||
return selected
|
||
.map((iss, n) => `${n + 1}. [${iss.category}] ${iss.issue}:${iss.suggestion}`)
|
||
.join('\n');
|
||
}
|
||
|
||
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
|
||
|
||
interface MaterialConfirmCardProps {
|
||
materials: AIMaterial[];
|
||
skillSearchSteps: SkillSearchStep[];
|
||
skillSearchInProgress?: boolean;
|
||
onSubmit: (payload: UserInterventionPayload) => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) {
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||
() => new Set(materials.map((m) => m.id)),
|
||
);
|
||
const [userQuery, setUserQuery] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
async function handleConfirm() {
|
||
setSubmitting(true);
|
||
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], userQuery: userQuery.trim() || undefined });
|
||
setSubmitting(false);
|
||
}
|
||
|
||
async function handleReSearch() {
|
||
setSubmitting(true);
|
||
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
|
||
setSubmitting(false);
|
||
}
|
||
|
||
async function handleEnableSkillSearch() {
|
||
setSubmitting(true);
|
||
await onSubmit({ action: 'enable_skill_search', userQuery: userQuery.trim() || undefined });
|
||
setSubmitting(false);
|
||
}
|
||
|
||
const hasQuery = userQuery.trim().length > 0;
|
||
// 当前素材是否来自技能检索:根据素材 sourceType 推断(覆盖式替换后全是 skill)
|
||
const isSkillResult = materials.length > 0 && materials.every((m) => m.sourceType === 'skill');
|
||
|
||
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
|
||
if (materials.length === 0) {
|
||
return (
|
||
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="text-xs font-medium">未检索到任何素材</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{(skillSearchSteps.length > 0 || skillSearchInProgress) && (
|
||
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
|
||
)}
|
||
|
||
<MaterialsToolbar
|
||
materials={materials}
|
||
selectedIds={selectedIds}
|
||
onSelectAll={() => setSelectedIds(new Set())}
|
||
onDeselectAll={() => setSelectedIds(new Set())}
|
||
userQuery={userQuery}
|
||
onUserQueryChange={setUserQuery}
|
||
onSubmitQuery={handleReSearch}
|
||
disabled={disabled || submitting}
|
||
/>
|
||
|
||
<div className="flex flex-wrap gap-2 pt-1">
|
||
<Button
|
||
type="button" size="sm" className="flex-1 min-w-[140px] h-8 text-xs gap-1.5"
|
||
onClick={handleReSearch} disabled={disabled || submitting}
|
||
>
|
||
{hasQuery
|
||
? <><Search className="size-3.5" />按输入重新搜索</>
|
||
: <><RotateCcw className="size-3.5" />重新搜索</>}
|
||
</Button>
|
||
<Button
|
||
type="button" variant="outline" size="sm"
|
||
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
|
||
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
|
||
>
|
||
<Sparkles className="size-3.5" />技能检索
|
||
</Button>
|
||
<Button
|
||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })} disabled={disabled || submitting}
|
||
>
|
||
<ArrowRight className="size-3.5" />无数据继续写作
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。
|
||
您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{skillSearchSteps.length > 0 && <SkillStepsCard steps={skillSearchSteps} />}
|
||
|
||
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
|
||
setSelectedIds((prev) => {
|
||
const next = new Set(prev);
|
||
next.has(id) ? next.delete(id) : next.add(id);
|
||
return next;
|
||
});
|
||
}} />
|
||
|
||
<MaterialsToolbar
|
||
materials={materials}
|
||
selectedIds={selectedIds}
|
||
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
|
||
onDeselectAll={() => setSelectedIds(new Set())}
|
||
userQuery={userQuery}
|
||
onUserQueryChange={setUserQuery}
|
||
onSubmitQuery={handleReSearch}
|
||
disabled={disabled || submitting}
|
||
/>
|
||
|
||
<div className="flex flex-wrap gap-2 pt-1">
|
||
<Button
|
||
type="button" size="sm" className="flex-1 min-w-[180px] h-8 text-xs gap-1.5"
|
||
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
|
||
>
|
||
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
|
||
</Button>
|
||
{!isSkillResult && (
|
||
<Button
|
||
type="button" variant="outline" size="sm"
|
||
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
|
||
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
|
||
>
|
||
<Sparkles className="size-3.5" />技能检索
|
||
</Button>
|
||
)}
|
||
<Button
|
||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||
onClick={handleReSearch} disabled={disabled || submitting}
|
||
>
|
||
{hasQuery
|
||
? <><Search className="size-3.5" />按输入重新搜索</>
|
||
: <><RotateCcw className="size-3.5" />重新搜索</>}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
|
||
|
||
interface OutlineConfirmCardProps {
|
||
outline: AIOutline | null;
|
||
onSubmit: (payload: UserInterventionPayload) => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
|
||
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
|
||
const [feedback, setFeedback] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
async function handleConfirm() {
|
||
setSubmitting(true);
|
||
await onSubmit({
|
||
action: 'confirm',
|
||
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
|
||
});
|
||
setSubmitting(false);
|
||
}
|
||
|
||
async function handleReplan() {
|
||
const text = feedback.trim();
|
||
if (!text) return;
|
||
setSubmitting(true);
|
||
// 带上卡片里的当前大纲,让 AI 在此基础上按要求增删/调整,而非从零重生成
|
||
await onSubmit({
|
||
action: 're_outline',
|
||
outlineFeedback: text,
|
||
editedOutline: editedOutline ?? undefined,
|
||
});
|
||
setSubmitting(false);
|
||
}
|
||
|
||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleReplan();
|
||
}
|
||
}
|
||
|
||
// 自动调整高度
|
||
function handleInput(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||
setFeedback(e.target.value);
|
||
const el = e.target;
|
||
el.style.height = 'auto';
|
||
el.style.height = `${el.scrollHeight}px`;
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="text-xs font-medium">请确认大纲后继续写作</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
可直接点击章节标题或要点进行编辑,或在下方输入意见让 AI 重新规划。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{editedOutline
|
||
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
|
||
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
|
||
|
||
{/* 重新规划输入框 */}
|
||
<div className="flex items-end gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 focus-within:ring-1 focus-within:ring-ring">
|
||
<textarea
|
||
ref={textareaRef}
|
||
rows={1}
|
||
value={feedback}
|
||
onChange={handleInput}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="输入修改意见后按 Enter 重新规划大纲,Shift+Enter 换行…"
|
||
disabled={disabled || submitting}
|
||
className="flex-1 resize-none overflow-hidden bg-transparent text-xs placeholder:text-muted-foreground focus:outline-none disabled:opacity-50 leading-5 py-0.5 min-h-[20px] max-h-32"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleReplan}
|
||
disabled={disabled || submitting || !feedback.trim()}
|
||
className="shrink-0 mb-0.5 p-1 rounded text-muted-foreground hover:text-primary hover:bg-primary/8 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||
title="发送(Enter)"
|
||
>
|
||
{submitting
|
||
? <RefreshCw className="size-3.5 animate-spin" />
|
||
: <SendHorizonal className="size-3.5" />}
|
||
</button>
|
||
</div>
|
||
|
||
<Button
|
||
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
|
||
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
|
||
>
|
||
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── PAUSE-3 草稿确认 ──────────────────────────────────────────────────────────
|
||
|
||
interface DraftConfirmCardProps {
|
||
draftTitle: string;
|
||
revisionCount: number;
|
||
reviewResult: ReviewResult | null;
|
||
onSubmit: (payload: UserInterventionPayload) => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
|
||
const [notes, setNotes] = useState('');
|
||
const [showNotes, setShowNotes] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
|
||
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
|
||
);
|
||
|
||
function toggleIssue(index: number) {
|
||
setSelectedIssueIndices((prev) => {
|
||
const next = new Set(prev);
|
||
next.has(index) ? next.delete(index) : next.add(index);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
async function submit(action: string, extra?: Partial<UserInterventionPayload>) {
|
||
setSubmitting(true);
|
||
await onSubmit({ action, userRevisionNotes: notes.trim(), ...extra });
|
||
setSubmitting(false);
|
||
}
|
||
|
||
async function handleApplyReview() {
|
||
if (!reviewResult) return;
|
||
const formattedNotes = formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices);
|
||
setSubmitting(true);
|
||
await onSubmit({
|
||
action: 'user_revise',
|
||
userRevisionNotes: formattedNotes,
|
||
selectedIssueIndices: [...selectedIssueIndices],
|
||
});
|
||
setSubmitting(false);
|
||
}
|
||
|
||
const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0;
|
||
|
||
return (
|
||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="text-xs font-medium">草稿已完成,请确认后继续</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
{draftTitle ? `《${draftTitle}》` : '草稿'}
|
||
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
|
||
· 右侧可预览完整内容
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 编辑审核结果(若已审核,支持勾选意见) */}
|
||
{reviewResult && (
|
||
<ReviewCard
|
||
review={reviewResult}
|
||
revisionCount={revisionCount}
|
||
selectedIndices={hasReview ? selectedIssueIndices : undefined}
|
||
onToggleIssue={hasReview ? toggleIssue : undefined}
|
||
/>
|
||
)}
|
||
|
||
{/* 用户自定义修改意见 */}
|
||
{showNotes && (
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs text-muted-foreground">您的修改意见</label>
|
||
<textarea
|
||
rows={2} value={notes} onChange={(e) => setNotes(e.target.value)}
|
||
placeholder="描述希望修改的内容,例如:第二段数据来源不清晰,结尾过于仓促…"
|
||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-1.5 pt-1">
|
||
<Button
|
||
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
|
||
onClick={() => submit('finalize')} disabled={disabled || submitting}
|
||
>
|
||
<CheckCircle2 className="size-3.5" />确认定稿
|
||
</Button>
|
||
|
||
{/* 按编辑审核意见修改(有审核结果时始终显示) */}
|
||
{hasReview && (
|
||
<Button
|
||
type="button" variant="outline" size="sm"
|
||
className="w-full h-8 text-xs gap-1.5 border-orange-200 text-orange-700 hover:bg-orange-50 dark:border-orange-800 dark:text-orange-400 dark:hover:bg-orange-950/30"
|
||
onClick={handleApplyReview}
|
||
disabled={disabled || submitting || selectedIssueIndices.size === 0}
|
||
>
|
||
<ClipboardEdit className="size-3.5" />
|
||
按编辑审核意见修改
|
||
{selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
|
||
<span className="text-[10px] opacity-70">
|
||
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
|
||
</span>
|
||
)}
|
||
</Button>
|
||
)}
|
||
|
||
<div className="flex gap-1.5">
|
||
{showNotes ? (
|
||
<Button
|
||
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||
onClick={() => submit('user_revise')} disabled={disabled || submitting || !notes.trim()}
|
||
>
|
||
<PenLine className="size-3.5" />按我的意见修改
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||
onClick={() => setShowNotes(true)} disabled={disabled || submitting}
|
||
>
|
||
<PenLine className="size-3.5" />给出修改意见…
|
||
</Button>
|
||
)}
|
||
|
||
<Button
|
||
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||
onClick={() => submit('to_editor')} disabled={disabled || submitting}
|
||
>
|
||
<ArrowRight className="size-3.5" />让编辑继续审核
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── PAUSE-4 编辑打回确认 ──────────────────────────────────────────────────────
|
||
|
||
interface ReviewConfirmCardProps {
|
||
reviewResult: ReviewResult | null;
|
||
revisionCount: number;
|
||
onSubmit: (payload: UserInterventionPayload) => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }: ReviewConfirmCardProps) {
|
||
const [extraNotes, setExtraNotes] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
|
||
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
|
||
);
|
||
|
||
function toggleIssue(index: number) {
|
||
setSelectedIssueIndices((prev) => {
|
||
const next = new Set(prev);
|
||
next.has(index) ? next.delete(index) : next.add(index);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
async function handleAccept() {
|
||
setSubmitting(true);
|
||
const issueNotes = reviewResult
|
||
? formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices)
|
||
: '';
|
||
const combined = [issueNotes, extraNotes.trim()].filter(Boolean).join('\n\n');
|
||
await onSubmit({
|
||
action: 'accept_review',
|
||
userRevisionNotes: combined || undefined,
|
||
selectedIssueIndices: [...selectedIssueIndices],
|
||
});
|
||
setSubmitting(false);
|
||
}
|
||
|
||
async function handleForce() {
|
||
setSubmitting(true);
|
||
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
|
||
setSubmitting(false);
|
||
}
|
||
|
||
const hasIssues = (reviewResult?.revisionNotes.length ?? 0) > 0;
|
||
|
||
return (
|
||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="text-xs font-medium">编辑认为文章需要修改</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
勾选您希望采纳的意见,再让作家重写;或忽略编辑意见强制定稿。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{reviewResult ? (
|
||
<ReviewCard
|
||
review={reviewResult}
|
||
revisionCount={revisionCount}
|
||
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
|
||
onToggleIssue={hasIssues ? toggleIssue : undefined}
|
||
/>
|
||
) : (
|
||
<p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>
|
||
)}
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs text-muted-foreground">补充修改指令(可选)</label>
|
||
<textarea
|
||
rows={2} value={extraNotes} onChange={(e) => setExtraNotes(e.target.value)}
|
||
placeholder="留空则只采纳已勾选意见;或在此补充您自己的修改方向…"
|
||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-2 pt-1">
|
||
<Button
|
||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||
onClick={handleAccept}
|
||
disabled={disabled || submitting || (hasIssues && selectedIssueIndices.size === 0 && !extraNotes.trim())}
|
||
>
|
||
<ClipboardEdit className="size-3.5" />按编辑意见修改
|
||
{hasIssues && selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
|
||
<span className="text-[10px] opacity-70">
|
||
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
|
||
</span>
|
||
)}
|
||
</Button>
|
||
<Button
|
||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0 text-destructive hover:text-destructive"
|
||
onClick={handleForce} disabled={disabled || submitting}
|
||
>
|
||
<ShieldAlert className="size-3.5" />强制定稿
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
|
||
|
||
interface SectionHelpCardProps {
|
||
blockedSections: BlockedSection[];
|
||
onSubmit: (payload: UserInterventionPayload) => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
const DECISION_OPTIONS: { value: SectionDecisionKind; label: string; icon: typeof Search }[] = [
|
||
{ value: 'supplement', label: '补充检索素材', icon: Search },
|
||
{ value: 'loose', label: '按通用知识续写', icon: BookOpen },
|
||
{ value: 'delete', label: '删除该章节', icon: Trash2 },
|
||
];
|
||
|
||
function SectionHelpCard({ blockedSections, onSubmit, disabled }: SectionHelpCardProps) {
|
||
const [decisions, setDecisions] = useState<Record<string, SectionDecisionKind>>(
|
||
() => Object.fromEntries(blockedSections.map((b) => [b.sectionTitle, 'supplement'])),
|
||
);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const hasSupplement = Object.values(decisions).some((d) => d === 'supplement');
|
||
|
||
async function handleSubmit() {
|
||
setSubmitting(true);
|
||
await onSubmit({
|
||
action: 'section_help',
|
||
sectionDecisions: blockedSections.map((b) => ({
|
||
sectionTitle: b.sectionTitle,
|
||
decision: decisions[b.sectionTitle] ?? 'supplement',
|
||
})),
|
||
});
|
||
setSubmitting(false);
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="text-xs font-medium">部分章节素材不足,需要你决定</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
严格依据素材模式下,以下 {blockedSections.length} 个章节缺少足够素材支撑写作。请为每个章节选择处理方式。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{blockedSections.map((b) => (
|
||
<div key={b.sectionTitle} className="rounded-md border bg-background p-2.5 space-y-2">
|
||
<div>
|
||
<p className="text-xs font-medium">{b.sectionTitle}</p>
|
||
<p className="text-[11px] text-muted-foreground mt-0.5">{b.reason}</p>
|
||
</div>
|
||
<div className="flex gap-1">
|
||
{DECISION_OPTIONS.map((opt) => {
|
||
const active = (decisions[b.sectionTitle] ?? 'supplement') === opt.value;
|
||
const Icon = opt.icon;
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
disabled={disabled || submitting}
|
||
onClick={() => setDecisions((p) => ({ ...p, [b.sectionTitle]: opt.value }))}
|
||
className={cn(
|
||
'flex-1 flex items-center justify-center gap-1 rounded px-1.5 py-1 text-[11px] border transition-colors',
|
||
active
|
||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||
: 'border-input text-muted-foreground hover:bg-muted/50',
|
||
)}
|
||
>
|
||
<Icon className="size-3 shrink-0" />
|
||
{opt.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{hasSupplement && (
|
||
<p className="text-[11px] text-amber-600 dark:text-amber-400">
|
||
含「补充检索素材」的章节将触发重新检索并重新规划大纲,本轮其它选择不生效。
|
||
</p>
|
||
)}
|
||
|
||
<Button
|
||
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
|
||
onClick={handleSubmit} disabled={disabled || submitting}
|
||
>
|
||
<CheckCircle2 className="size-3.5" />提交并继续
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|