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:
+21
-182
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user