diff --git a/web/src/App.tsx b/web/src/App.tsx index b4e19aa9..b2ae0c27 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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([]); + 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); diff --git a/web/src/hooks/useFreeformDocActions.test.ts b/web/src/hooks/useFreeformDocActions.test.ts new file mode 100644 index 00000000..0b21bd08 --- /dev/null +++ b/web/src/hooks/useFreeformDocActions.test.ts @@ -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); +}); diff --git a/web/src/hooks/useFreeformDocActions.ts b/web/src/hooks/useFreeformDocActions.ts new file mode 100644 index 00000000..6990caea --- /dev/null +++ b/web/src/hooks/useFreeformDocActions.ts @@ -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>; + setDocCache: Dispatch>>; + setActiveDocId: Dispatch>; + setActiveCategory: Dispatch>; + setMessages: Dispatch>; + setDeckDrawerOpen: Dispatch>; +} + +export interface FreeformDocActions { + handleShareToProject: (docId: string) => Promise; + handleRevokeShare: (docId: string) => Promise; + handleDeleteDoc: (docId: string) => Promise; + handleSaveMessageToPrivate: (message: ChatMessage) => Promise; +} + +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, + }; +}