Files
deerflow-code/frontend-web/src/open-canvas/components/ai-writing/OutlineCard.tsx
T
fengxiangdebaoziandClaude Opus 4.8 b8c78ba06e feat(ai-writing): 大纲先行确认 + 新文章类型识别 + 参考文献 + 编辑器修复
- 写作配置 Q&A 改为「先流式生成大纲并多轮确认 → 再确认意图」,放宽轮数上限
- 意图识别出列表外的新文章类型时带上类型名+简介(仅本次写作临时使用)
- 成稿支持参考文献:正文 [n] 角标 + 文末按出现顺序的「参考文献」列表(表单开关,默认开)
- 流式接取未闭合 <think> 思考内容,首 token 前加「正在整理…」占位
- 修复右侧编辑器输入空格只出一次、光标跳字(避免自激回灌 setContent 重置光标)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:07:03 +08:00

386 lines
14 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 { ListTree, Plus, GripVertical } from 'lucide-react';
import {
DndContext,
DragOverlay,
PointerSensor,
pointerWithin,
rectIntersection,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AIOutline, KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
import { normalizeKeyPoint } from '../../types/ai-writing';
import { OutlineSection } from './OutlineSection';
import { CollapseTransition } from './CollapseTransition';
interface OutlineCardProps {
outline: AIOutline;
onChange?: (updated: AIOutline) => void;
readOnly?: boolean;
}
// 节点路径(章节/小节/子要点)
export type DragPath =
| { type: 'section'; s: number }
| { type: 'keypoint'; s: number; k: number }
| { type: 'subpoint'; s: number; k: number; p: number };
// 节点 id:s{S} / s{S}-k{K} / s{S}-k{K}-p{P}
export function parseItemId(id: string): DragPath | null {
const m = id.match(/^s(\d+)(?:-k(\d+))?(?:-p(\d+))?$/);
if (!m) return null;
const s = parseInt(m[1], 10);
const k = m[2] !== undefined ? parseInt(m[2], 10) : undefined;
const p = m[3] !== undefined ? parseInt(m[3], 10) : undefined;
if (p !== undefined && k !== undefined) return { type: 'subpoint', s, k, p };
if (k !== undefined) return { type: 'keypoint', s, k };
return { type: 'section', s };
}
// 区域 id:before:<节点id> / after:<节点id> / into:<节点id>
type Zone = 'before' | 'after' | 'into';
function parseZoneId(zoneId: string): { zone: Zone; targetPath: DragPath; targetItemId: string } | null {
const m = zoneId.match(/^(before|after|into):(.+)$/);
if (!m) return null;
const targetItemId = m[2];
const targetPath = parseItemId(targetItemId);
if (!targetPath) return null;
return { zone: m[1] as Zone, targetPath, targetItemId };
}
function cloneOutline(outline: AIOutline): AIOutline {
return {
...outline,
sections: outline.sections.map((s) => ({
...s,
keyPoints: (s.keyPoints ?? []).map((kp) => {
const n = normalizeKeyPoint(kp);
return { ...n, subPoints: [...n.subPoints] } as KeyPoint;
}),
materialIds: [...(s.materialIds ?? [])],
})),
};
}
type SourceValue = OutlineSectionType | KeyPoint | string;
// 拖到自身子树内非法(章节扔进自己的小节/子要点 / 小节扔进自己的子要点)
function isDescendantTarget(from: DragPath, to: DragPath): boolean {
if (from.type === 'section') return to.s === from.s;
if (from.type === 'keypoint') return to.type !== 'section' && to.s === from.s && to.k === from.k;
return false;
}
function readAt(outline: AIOutline, p: DragPath): SourceValue {
if (p.type === 'section') return outline.sections[p.s];
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
if (p.type === 'keypoint') return kps[p.k];
return kps[p.k].subPoints[p.p];
}
function removeAt(outline: AIOutline, p: DragPath): void {
if (p.type === 'section') { outline.sections.splice(p.s, 1); return; }
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
if (p.type === 'keypoint') { kps.splice(p.k, 1); return; }
kps[p.k].subPoints.splice(p.p, 1);
}
function insertAt(outline: AIOutline, p: DragPath, value: SourceValue): void {
if (p.type === 'section') { outline.sections.splice(p.s, 0, value as OutlineSectionType); return; }
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
if (p.type === 'keypoint') { kps.splice(p.k, 0, value as KeyPoint); return; }
kps[p.k].subPoints.splice(p.p, 0, value as string);
}
// 删源后,同一父列表里 target index 会前移一位
function adjustTargetAfterRemove(to: DragPath, from: DragPath): DragPath {
if (from.type === 'section') {
if (to.s > from.s) return { ...to, s: to.s - 1 } as DragPath;
return to;
}
if (from.type === 'keypoint') {
if (to.type !== 'section' && to.s === from.s && to.k > from.k) {
return { ...to, k: to.k - 1 } as DragPath;
}
return to;
}
if (to.type === 'subpoint' && to.s === from.s && to.k === from.k && to.p > from.p) {
return { ...to, p: to.p - 1 };
}
return to;
}
type Level = DragPath['type'];
function convertToLevel(value: SourceValue, fromLevel: Level, toLevel: Level): SourceValue {
if (fromLevel === toLevel) return value;
if (toLevel === 'section') {
if (fromLevel === 'keypoint') {
const kp = value as KeyPoint;
return {
sectionTitle: kp.pointTitle,
keyPoints: kp.subPoints.map((sp) => ({ pointTitle: sp, subPoints: [] })),
materialIds: [],
};
}
return { sectionTitle: value as string, keyPoints: [], materialIds: [] };
}
if (toLevel === 'keypoint') {
if (fromLevel === 'section') {
const sec = value as OutlineSectionType;
const sub = (sec.keyPoints ?? [])
.map((kp) => normalizeKeyPoint(kp).pointTitle)
.filter((s) => !!s);
return { pointTitle: sec.sectionTitle, subPoints: sub };
}
return { pointTitle: value as string, subPoints: [] };
}
if (fromLevel === 'section') return (value as OutlineSectionType).sectionTitle;
return (value as KeyPoint).pointTitle;
}
// into 区域:作为 target 节点的「末尾子项」插入
function childPathOf(outline: AIOutline, target: DragPath): DragPath | null {
if (target.type === 'section') {
const kps = outline.sections[target.s]?.keyPoints ?? [];
return { type: 'keypoint', s: target.s, k: kps.length };
}
if (target.type === 'keypoint') {
const kp = normalizeKeyPoint((outline.sections[target.s]?.keyPoints ?? [])[target.k]);
return { type: 'subpoint', s: target.s, k: target.k, p: kp.subPoints.length };
}
return null; // 子要点没有子节点
}
// before/after:作为 target 节点的兄弟插入
function siblingPathOf(target: DragPath, side: 'before' | 'after'): DragPath {
const delta = side === 'after' ? 1 : 0;
if (target.type === 'section') return { type: 'section', s: target.s + delta };
if (target.type === 'keypoint') return { type: 'keypoint', s: target.s, k: target.k + delta };
return { type: 'subpoint', s: target.s, k: target.k, p: target.p + delta };
}
// 自定义碰撞:pointerWithin 命中所有包含光标的 droppable,取 rect 最小的(最内层)
// —— 这是嵌套区域 (section > keypoint > subpoint,每层又分 before/into/after) 能
// 正确选中「最深的那个」的关键。
function getRectArea(rect: { width: number; height: number } | null | undefined): number {
if (!rect) return Infinity;
return rect.width * rect.height;
}
const innermostCollision: CollisionDetection = (args) => {
const pointer = pointerWithin(args);
if (pointer.length === 0) return rectIntersection(args);
let best = pointer[0];
let bestArea = getRectArea(args.droppableRects.get(best.id));
for (const c of pointer.slice(1)) {
const a = getRectArea(args.droppableRects.get(c.id));
if (a < bestArea) { best = c; bestArea = a; }
}
return [best];
};
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
const [collapsed, setCollapsed] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(outline.title);
const [activeId, setActiveId] = useState<string | null>(null);
const editable = !readOnly && !!onChange;
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
function commitTitle() {
setEditingTitle(false);
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== outline.title) {
onChange?.({ ...outline, title: trimmed });
} else {
setTitleDraft(outline.title);
}
}
function updateSection(i: number, updated: OutlineSectionType) {
const sections = [...outline.sections];
sections[i] = updated;
onChange?.({ ...outline, sections });
}
function removeSection(i: number) {
onChange?.({ ...outline, sections: outline.sections.filter((_, idx) => idx !== i) });
}
function addSection() {
onChange?.({
...outline,
sections: [...outline.sections, { sectionTitle: '', keyPoints: [], materialIds: [] }],
});
}
function handleDragStart(event: DragStartEvent) {
setActiveId(String(event.active.id));
}
function handleDragCancel() {
setActiveId(null);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over || !editable) return;
const fromId = String(active.id);
const overId = String(over.id);
const from = parseItemId(fromId);
const zoneInfo = parseZoneId(overId);
if (!from || !zoneInfo) return;
const { zone, targetPath, targetItemId } = zoneInfo;
// 落在源自身的任何区域 → 无操作
if (targetItemId === fromId) return;
// 计算插入路径:into = 末尾子项;before/after = 同级兄弟
const insertPath = zone === 'into'
? childPathOf(outline, targetPath)
: siblingPathOf(targetPath, zone);
if (!insertPath) return;
// 拖入源自身子树 → 非法
if (isDescendantTarget(from, insertPath)) return;
const next = cloneOutline(outline);
const sourceValue = readAt(next, from);
removeAt(next, from);
const adjusted = adjustTargetAfterRemove(insertPath, from);
const converted = convertToLevel(sourceValue, from.type, adjusted.type);
insertAt(next, adjusted, converted);
onChange?.(next);
}
function renderDragOverlay() {
if (!activeId) return null;
const path = parseItemId(activeId);
if (!path) return null;
const section = outline.sections[path.s];
if (!section) return null;
if (path.type === 'section') {
return <OverlayCard level="section" label={section.sectionTitle} />;
}
const kp = normalizeKeyPoint((section.keyPoints ?? [])[path.k]);
if (path.type === 'keypoint') {
return <OverlayCard level="keypoint" label={kp.pointTitle} />;
}
return <OverlayCard level="subpoint" label={kp.subPoints?.[path.p] ?? ''} />;
}
return (
<div className="rounded-md border bg-muted/20 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b">
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium flex-1">文章大纲</span>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground"
>
{collapsed ? '展开' : '收起'}
</button>
</div>
<CollapseTransition open={!collapsed}>
<div className="px-3 py-2.5 space-y-2">
{/* 文章标题 */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground shrink-0">标题:</span>
{editingTitle && editable ? (
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') commitTitle();
if (e.key === 'Escape') { setTitleDraft(outline.title); setEditingTitle(false); }
}}
className="flex-1 bg-transparent border-b border-primary outline-none font-semibold"
/>
) : (
<span
className={editable ? 'flex-1 font-semibold cursor-text hover:text-primary' : 'flex-1 font-semibold'}
onClick={() => editable && setEditingTitle(true)}
>
{outline.title || <span className="text-muted-foreground italic font-normal">(未设置标题)</span>}
</span>
)}
</div>
{/* 章节列表 —— 三区域拖放:顶/底边重排,中间嵌入为子项 */}
<DndContext
sensors={sensors}
collisionDetection={innermostCollision}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="space-y-1.5">
{outline.sections.map((section, i) => (
<OutlineSection
key={i}
section={section}
index={i}
sectionIndex={i}
onChange={(updated) => updateSection(i, updated)}
onRemove={editable ? () => removeSection(i) : undefined}
readOnly={!editable}
/>
))}
</div>
<DragOverlay dropAnimation={null}>{renderDragOverlay()}</DragOverlay>
</DndContext>
{editable && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full h-7 text-xs gap-1.5 text-muted-foreground"
onClick={addSection}
>
<Plus className="size-3" />
添加章节
</Button>
)}
</div>
</CollapseTransition>
</div>
);
}
// 拖动时跟随光标的浮动预览。DragOverlay 把这个节点 portal 到 body,
// 不受任何祖先 overflow:hidden 影响,所以拖出卡片也始终可见。
function OverlayCard({ level, label }: { level: Level; label: string }) {
const styleByLevel: Record<Level, string> = {
section: 'rounded border-2 border-blue-400 bg-background text-xs font-medium px-2.5 py-2 shadow-lg',
keypoint: 'rounded border-2 border-blue-400 bg-muted/40 text-xs px-2 py-1.5 shadow-lg',
subpoint: 'rounded border-2 border-blue-400 bg-background text-xs px-2 py-1 shadow-lg',
};
return (
<div className={cn('flex items-center gap-1.5 cursor-grabbing min-w-[180px] max-w-[420px]', styleByLevel[level])}>
<GripVertical className="size-3 text-muted-foreground/50 shrink-0" />
{level === 'keypoint' && <span className="text-purple-500/70 shrink-0">###</span>}
{level === 'subpoint' && <span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>}
<span className="truncate">
{label || <span className="italic text-muted-foreground">(未命名)</span>}
</span>
</div>
);
}