feat(ai写作): 历史回看右侧成稿可编辑并保存回历史记录
- 后端新增 PUT /api/ai-writing/sessions/{id}/draft(属主校验,draft_title 可选)
- 前端历史模式放开只读,工具栏新增「保存」按钮持久化修改
- 实时写作完成态保持原样(仅导出,不落库)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
304e1b637a
commit
42f55251f2
7 files changed
+126
-6
No files matched your search
@@ -82,6 +82,27 @@ export async function renameAIWritingSession(id: string, title: string): Promise
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新历史会话的成稿正文(历史回看时用户在右侧编辑器里直接修改并保存)。
|
||||
* draftTitle 省略时后端不动原标题。
|
||||
*/
|
||||
export async function updateAIWritingDraft(
|
||||
id: string,
|
||||
draftMarkdown: string,
|
||||
draftTitle?: string,
|
||||
): Promise<AIWritingSessionSummary> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}/draft`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
draft_markdown: draftMarkdown,
|
||||
...(draftTitle === undefined ? {} : { draft_title: draftTitle }),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`保存修改失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function deleteAIWritingSession(id: string): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
|
||||
|
||||
@@ -102,7 +102,8 @@ export function AIWritingDraftPanel() {
|
||||
<TextArtifactRenderer
|
||||
content={textContent}
|
||||
isStreaming={!showCompletedDraftView}
|
||||
readOnly={isHistoryMode}
|
||||
readOnly={false}
|
||||
onSaveDraft={isHistoryMode ? history?.saveHistoryDraft : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,8 @@ interface TextArtifactRendererProps {
|
||||
isStreaming?: boolean;
|
||||
/** 完成态只读(如历史回看):保留目录 + 排版,禁用编辑与 AI 润色 */
|
||||
readOnly?: boolean;
|
||||
/** 历史回看可编辑场景:提供后工具栏出现「保存」按钮,把修改持久化回会话 */
|
||||
onSaveDraft?: (markdown: string, title?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface PanelPos {
|
||||
@@ -40,7 +42,7 @@ interface LastAction {
|
||||
customInstruction?: string;
|
||||
}
|
||||
|
||||
export function TextArtifactRenderer({ content, isStreaming, readOnly = false }: TextArtifactRendererProps) {
|
||||
export function TextArtifactRenderer({ content, isStreaming, readOnly = false, onSaveDraft }: TextArtifactRendererProps) {
|
||||
const { artifact, setArtifact } = useCanvasGraphContext();
|
||||
const { writingSelection } = useCanvasUIContext();
|
||||
|
||||
@@ -280,6 +282,7 @@ export function TextArtifactRenderer({ content, isStreaming, readOnly = false }:
|
||||
markdown={content.fullMarkdown}
|
||||
title={content.title}
|
||||
readOnly={readOnly}
|
||||
onSave={onSaveDraft}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Pilcrow,
|
||||
Download,
|
||||
Loader2,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -71,14 +72,32 @@ interface WritingToolbarProps {
|
||||
title?: string;
|
||||
/** 历史回看等场景:仅保留导出,隐藏格式工具 */
|
||||
readOnly?: boolean;
|
||||
/** 提供后显示「保存」按钮,把当前正文持久化(历史回看可编辑场景) */
|
||||
onSave?: (markdown: string, title?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function WritingToolbar({ editor, markdown, title, readOnly = false }: WritingToolbarProps) {
|
||||
export function WritingToolbar({ editor, markdown, title, readOnly = false, onSave }: WritingToolbarProps) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const canExport = !exporting && !!markdown?.trim();
|
||||
const canSave = !!onSave && !saving && !!markdown?.trim();
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave || saving || !markdown?.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(markdown, title);
|
||||
toast.success("已保存修改");
|
||||
} catch (err) {
|
||||
console.error("[writing-save] failed", err);
|
||||
toast.error("保存失败,请稍后重试");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!canExport) return;
|
||||
@@ -240,8 +259,40 @@ export function WritingToolbar({ editor, markdown, title, readOnly = false }: Wr
|
||||
<Strikethrough className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
{/* Right side: export + model selector */}
|
||||
{/* Right side: save + export + model selector */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{onSave && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSave();
|
||||
}}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center justify-center gap-1 rounded-md px-2 text-xs font-medium transition-colors",
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
"disabled:pointer-events-none disabled:opacity-40",
|
||||
)}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
保存修改到历史记录
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="mx-0.5 h-4" />
|
||||
</>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
import { getAIWritingSession } from '../api/ai-writing-sessions';
|
||||
import { getAIWritingSession, updateAIWritingDraft } from '../api/ai-writing-sessions';
|
||||
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
|
||||
import { useCanvasGraphContext } from './CanvasGraphContext';
|
||||
import { useCanvasUIContext } from './CanvasUIContext';
|
||||
@@ -10,6 +10,8 @@ type AIWritingHistoryContextValue = {
|
||||
isHistoryMode: boolean;
|
||||
openHistory: (session: AIWritingSessionSummary) => Promise<void>;
|
||||
closeHistory: () => void;
|
||||
/** 历史回看时把用户修改后的正文保存回该会话(持久化到后端)。 */
|
||||
saveHistoryDraft: (markdown: string, title?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const AIWritingHistoryContext = createContext<AIWritingHistoryContextValue | null>(null);
|
||||
@@ -52,14 +54,25 @@ export function AIWritingHistoryProvider({ children }: { children: ReactNode })
|
||||
setIsDraftEditable(true);
|
||||
}, [setArtifact, setIsDraftEditable]);
|
||||
|
||||
const saveHistoryDraft = useCallback(
|
||||
async (markdown: string, title?: string) => {
|
||||
if (!historySession) return;
|
||||
const updated = await updateAIWritingDraft(historySession.id, markdown, title);
|
||||
// 回写本地会话,保证后续重开/重命名读到最新成稿
|
||||
setHistorySession((prev) => (prev && prev.id === updated.id ? updated : prev));
|
||||
},
|
||||
[historySession],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
historySession,
|
||||
isHistoryMode: historySession !== null,
|
||||
openHistory,
|
||||
closeHistory,
|
||||
saveHistoryDraft,
|
||||
}),
|
||||
[historySession, openHistory, closeHistory],
|
||||
[historySession, openHistory, closeHistory, saveHistoryDraft],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user