refactor(web): share and save freeform docs from one hook

Keep private-library share, revoke, delete, and save-to-private off the workbench shell so those side effects stay testable without growing App.tsx.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 12:57:12 +00:00
co-authored by Cursor
parent 8de29466e1
commit 95f0e7bcf9
3 changed files with 263 additions and 182 deletions
+21 -182
View File
@@ -9,13 +9,7 @@ import { DocumentRevisionToolbar } from './components/doc/DocumentRevisionToolba
import { CommentSidePanel } from './components/doc/CommentSidePanel';
import { DocGovActionBar } from './components/doc/DocGovActionBar';
import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar';
import {
saveMessageToPrivate,
fetchFreeformDoc,
shareFreeformDoc,
unshareFreeformDoc,
deleteFreeformDoc,
} from './api/freeformDoc';
import { fetchFreeformDoc } from './api/freeformDoc';
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
import {
documentRevisionInactiveHint,
@@ -51,7 +45,6 @@ import {
import { DocumentLineage } from './types/projectWorkflow';
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
import { deriveReplyTitle } from './features/replyExport/title';
import { copyRichReplyToClipboard, copyMarkdownReply } from './features/replyExport/clipboard';
import { downloadMarkdownReply } from './features/replyExport/download';
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
@@ -119,6 +112,7 @@ import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspace
import { useBlockEditing } from './hooks/useBlockEditing';
import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration';
import { useDocumentLifecycleActions } from './hooks/useDocumentLifecycleActions';
import { useFreeformDocActions } from './hooks/useFreeformDocActions';
import { useCreateProjectConfirm } from './hooks/useCreateProjectConfirm';
import { useProjectMemberActions } from './hooks/useProjectMemberActions';
import { useCardActions } from './hooks/useCardActions';
@@ -895,180 +889,6 @@ export default function App() {
}
};
// 一键将个人私密文档共享至项目协作参考库
const handleShareToProject = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await shareFreeformDoc(docId);
}
} catch (e) {
console.error('Share doc error:', e);
}
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
setDeliverables((prev) =>
prev.map((d) => (d.docId === docId ? { ...d, category: 'shared', space: 'project_shared', sharedAt: nowStr } : d))
);
setDocCache((prev) => {
if (!prev[docId]) return prev;
return {
...prev,
[docId]: { ...prev[docId], category: 'shared', space: 'project_shared', sharedAt: nowStr },
};
});
setActiveCategory('shared');
const targetDoc = deliverables.find((d) => d.docId === docId);
setMessages((prev) => [
...prev,
{
id: 'ast-share-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🌐 一键共享至项目参考库',
content: `已为您将私密推演文档《${targetDoc?.title || '文档'}》一键共享至【项目协作参考库】!项目全员均可作为方案参考进行查阅与批注。您作为创建者,可随时点击「取消共享」恢复为个人私密状态。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
};
// 取消共享 (恢复为个人私有草稿)
const handleRevokeShare = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await unshareFreeformDoc(docId);
}
} catch (e) {
console.error('Unshare doc error:', e);
}
setDeliverables((prev) =>
prev.map((d) => (d.docId === docId ? { ...d, category: 'private', space: 'my_private', sharedAt: undefined } : d))
);
setDocCache((prev) => {
if (!prev[docId]) return prev;
return {
...prev,
[docId]: { ...prev[docId], category: 'private', space: 'my_private', sharedAt: undefined },
};
});
setActiveCategory('private');
const targetDoc = deliverables.find((d) => d.docId === docId);
setMessages((prev) => [
...prev,
{
id: 'ast-revoke-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🔒 撤回共享至个人私密',
content: `已撤回共享!《${targetDoc?.title || '文档'}》已从项目协作库移除并恢复为您的【个人私密文档】,其他团队成员在界面与大盘中已不可见。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
};
// 删除私密文档
const handleDeleteDoc = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await deleteFreeformDoc(docId);
}
} catch (e) {
console.error('Delete doc error:', e);
}
setDeliverables((prev) => prev.filter((d) => d.docId !== docId));
if (activeDocId === docId) {
const remaining = deliverables.filter((d) => d.docId !== docId);
const fallback = remaining.find((d) => d.category === 'workflow') || remaining[0];
if (fallback) {
setActiveDocId(fallback.docId);
setActiveCategory(fallback.category);
}
}
};
// 单条智能体回复保存到“我的私密”文档
const handleSaveMessageToPrivate = async (message: ChatMessage) => {
try {
const serverId = message.serverId || 0;
let resData;
if (serverId > 0) {
try {
const resp = await saveMessageToPrivate(
serverId,
currentProjectId,
undefined,
message.contentSha256,
);
resData = resp.data;
} catch (serverErr: any) {
console.warn('服务端自由文档落库失败,降级为本地草稿保存:', serverErr);
}
}
const docId = resData?.document.doc_id || `doc-free-${Date.now()}`;
const title = resData?.document.title || deriveReplyTitle(message.content, message.timestamp);
const markdown = resData?.content_markdown || message.content;
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
const newDocItem: DeliverableItem = {
docId,
projectId: currentProjectId,
taskKey: 'freeform_private',
title,
stage: 'V0.1',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
updatedAt: nowStr,
summary: message.content.slice(0, 120),
governanceMode: 'freeform',
space: 'my_private',
};
const newAST = markdownToBlockAST(markdown, {
docId,
title,
taskKey: 'freeform_private',
version: `${resData?.document.current_version_id || 1}`,
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
setDeliverables((prev) => {
const exists = prev.some((d) => d.docId === docId);
if (exists) return prev;
return [newDocItem, ...prev];
});
setDocCache((prev) => ({
...prev,
[docId]: newAST,
}));
setActiveDocId(docId);
setActiveCategory('private');
setDeckDrawerOpen(false);
} catch (err: any) {
console.error('保存到我的私密失败:', err);
throw err;
}
};
useServerDocumentSync({
session,
sessionValidated,
@@ -1098,6 +918,25 @@ export default function App() {
// 消息流:权威来源为服务端窗口;不再全量持久化到 localStorage
const [messages, setMessages] = useState<ChatMessage[]>([]);
const {
handleShareToProject,
handleRevokeShare,
handleDeleteDoc,
handleSaveMessageToPrivate,
} = useFreeformDocActions({
activeDocId,
activeSessionId,
currentProjectId,
currentUser,
deliverables,
setDeliverables,
setDocCache,
setActiveDocId,
setActiveCategory,
setMessages,
setDeckDrawerOpen,
});
// 基于当前激活的项目会话严格隔离对话记录
const currentSessionMessages = useMemo(() => {
return messages.filter((m) => m.sessionId === activeSessionId);
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
test('freeform document actions live in one hook', () => {
const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
const hook = readFileSync(new URL('./useFreeformDocActions.ts', import.meta.url), 'utf8');
assert.equal((app.match(/useFreeformDocActions\(/g) || []).length, 1);
assert.equal(app.includes('shareFreeformDoc('), false);
assert.equal(app.includes('saveMessageToPrivate('), false);
assert.equal(hook.includes('shareFreeformDoc'), true);
assert.equal(hook.includes('unshareFreeformDoc'), true);
assert.equal(hook.includes('deleteFreeformDoc'), true);
assert.equal(hook.includes('saveMessageToPrivate'), true);
assert.equal(hook.includes("category: 'private'"), true);
});
+226
View File
@@ -0,0 +1,226 @@
import type { Dispatch, SetStateAction } from 'react';
import {
deleteFreeformDoc,
saveMessageToPrivate,
shareFreeformDoc,
unshareFreeformDoc,
} from '../api/freeformDoc';
import { markdownToBlockAST } from '../engine/markdownToBlock';
import { deriveReplyTitle } from '../features/replyExport/title';
import type { DocumentAST } from '../types/block';
import type { UserProfile } from '../types/docgov';
import type { ChatMessage, DeliverableCategory, DeliverableItem } from '../types/workbench';
export interface FreeformDocActionsInput {
activeDocId: string;
activeSessionId: string;
currentProjectId: number;
currentUser: UserProfile;
deliverables: DeliverableItem[];
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
setActiveDocId: Dispatch<SetStateAction<string>>;
setActiveCategory: Dispatch<SetStateAction<DeliverableCategory>>;
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
setDeckDrawerOpen: Dispatch<SetStateAction<boolean>>;
}
export interface FreeformDocActions {
handleShareToProject: (docId: string) => Promise<void>;
handleRevokeShare: (docId: string) => Promise<void>;
handleDeleteDoc: (docId: string) => Promise<void>;
handleSaveMessageToPrivate: (message: ChatMessage) => Promise<void>;
}
export function useFreeformDocActions(input: FreeformDocActionsInput): FreeformDocActions {
const {
activeDocId,
activeSessionId,
currentProjectId,
currentUser,
deliverables,
setDeliverables,
setDocCache,
setActiveDocId,
setActiveCategory,
setMessages,
setDeckDrawerOpen,
} = input;
const handleShareToProject = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await shareFreeformDoc(docId);
}
} catch (e) {
console.error('Share doc error:', e);
}
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
setDeliverables((prev) =>
prev.map((d) => (d.docId === docId ? { ...d, category: 'shared', space: 'project_shared', sharedAt: nowStr } : d)),
);
setDocCache((prev) => {
if (!prev[docId]) return prev;
return {
...prev,
[docId]: { ...prev[docId], category: 'shared', space: 'project_shared', sharedAt: nowStr },
};
});
setActiveCategory('shared');
const targetDoc = deliverables.find((d) => d.docId === docId);
setMessages((prev) => [
...prev,
{
id: 'ast-share-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🌐 一键共享至项目参考库',
content: `已为您将私密推演文档《${targetDoc?.title || '文档'}》一键共享至【项目协作参考库】!项目全员均可作为方案参考进行查阅与批注。您作为创建者,可随时点击「取消共享」恢复为个人私密状态。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
};
const handleRevokeShare = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await unshareFreeformDoc(docId);
}
} catch (e) {
console.error('Unshare doc error:', e);
}
setDeliverables((prev) =>
prev.map((d) => (d.docId === docId ? { ...d, category: 'private', space: 'my_private', sharedAt: undefined } : d)),
);
setDocCache((prev) => {
if (!prev[docId]) return prev;
return {
...prev,
[docId]: { ...prev[docId], category: 'private', space: 'my_private', sharedAt: undefined },
};
});
setActiveCategory('private');
const targetDoc = deliverables.find((d) => d.docId === docId);
setMessages((prev) => [
...prev,
{
id: 'ast-revoke-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🔒 撤回共享至个人私密',
content: `已撤回共享!《${targetDoc?.title || '文档'}》已从项目协作库移除并恢复为您的【个人私密文档】,其他团队成员在界面与大盘中已不可见。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
};
const handleDeleteDoc = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await deleteFreeformDoc(docId);
}
} catch (e) {
console.error('Delete doc error:', e);
}
setDeliverables((prev) => prev.filter((d) => d.docId !== docId));
if (activeDocId === docId) {
const remaining = deliverables.filter((d) => d.docId !== docId);
const fallback = remaining.find((d) => d.category === 'workflow') || remaining[0];
if (fallback) {
setActiveDocId(fallback.docId);
setActiveCategory(fallback.category);
}
}
};
const handleSaveMessageToPrivate = async (message: ChatMessage) => {
try {
const serverId = message.serverId || 0;
let resData;
if (serverId > 0) {
try {
const resp = await saveMessageToPrivate(
serverId,
currentProjectId,
undefined,
message.contentSha256,
);
resData = resp.data;
} catch (serverErr: unknown) {
console.warn('服务端自由文档落库失败,降级为本地草稿保存:', serverErr);
}
}
const docId = resData?.document.doc_id || `doc-free-${Date.now()}`;
const title = resData?.document.title || deriveReplyTitle(message.content, message.timestamp);
const markdown = resData?.content_markdown || message.content;
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
const newDocItem: DeliverableItem = {
docId,
projectId: currentProjectId,
taskKey: 'freeform_private',
title,
stage: 'V0.1',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
updatedAt: nowStr,
summary: message.content.slice(0, 120),
governanceMode: 'freeform',
space: 'my_private',
};
const newAST = markdownToBlockAST(markdown, {
docId,
title,
taskKey: 'freeform_private',
version: `${resData?.document.current_version_id || 1}`,
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
setDeliverables((prev) => {
const exists = prev.some((d) => d.docId === docId);
if (exists) return prev;
return [newDocItem, ...prev];
});
setDocCache((prev) => ({
...prev,
[docId]: newAST,
}));
setActiveDocId(docId);
setActiveCategory('private');
setDeckDrawerOpen(false);
} catch (err) {
console.error('保存到我的私密失败:', err);
throw err;
}
};
return {
handleShareToProject,
handleRevokeShare,
handleDeleteDoc,
handleSaveMessageToPrivate,
};
}