feat(web): integrate interactive cards into chat stream and wire docgov actions
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
656a19cbe2
commit
d4ddd61443
+277
-3
@@ -15,11 +15,28 @@ import { markdownToBlockAST } from './engine/markdownToBlock';
|
||||
import { blockASTToMarkdown } from './engine/blockToMarkdown';
|
||||
import { recomputeNumbering } from './engine/autoNumber';
|
||||
import { DocumentAST, DocumentBlock, BlockComment } from './types/block';
|
||||
import { ProjectSession, ChatMessage, DeliverableItem, DeliverableCategory } from './types/workbench';
|
||||
import {
|
||||
ProjectSession,
|
||||
ChatMessage,
|
||||
DeliverableItem,
|
||||
DeliverableCategory,
|
||||
InteractiveCardData,
|
||||
} from './types/workbench';
|
||||
import { UserRole, UserProfile, DocPermissionRole } from './types/docgov';
|
||||
import { MOCK_DOCUMENTS } from './data/mockDocuments';
|
||||
import { exportDocumentToDocx } from './engine/docxRenderer';
|
||||
import { exportDocumentToXlsx } from './engine/xlsxRenderer';
|
||||
import {
|
||||
findMatchingBlockForReview,
|
||||
createRejectConfirmationCard,
|
||||
isAffirmativeResponse,
|
||||
findLastActiveCard,
|
||||
decrementActiveCardsTtl,
|
||||
applyConfirmRejectToDoc,
|
||||
applyUndoRejectToDoc,
|
||||
resolveCardInMessages,
|
||||
cancelCardInMessages,
|
||||
} from './engine/cardActionEngine';
|
||||
import {
|
||||
loadSessions,
|
||||
saveSessions,
|
||||
@@ -402,6 +419,7 @@ export default function App() {
|
||||
// 模态弹窗与侧栏
|
||||
const [editingBlock, setEditingBlock] = useState<DocumentBlock | null>(null);
|
||||
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null);
|
||||
const [pendingProposal, setPendingProposal] = useState<{
|
||||
changes: string;
|
||||
impact: string;
|
||||
@@ -423,8 +441,199 @@ export default function App() {
|
||||
return messages.filter((m) => m.sessionId === activeSessionId);
|
||||
}, [messages, activeSessionId]);
|
||||
|
||||
// 处理交互卡片动作流转 (驳回门禁确认、撤回、取消与终审联动)
|
||||
const handleCardAction = (cardId: string, actionId: string, payload?: any) => {
|
||||
// 查找对应的卡片
|
||||
let targetCard: InteractiveCardData | undefined;
|
||||
for (const m of messages) {
|
||||
if (m.interactiveCard && m.interactiveCard.cardId === cardId) {
|
||||
targetCard = m.interactiveCard;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const targetDocId =
|
||||
payload?.targetDocId || payload?.docId || targetCard?.targetDocId || activeDocId;
|
||||
const reason =
|
||||
payload?.reason || '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。';
|
||||
|
||||
if (actionId === 'confirm_reject') {
|
||||
// 1. 调用既有文档驳回逻辑 (将目标文档生命周期由 reviewing 回退为 drafting)
|
||||
setDeliverables((prev) =>
|
||||
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'drafting' } : d))
|
||||
);
|
||||
|
||||
// 2. 扫描目标文档 AST,在匹配块上追加 type: 'issue' 批注
|
||||
let targetBlockId: string | null = null;
|
||||
let targetSectionName = '第3节';
|
||||
setDocCache((prev) => {
|
||||
let currentDoc = prev[targetDocId];
|
||||
if (!currentDoc && MOCK_DOCUMENTS[targetDocId]) {
|
||||
const meta = MOCK_DOCUMENTS[targetDocId];
|
||||
currentDoc = markdownToBlockAST(meta.markdown, {
|
||||
docId: meta.docId,
|
||||
title: meta.title,
|
||||
taskKey: meta.taskKey,
|
||||
version: meta.version,
|
||||
category: meta.category,
|
||||
creatorId: meta.creatorId,
|
||||
creatorName: meta.creatorName,
|
||||
sharedAt: meta.sharedAt,
|
||||
lifecycleState: meta.lifecycleState,
|
||||
isClaimed: meta.isClaimed,
|
||||
author: meta.author,
|
||||
reviewer: meta.reviewer,
|
||||
approver: meta.approver,
|
||||
});
|
||||
}
|
||||
if (!currentDoc) return prev;
|
||||
|
||||
const { updatedDoc, matchingBlock } = applyConfirmRejectToDoc(
|
||||
currentDoc,
|
||||
cardId,
|
||||
currentUser.name,
|
||||
reason,
|
||||
targetCard?.sectionTitle || payload?.targetSection
|
||||
);
|
||||
targetBlockId = matchingBlock.id;
|
||||
if (matchingBlock.numbering) {
|
||||
targetSectionName = `第${matchingBlock.numbering.replace('.', '')}节`;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[targetDocId]: updatedDoc,
|
||||
};
|
||||
});
|
||||
|
||||
// 3. 更新卡片状态为 resolved
|
||||
const sectionLabel =
|
||||
targetCard?.sectionTitle?.match(/第[0-9一二三四五六七八九]+节/)?.[0] || targetSectionName || '第3节';
|
||||
const resolutionSummary = `已正式退回编制阶段,批注已挂载至${sectionLabel}`;
|
||||
|
||||
setMessages((prev) => {
|
||||
const updatedMessages = resolveCardInMessages(
|
||||
prev,
|
||||
cardId,
|
||||
resolutionSummary,
|
||||
currentUser.name
|
||||
);
|
||||
return [
|
||||
...updatedMessages,
|
||||
{
|
||||
id: 'sys-rej-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'system',
|
||||
content: `⚠️ ${currentUser.name}(审核负责人)驳回了《${targetCard?.targetDocTitle || targetDocId}》,已退回编制阶段。驳回原因:${reason}`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// 4. 切换当前右侧激活文档为目标文档并高亮匹配块
|
||||
setActiveDocId(targetDocId);
|
||||
const targetDeliverable = deliverables.find((d) => d.docId === targetDocId);
|
||||
if (targetDeliverable) {
|
||||
setActiveCategory(targetDeliverable.category);
|
||||
}
|
||||
if (targetBlockId) {
|
||||
setHighlightedBlockId(targetBlockId);
|
||||
setTimeout(() => {
|
||||
setHighlightedBlockId(null);
|
||||
}, 2500);
|
||||
}
|
||||
} else if (actionId === 'cancel') {
|
||||
// 标记卡片为 cancelled 并追加说明
|
||||
setMessages((prev) => [
|
||||
...cancelCardInMessages(prev, cardId),
|
||||
{
|
||||
id: 'ast-cancel-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '🚫 取消操作',
|
||||
content: '已取消驳回操作,文档保持待审核状态。',
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
} else if (actionId === 'undo') {
|
||||
// 撤销驳回:恢复为待审核状态 (reviewing)
|
||||
setDeliverables((prev) =>
|
||||
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'reviewing' } : d))
|
||||
);
|
||||
|
||||
// 移除注入的批注并恢复 reviewing 状态
|
||||
setDocCache((prev) => {
|
||||
const currentDoc = prev[targetDocId];
|
||||
if (!currentDoc) return prev;
|
||||
const updatedDoc = applyUndoRejectToDoc(currentDoc, cardId);
|
||||
return {
|
||||
...prev,
|
||||
[targetDocId]: updatedDoc,
|
||||
};
|
||||
});
|
||||
|
||||
// 标记卡片为 cancelled 并追加说明
|
||||
setMessages((prev) => [
|
||||
...cancelCardInMessages(prev, cardId),
|
||||
{
|
||||
id: 'ast-undo-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '↩️ 撤回驳回',
|
||||
content: '已在合法窗口期撤回驳回,文档已恢复为待审核状态。',
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
} else if (actionId === 'confirm_final_approve') {
|
||||
// 终审批准
|
||||
setDeliverables((prev) =>
|
||||
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'approved' } : d))
|
||||
);
|
||||
setDocCache((prev) => {
|
||||
const currentDoc = prev[targetDocId];
|
||||
if (!currentDoc) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[targetDocId]: { ...currentDoc, lifecycleState: 'approved' as const },
|
||||
};
|
||||
});
|
||||
|
||||
setMessages((prev) => [
|
||||
...resolveCardInMessages(
|
||||
prev,
|
||||
cardId,
|
||||
'已终审批准生效',
|
||||
currentUser.name
|
||||
),
|
||||
{
|
||||
id: 'appr-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '🏆 终审批准生效',
|
||||
content: `🎉 祝贺!${currentUser.name}(批准人)已终审批准《${targetCard?.targetDocTitle || targetDocId}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
recommendedCases: [
|
||||
{
|
||||
docId: targetDocId,
|
||||
taskKey: targetCard?.targetDocId || 'research',
|
||||
artifactTitle: targetCard?.targetDocTitle || '受控交付物',
|
||||
sectionTitle: '终审已生效标杆案例',
|
||||
similarityScore: 1.0,
|
||||
keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'],
|
||||
summarySnippet: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// 发送消息与对话驱动双轨逻辑
|
||||
const handleSendMessage = (text: string, attachments?: { name: string; size: string; type: string }[]) => {
|
||||
const trimmed = text.trim();
|
||||
const userMsg: ChatMessage = {
|
||||
id: 'msg-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
@@ -434,7 +643,23 @@ export default function App() {
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
attachments,
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
|
||||
// 检查是否为肯定回复(双轨同效判定:打字回复等同于点击卡片默认动作)
|
||||
const isAffirmative = isAffirmativeResponse(trimmed);
|
||||
const lastActive = findLastActiveCard(messages, activeSessionId);
|
||||
|
||||
if (isAffirmative && lastActive) {
|
||||
const { card } = lastActive;
|
||||
const defaultAction = card.actions.find((a) => a.isAffirmativeDefault) || card.actions[0];
|
||||
if (defaultAction) {
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
handleCardAction(card.cardId, defaultAction.actionId, defaultAction.payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 递减消息列表中尚未完结的 active 卡片 ttlTurns,若 <= 0 则标记为 expired
|
||||
setMessages((prev) => [...decrementActiveCardsTtl(prev, activeSessionId), userMsg]);
|
||||
setIsThinking(true);
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -509,6 +734,50 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 用户发送驳回意图 (且存在处于 reviewing 的目标交付物)
|
||||
if (text.includes('驳回') || text.includes('退回')) {
|
||||
const targetReviewDoc =
|
||||
docAST.lifecycleState === 'reviewing'
|
||||
? docAST
|
||||
: deliverables.find((d) => d.lifecycleState === 'reviewing')
|
||||
? (docCache[deliverables.find((d) => d.lifecycleState === 'reviewing')!.docId] || docAST)
|
||||
: null;
|
||||
|
||||
if (targetReviewDoc && targetReviewDoc.lifecycleState === 'reviewing') {
|
||||
const matchingBlock = findMatchingBlockForReview(targetReviewDoc);
|
||||
const card = createRejectConfirmationCard(targetReviewDoc, matchingBlock);
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-reject-gate-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '🛡️ 审核驳回门禁',
|
||||
content: `检测到您对《${targetReviewDoc.title}》提出驳回审核意图。驳回将退回编制阶段并解除锁定,同时向编制人下达整改批注。请确认操作:`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
interactiveCard: card,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
} else {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '水木东方协同参谋',
|
||||
intentTag: '⚠️ 状态校验提示',
|
||||
content: `当前受控文档《${docAST.title}》处于【${docAST.lifecycleState}】阶段,非待审核状态,无需执行驳回。`,
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('修改') || text.includes('更新') || text.includes('调整')) {
|
||||
if (docAST.isClaimed && currentDocRole === 'author') {
|
||||
setPendingProposal({
|
||||
@@ -813,7 +1082,11 @@ export default function App() {
|
||||
onOpenSettingsModal={() => setSettingsModalOpen(true)}
|
||||
chatContent={
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<MessageStream messages={currentSessionMessages} onOpenCase={handleSelectDoc} />
|
||||
<MessageStream
|
||||
messages={currentSessionMessages}
|
||||
onOpenCase={handleSelectDoc}
|
||||
onCardAction={handleCardAction}
|
||||
/>
|
||||
{pendingProposal && (
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
<ProposalConfirmCard
|
||||
@@ -874,6 +1147,7 @@ export default function App() {
|
||||
`针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。`
|
||||
);
|
||||
}}
|
||||
highlightedBlockId={highlightedBlockId}
|
||||
/>
|
||||
|
||||
{/* 底部法定流转动作条 (根据受控/共享/私密形态自适应展示) */}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { InteractiveCardData, CardActionItem } from '../../types/workbench';
|
||||
import { InteractiveCardData, CardActionItem, ChatMessage } from '../../types/workbench';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { MessageStream } from './MessageStream';
|
||||
import {
|
||||
InteractiveCard,
|
||||
getCardTypeMeta,
|
||||
@@ -368,4 +370,84 @@ describe('InteractiveCard Logic', () => {
|
||||
expect(none.level).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageBubble and MessageStream Card Integration', () => {
|
||||
it('should render InteractiveCard inside MessageBubble when message has interactiveCard', () => {
|
||||
const cardData: InteractiveCardData = {
|
||||
cardId: 'card-bubble-test',
|
||||
cardType: 'CONFIRMATION',
|
||||
title: '审核驳回与返工确认',
|
||||
targetDocId: 'doc-002',
|
||||
targetDocTitle: '微创外科全球竞品深度调研',
|
||||
sectionTitle: '第3节「电气安全与EMC」',
|
||||
status: 'active',
|
||||
ttlTurns: 3,
|
||||
actions: [
|
||||
{ actionId: 'confirm_reject', label: '确认驳回', style: 'danger', isAffirmativeDefault: true },
|
||||
{ actionId: 'cancel', label: '放弃操作', style: 'subtle' },
|
||||
],
|
||||
};
|
||||
|
||||
const msg: ChatMessage = {
|
||||
id: 'msg-ast-1',
|
||||
sessionId: 'sess-001',
|
||||
role: 'assistant',
|
||||
content: '检测到您提出驳回意图,请确认是否执行:',
|
||||
timestamp: '18:30',
|
||||
interactiveCard: cardData,
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(MessageBubble, { message: msg })
|
||||
);
|
||||
|
||||
// 气泡内应包含文本、卡片标题、徽章与操作按钮
|
||||
expect(html).toContain('检测到您提出驳回意图');
|
||||
expect(html).toContain('审核驳回与返工确认');
|
||||
expect(html).toContain('微创外科全球竞品深度调研');
|
||||
expect(html).toContain('第3节「电气安全与EMC」');
|
||||
expect(html).toContain('确认驳回');
|
||||
expect(html).toContain('放弃操作');
|
||||
});
|
||||
|
||||
it('should render InteractiveCard in MessageStream with multiple messages', () => {
|
||||
const cardData: InteractiveCardData = {
|
||||
cardId: 'card-stream-test',
|
||||
cardType: 'CONFIRMATION',
|
||||
title: '流转确认门禁',
|
||||
status: 'active',
|
||||
ttlTurns: 2,
|
||||
actions: [
|
||||
{ actionId: 'confirm', label: '确认执行', style: 'primary', isAffirmativeDefault: true },
|
||||
],
|
||||
};
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'u1',
|
||||
sessionId: 'sess-001',
|
||||
role: 'user',
|
||||
content: '驳回这个文档',
|
||||
timestamp: '18:29',
|
||||
},
|
||||
{
|
||||
id: 'a1',
|
||||
sessionId: 'sess-001',
|
||||
role: 'assistant',
|
||||
content: '请确认是否驳回:',
|
||||
timestamp: '18:30',
|
||||
interactiveCard: cardData,
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(MessageStream, { messages })
|
||||
);
|
||||
|
||||
expect(html).toContain('驳回这个文档');
|
||||
expect(html).toContain('请确认是否驳回:');
|
||||
expect(html).toContain('流转确认门禁');
|
||||
expect(html).toContain('确认执行');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,20 @@ import React from 'react';
|
||||
import { ChatMessage } from '../../types/workbench';
|
||||
import { PhaseEventStrip } from './PhaseEventStrip';
|
||||
import { RecommendedCaseCard } from './RecommendedCaseCard';
|
||||
import { InteractiveCard } from './InteractiveCard';
|
||||
import { FileText, Bot, User } from 'lucide-react';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
export interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
onOpenCase?: (docId: string) => void;
|
||||
onCardAction?: (cardId: string, actionId: string, payload?: any) => void;
|
||||
}
|
||||
|
||||
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onOpenCase }) => {
|
||||
export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
message,
|
||||
onOpenCase,
|
||||
onCardAction,
|
||||
}) => {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
@@ -132,6 +138,16 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onOpenCas
|
||||
{message.recommendedCases && (
|
||||
<RecommendedCaseCard cases={message.recommendedCases} onOpenCase={onOpenCase} />
|
||||
)}
|
||||
|
||||
{/* 交互门禁卡片 */}
|
||||
{message.interactiveCard && (
|
||||
<div style={{ whiteSpace: 'normal', width: '100%' }}>
|
||||
<InteractiveCard
|
||||
card={message.interactiveCard}
|
||||
onExecuteAction={onCardAction}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,17 @@ import React, { useRef, useEffect } from 'react';
|
||||
import { ChatMessage } from '../../types/workbench';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
|
||||
interface MessageStreamProps {
|
||||
export interface MessageStreamProps {
|
||||
messages: ChatMessage[];
|
||||
onOpenCase?: (docId: string) => void;
|
||||
onCardAction?: (cardId: string, actionId: string, payload?: any) => void;
|
||||
}
|
||||
|
||||
export const MessageStream: React.FC<MessageStreamProps> = ({ messages, onOpenCase }) => {
|
||||
export const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
messages,
|
||||
onOpenCase,
|
||||
onCardAction,
|
||||
}) => {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,7 +46,12 @@ export const MessageStream: React.FC<MessageStreamProps> = ({ messages, onOpenCa
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} onOpenCase={onOpenCase} />
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onOpenCase={onOpenCase}
|
||||
onCardAction={onCardAction}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} style={{ height: '1px' }} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DocumentAST, DocumentBlock } from '../../types/block';
|
||||
import { UserRole } from '../../types/docgov';
|
||||
import { HeadingBlockView } from './blocks/HeadingBlockView';
|
||||
@@ -11,7 +11,7 @@ import { ListBlockView } from './blocks/ListBlockView';
|
||||
import { BlockContextMenu } from './BlockContextMenu';
|
||||
import { Plus, ArrowUp, ArrowDown, Trash2, MessageSquare } from 'lucide-react';
|
||||
|
||||
interface BlockRendererProps {
|
||||
export interface BlockRendererProps {
|
||||
ast: DocumentAST;
|
||||
currentRole: UserRole;
|
||||
onEditBlock: (block: DocumentBlock) => void;
|
||||
@@ -20,6 +20,7 @@ interface BlockRendererProps {
|
||||
onDeleteBlock: (index: number) => void;
|
||||
onOpenComments: (block: DocumentBlock) => void;
|
||||
onAskAgent?: (content: string) => void;
|
||||
highlightedBlockId?: string | null;
|
||||
}
|
||||
|
||||
export const BlockRenderer: React.FC<BlockRendererProps> = ({
|
||||
@@ -31,6 +32,7 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
|
||||
onDeleteBlock,
|
||||
onOpenComments,
|
||||
onAskAgent,
|
||||
highlightedBlockId,
|
||||
}) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
@@ -40,6 +42,15 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
|
||||
index: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedBlockId) {
|
||||
const el = document.getElementById(`block-${highlightedBlockId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [highlightedBlockId]);
|
||||
|
||||
const isEditable = currentRole === 'author' && ast.lifecycleState === 'drafting';
|
||||
const children = ast.rootBlock.children || [];
|
||||
|
||||
@@ -111,11 +122,13 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
|
||||
{/* 块流 */}
|
||||
{children.map((block, idx) => {
|
||||
const isHovered = hoveredIndex === idx;
|
||||
const isHighlighted = !!highlightedBlockId && highlightedBlockId === block.id;
|
||||
const commentCount = block.comments?.length || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={block.id || idx}
|
||||
id={block.id ? `block-${block.id}` : undefined}
|
||||
onMouseEnter={() => setHoveredIndex(idx)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onDoubleClick={() => {
|
||||
@@ -138,9 +151,18 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
|
||||
borderRadius: 'var(--r-sm)',
|
||||
padding: '3px 8px',
|
||||
margin: '3px -8px',
|
||||
border: isHovered && isEditable ? '1px dashed var(--brand-500)' : '1px solid transparent',
|
||||
background: isHovered && isEditable ? 'var(--bg-hover)' : 'transparent',
|
||||
transition: 'background 0.15s ease, border-color 0.15s ease',
|
||||
border: isHighlighted
|
||||
? '2px solid var(--danger)'
|
||||
: isHovered && isEditable
|
||||
? '1px dashed var(--brand-500)'
|
||||
: '1px solid transparent',
|
||||
background: isHighlighted
|
||||
? 'rgba(239, 68, 68, 0.08)'
|
||||
: isHovered && isEditable
|
||||
? 'var(--bg-hover)'
|
||||
: 'transparent',
|
||||
boxShadow: isHighlighted ? '0 0 10px rgba(239, 68, 68, 0.25)' : 'none',
|
||||
transition: 'background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease',
|
||||
cursor: isEditable ? 'text' : 'default',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
findMatchingBlockForReview,
|
||||
createRejectConfirmationCard,
|
||||
isAffirmativeResponse,
|
||||
findLastActiveCard,
|
||||
decrementActiveCardsTtl,
|
||||
applyConfirmRejectToDoc,
|
||||
applyUndoRejectToDoc,
|
||||
resolveCardInMessages,
|
||||
cancelCardInMessages,
|
||||
} from './cardActionEngine';
|
||||
import { DocumentAST } from '../types/block';
|
||||
import { ChatMessage } from '../types/workbench';
|
||||
|
||||
const sampleAST: DocumentAST = {
|
||||
docId: 'doc-002',
|
||||
title: '微创外科全球竞品深度调研',
|
||||
taskKey: 'research',
|
||||
version: 'V1.0',
|
||||
lifecycleState: 'reviewing',
|
||||
isClaimed: true,
|
||||
author: '张工',
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: '2026-09-08 11:30',
|
||||
rootBlock: {
|
||||
id: 'root',
|
||||
type: 'document',
|
||||
content: '',
|
||||
children: [
|
||||
{
|
||||
id: 'blk-h1',
|
||||
type: 'heading',
|
||||
level: 1,
|
||||
numbering: '1',
|
||||
content: '微创外科全球竞品深度调研',
|
||||
},
|
||||
{
|
||||
id: 'blk-comp',
|
||||
type: 'heading',
|
||||
level: 2,
|
||||
numbering: '1.1',
|
||||
content: '全球主要竞品横向性能对标与临床痛点',
|
||||
},
|
||||
{
|
||||
id: 'blk-emc',
|
||||
type: 'paragraph',
|
||||
content: '拟在第2节强化补充高频电刀共模抑制比设计指标,满足 GB 9706.1-2020 电气安全与 EMC 滤波要求。',
|
||||
},
|
||||
{
|
||||
id: 'blk-fto',
|
||||
type: 'heading',
|
||||
level: 2,
|
||||
numbering: '1.2',
|
||||
content: '自由实施 (FTO) 专利检索与回避流程',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
test('findMatchingBlockForReview: 能够精准按审查关键词(电气安全/EMC/竞品)定位段落块', () => {
|
||||
const matched = findMatchingBlockForReview(sampleAST);
|
||||
assert.ok(matched);
|
||||
// 应当优先匹配包含竞品/电气安全/EMC 的段落或标题
|
||||
assert.ok(
|
||||
matched.content.includes('竞品') ||
|
||||
matched.content.includes('电气安全') ||
|
||||
matched.content.includes('EMC')
|
||||
);
|
||||
});
|
||||
|
||||
test('findMatchingBlockForReview: 能够按外部指定 hint 线索精确命中目标块', () => {
|
||||
const matched = findMatchingBlockForReview(sampleAST, '自由实施');
|
||||
assert.ok(matched);
|
||||
assert.equal(matched.id, 'blk-fto');
|
||||
assert.ok(matched.content.includes('自由实施'));
|
||||
});
|
||||
|
||||
test('createRejectConfirmationCard: 生成合规危险级驳回门禁卡片', () => {
|
||||
const block = sampleAST.rootBlock.children![2];
|
||||
const card = createRejectConfirmationCard({ docId: 'doc-002', title: '微创外科调研' }, block);
|
||||
|
||||
assert.equal(card.cardType, 'CONFIRMATION');
|
||||
assert.equal(card.targetDocId, 'doc-002');
|
||||
assert.equal(card.status, 'active');
|
||||
assert.equal(card.ttlTurns, 3);
|
||||
assert.ok(card.actions.length >= 2);
|
||||
|
||||
const confirmAction = card.actions.find((a) => a.actionId === 'confirm_reject');
|
||||
assert.ok(confirmAction);
|
||||
assert.equal(confirmAction.style, 'danger');
|
||||
assert.equal(confirmAction.isAffirmativeDefault, true);
|
||||
assert.equal(confirmAction.payload?.docId, 'doc-002');
|
||||
});
|
||||
|
||||
test('isAffirmativeResponse: 准确识别双轨交互中的肯定意图输入', () => {
|
||||
assert.equal(isAffirmativeResponse('好'), true);
|
||||
assert.equal(isAffirmativeResponse('好的'), true);
|
||||
assert.equal(isAffirmativeResponse('确认'), true);
|
||||
assert.equal(isAffirmativeResponse('确认驳回'), true);
|
||||
assert.equal(isAffirmativeResponse('同意'), true);
|
||||
assert.equal(isAffirmativeResponse('同意驳回'), true);
|
||||
assert.equal(isAffirmativeResponse('批准'), true);
|
||||
assert.equal(isAffirmativeResponse('ok'), true);
|
||||
assert.equal(isAffirmativeResponse('YES'), true);
|
||||
|
||||
// 否定或无关意图
|
||||
assert.equal(isAffirmativeResponse('取消'), false);
|
||||
assert.equal(isAffirmativeResponse('不驳回'), false);
|
||||
assert.equal(isAffirmativeResponse('请帮我修改第二段'), false);
|
||||
});
|
||||
|
||||
test('findLastActiveCard: 正确在会话历史中提取最新的待决策卡片', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'm1',
|
||||
sessionId: 'sess-001',
|
||||
role: 'assistant',
|
||||
content: '旧消息已解决',
|
||||
timestamp: '10:00',
|
||||
interactiveCard: {
|
||||
cardId: 'c1',
|
||||
cardType: 'CONFIRMATION',
|
||||
title: '旧卡片',
|
||||
status: 'resolved',
|
||||
ttlTurns: 0,
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
sessionId: 'sess-001',
|
||||
role: 'assistant',
|
||||
content: '当前待确认卡片',
|
||||
timestamp: '10:05',
|
||||
interactiveCard: {
|
||||
cardId: 'c2',
|
||||
cardType: 'CONFIRMATION',
|
||||
title: '待驳回卡片',
|
||||
status: 'active',
|
||||
ttlTurns: 2,
|
||||
actions: [{ actionId: 'confirm_reject', label: '确认', style: 'danger', isAffirmativeDefault: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
sessionId: 'sess-002', // 属于其他会话
|
||||
role: 'assistant',
|
||||
content: '其他会话活跃卡片',
|
||||
timestamp: '10:06',
|
||||
interactiveCard: {
|
||||
cardId: 'c3',
|
||||
cardType: 'CONFIRMATION',
|
||||
title: '其他会话卡片',
|
||||
status: 'active',
|
||||
ttlTurns: 3,
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const found = findLastActiveCard(messages, 'sess-001');
|
||||
assert.ok(found);
|
||||
assert.equal(found.card.cardId, 'c2');
|
||||
assert.equal(found.card.status, 'active');
|
||||
});
|
||||
|
||||
test('decrementActiveCardsTtl: 多轮对话超时递减与自动失效归档', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'm1',
|
||||
sessionId: 'sess-001',
|
||||
role: 'assistant',
|
||||
content: '待确认',
|
||||
timestamp: '10:00',
|
||||
interactiveCard: {
|
||||
cardId: 'c1',
|
||||
cardType: 'CONFIRMATION',
|
||||
title: '剩余1轮卡片',
|
||||
status: 'active',
|
||||
ttlTurns: 1,
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
sessionId: 'sess-001',
|
||||
role: 'assistant',
|
||||
content: '待确认2',
|
||||
timestamp: '10:01',
|
||||
interactiveCard: {
|
||||
cardId: 'c2',
|
||||
cardType: 'CONFIRMATION',
|
||||
title: '剩余3轮卡片',
|
||||
status: 'active',
|
||||
ttlTurns: 3,
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const decremented = decrementActiveCardsTtl(messages, 'sess-001');
|
||||
// c1 原为 1,递减后为 0,应归档为 expired
|
||||
assert.equal(decremented[0].interactiveCard?.ttlTurns, 0);
|
||||
assert.equal(decremented[0].interactiveCard?.status, 'expired');
|
||||
|
||||
// c2 原为 3,递减后为 2,仍为 active
|
||||
assert.equal(decremented[1].interactiveCard?.ttlTurns, 2);
|
||||
assert.equal(decremented[1].interactiveCard?.status, 'active');
|
||||
});
|
||||
|
||||
test('applyConfirmRejectToDoc & applyUndoRejectToDoc: 驳回挂载批注与60s窗口期撤销完整闭环', () => {
|
||||
const cardId = 'card-test-999';
|
||||
const reviewer = '李主管';
|
||||
const reason = '需按 GB 9706.1-2020 补充电气安全绝缘耐压参数';
|
||||
|
||||
// 1. 确认驳回
|
||||
const { updatedDoc, matchingBlock } = applyConfirmRejectToDoc(
|
||||
sampleAST,
|
||||
cardId,
|
||||
reviewer,
|
||||
reason
|
||||
);
|
||||
|
||||
// 状态流转验证:回退为 drafting
|
||||
assert.equal(updatedDoc.lifecycleState, 'drafting');
|
||||
|
||||
// 批注挂载验证
|
||||
const targetBlock = updatedDoc.rootBlock.children?.find((b) => b.id === matchingBlock.id);
|
||||
assert.ok(targetBlock);
|
||||
assert.ok(targetBlock.comments && targetBlock.comments.length > 0);
|
||||
|
||||
const comment = targetBlock.comments.find((c) => c.id === `cmt-reject-${cardId}`);
|
||||
assert.ok(comment);
|
||||
assert.equal(comment.authorName, reviewer);
|
||||
assert.equal(comment.type, 'issue');
|
||||
assert.equal(comment.content, reason);
|
||||
assert.equal(comment.resolved, false);
|
||||
|
||||
// 2. 撤回驳回 (Undo)
|
||||
const undoneDoc = applyUndoRejectToDoc(updatedDoc, cardId);
|
||||
|
||||
// 状态流转验证:恢复为 reviewing
|
||||
assert.equal(undoneDoc.lifecycleState, 'reviewing');
|
||||
|
||||
// 批注清理验证:对应 comment 已安全移除
|
||||
const targetBlockAfterUndo = undoneDoc.rootBlock.children?.find((b) => b.id === matchingBlock.id);
|
||||
const remainingComment = targetBlockAfterUndo?.comments?.find((c) => c.id === `cmt-reject-${cardId}`);
|
||||
assert.equal(remainingComment, undefined);
|
||||
});
|
||||
|
||||
test('resolveCardInMessages & cancelCardInMessages: 消息流中卡片终态沉淀', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'm1',
|
||||
sessionId: 'sess-001',
|
||||
role: 'assistant',
|
||||
content: '卡片消息',
|
||||
timestamp: '10:00',
|
||||
interactiveCard: {
|
||||
cardId: 'c1',
|
||||
cardType: 'CONFIRMATION',
|
||||
title: '审核确认',
|
||||
status: 'active',
|
||||
ttlTurns: 3,
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 1. Resolve
|
||||
const resolved = resolveCardInMessages(
|
||||
messages,
|
||||
'c1',
|
||||
'已正式退回编制阶段,批注已挂载至第3节',
|
||||
'李主管',
|
||||
'2026-09-08T18:00:00.000Z'
|
||||
);
|
||||
assert.equal(resolved[0].interactiveCard?.status, 'resolved');
|
||||
assert.equal(resolved[0].interactiveCard?.resolutionSummary, '已正式退回编制阶段,批注已挂载至第3节');
|
||||
assert.equal(resolved[0].interactiveCard?.resolvedBy, '李主管');
|
||||
assert.equal(resolved[0].interactiveCard?.resolvedAt, '2026-09-08T18:00:00.000Z');
|
||||
|
||||
// 2. Cancel
|
||||
const cancelled = cancelCardInMessages(messages, 'c1');
|
||||
assert.equal(cancelled[0].interactiveCard?.status, 'cancelled');
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { DocumentAST, DocumentBlock, BlockComment } from '../types/block';
|
||||
import { ChatMessage, InteractiveCardData } from '../types/workbench';
|
||||
|
||||
/**
|
||||
* 扫描受控文档 AST,寻找与审核审查最匹配的锚定段落块 (如电气安全、EMC、竞品对比、临床痛点)
|
||||
*/
|
||||
export function findMatchingBlockForReview(ast: DocumentAST, hint?: string): DocumentBlock {
|
||||
const children = ast.rootBlock?.children || [];
|
||||
if (children.length === 0) {
|
||||
return ast.rootBlock;
|
||||
}
|
||||
|
||||
// 1. 若外部指定了段落 ID 或文本线索,优先精确/包含匹配
|
||||
if (hint && hint.trim()) {
|
||||
const trimmed = hint.trim().toLowerCase();
|
||||
const matched = children.find(
|
||||
(b) =>
|
||||
b.id === hint ||
|
||||
b.content.toLowerCase().includes(trimmed) ||
|
||||
(b.properties?.tableData &&
|
||||
JSON.stringify(b.properties.tableData).toLowerCase().includes(trimmed))
|
||||
);
|
||||
if (matched) return matched;
|
||||
}
|
||||
|
||||
// 2. 依次匹配关键合规与审查高频词
|
||||
const keywords = ['电气安全', 'emc', '电磁兼容', '竞品', '对标', '临床痛点', '性能矩阵'];
|
||||
for (const kw of keywords) {
|
||||
const matched = children.find(
|
||||
(b) =>
|
||||
b.content.toLowerCase().includes(kw) ||
|
||||
(b.properties?.tableData &&
|
||||
JSON.stringify(b.properties.tableData).toLowerCase().includes(kw))
|
||||
);
|
||||
if (matched) return matched;
|
||||
}
|
||||
|
||||
// 3. 降级:优先返回第 3 个段落块,或第 1 个 H2 标题块,或第 1 个子块
|
||||
const h2Block = children.find((b) => b.type === 'heading' && b.level === 2);
|
||||
if (h2Block) return h2Block;
|
||||
|
||||
return children[Math.min(2, children.length - 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建审核驳回流转确认交互卡片
|
||||
*/
|
||||
export function createRejectConfirmationCard(
|
||||
targetDoc: { docId: string; title: string },
|
||||
matchingBlock?: DocumentBlock
|
||||
): InteractiveCardData {
|
||||
const cardId = 'card-reject-' + Date.now();
|
||||
const sectionLabel = matchingBlock?.numbering
|
||||
? `${matchingBlock.numbering} ${matchingBlock.content.slice(0, 24)}`
|
||||
: '第3节「电气安全与合规风险对标」';
|
||||
|
||||
return {
|
||||
cardId,
|
||||
cardType: 'CONFIRMATION',
|
||||
title: '审核驳回与返工确认',
|
||||
targetDocId: targetDoc.docId,
|
||||
targetDocTitle: targetDoc.title,
|
||||
targetSectionId: matchingBlock?.id || 'sec-3',
|
||||
sectionTitle: sectionLabel,
|
||||
proposedContent: '【驳回审查意见】关键技术指标与竞品对比不充分,需补充电气安全与EMC合规佐证,退回编制人重新修订。',
|
||||
impactRisk: '高风险:将退回至草稿编制阶段,正文锁定将解除',
|
||||
status: 'active',
|
||||
ttlTurns: 3,
|
||||
createdAt: new Date().toTimeString().substring(0, 5),
|
||||
actions: [
|
||||
{
|
||||
actionId: 'confirm_reject',
|
||||
label: '确认驳回并退回草稿',
|
||||
style: 'danger',
|
||||
isAffirmativeDefault: true,
|
||||
payload: {
|
||||
docId: targetDoc.docId,
|
||||
reason: '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。',
|
||||
targetSection: sectionLabel,
|
||||
},
|
||||
},
|
||||
{
|
||||
actionId: 'cancel',
|
||||
label: '放弃操作',
|
||||
style: 'subtle',
|
||||
payload: {
|
||||
docId: targetDoc.docId,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定用户输入是否属于肯定型双轨回复(好、确认、同意等)
|
||||
*/
|
||||
export function isAffirmativeResponse(text: string): boolean {
|
||||
const lower = text.trim().toLowerCase();
|
||||
const affirmativeKeywords = ['好', '确认', '同意', '是的', '准许', '批准', '行', '确认驳回', '同意驳回', 'ok', 'yes'];
|
||||
return affirmativeKeywords.some(
|
||||
(kw) =>
|
||||
lower === kw ||
|
||||
lower.startsWith(kw) ||
|
||||
(lower.length <= 8 && lower.includes(kw))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找消息历史中最近一条处于 active 状态的交互卡片消息
|
||||
*/
|
||||
export function findLastActiveCard(
|
||||
messages: ChatMessage[],
|
||||
sessionId?: string
|
||||
): { message: ChatMessage; card: InteractiveCardData } | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (sessionId && m.sessionId !== sessionId) continue;
|
||||
if (m.interactiveCard && m.interactiveCard.status === 'active') {
|
||||
return { message: m, card: m.interactiveCard };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递减所有 active 卡片的有效交互轮次 (TTL);达到 0 自动归档为 expired
|
||||
*/
|
||||
export function decrementActiveCardsTtl(
|
||||
messages: ChatMessage[],
|
||||
sessionId?: string
|
||||
): ChatMessage[] {
|
||||
return messages.map((m) => {
|
||||
if (sessionId && m.sessionId !== sessionId) return m;
|
||||
if (m.interactiveCard && m.interactiveCard.status === 'active') {
|
||||
const nextTtl = m.interactiveCard.ttlTurns - 1;
|
||||
return {
|
||||
...m,
|
||||
interactiveCard: {
|
||||
...m.interactiveCard,
|
||||
ttlTurns: nextTtl,
|
||||
status: nextTtl <= 0 ? 'expired' : m.interactiveCard.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行审核驳回对文档 AST 的变更:回退为 drafting 并挂载 issue 批注
|
||||
*/
|
||||
export function applyConfirmRejectToDoc(
|
||||
doc: DocumentAST,
|
||||
cardId: string,
|
||||
reviewerName: string,
|
||||
reason: string,
|
||||
hint?: string
|
||||
): { updatedDoc: DocumentAST; matchingBlock: DocumentBlock } {
|
||||
const matchingBlock = findMatchingBlockForReview(doc, hint);
|
||||
const commentId = `cmt-reject-${cardId}`;
|
||||
|
||||
const issueComment: BlockComment = {
|
||||
id: commentId,
|
||||
authorName: reviewerName,
|
||||
authorRole: 'reviewer',
|
||||
createdAt: new Date().toTimeString().substring(0, 5),
|
||||
type: 'issue',
|
||||
content: reason || '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。',
|
||||
resolved: false,
|
||||
};
|
||||
|
||||
// 克隆子块并追加批注
|
||||
const updatedChildren = (doc.rootBlock.children || []).map((b) => {
|
||||
if (b.id === matchingBlock.id) {
|
||||
return {
|
||||
...b,
|
||||
comments: [...(b.comments || []), issueComment],
|
||||
};
|
||||
}
|
||||
return b;
|
||||
});
|
||||
|
||||
const updatedDoc: DocumentAST = {
|
||||
...doc,
|
||||
lifecycleState: 'drafting',
|
||||
rootBlock: {
|
||||
...doc.rootBlock,
|
||||
children: updatedChildren,
|
||||
},
|
||||
};
|
||||
|
||||
return { updatedDoc, matchingBlock };
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行合法窗口期撤销驳回对文档 AST 的变更:恢复为 reviewing 并移除挂载的驳回批注
|
||||
*/
|
||||
export function applyUndoRejectToDoc(doc: DocumentAST, cardId: string): DocumentAST {
|
||||
const commentId = `cmt-reject-${cardId}`;
|
||||
|
||||
const updatedChildren = (doc.rootBlock.children || []).map((b) => {
|
||||
if (b.comments && b.comments.length > 0) {
|
||||
return {
|
||||
...b,
|
||||
comments: b.comments.filter((c) => c.id !== commentId),
|
||||
};
|
||||
}
|
||||
return b;
|
||||
});
|
||||
|
||||
return {
|
||||
...doc,
|
||||
lifecycleState: 'reviewing',
|
||||
rootBlock: {
|
||||
...doc.rootBlock,
|
||||
children: updatedChildren,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在消息列表中将指定卡片更新为 resolved
|
||||
*/
|
||||
export function resolveCardInMessages(
|
||||
messages: ChatMessage[],
|
||||
cardId: string,
|
||||
resolutionSummary: string,
|
||||
operatorName: string,
|
||||
resolvedAt?: string
|
||||
): ChatMessage[] {
|
||||
return messages.map((m) => {
|
||||
if (m.interactiveCard && m.interactiveCard.cardId === cardId) {
|
||||
return {
|
||||
...m,
|
||||
interactiveCard: {
|
||||
...m.interactiveCard,
|
||||
status: 'resolved',
|
||||
resolutionSummary,
|
||||
resolvedAt: resolvedAt || new Date().toISOString(),
|
||||
resolvedBy: operatorName,
|
||||
},
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在消息列表中将指定卡片更新为 cancelled
|
||||
*/
|
||||
export function cancelCardInMessages(messages: ChatMessage[], cardId: string): ChatMessage[] {
|
||||
return messages.map((m) => {
|
||||
if (m.interactiveCard && m.interactiveCard.cardId === cardId) {
|
||||
return {
|
||||
...m,
|
||||
interactiveCard: {
|
||||
...m.interactiveCard,
|
||||
status: 'cancelled',
|
||||
},
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user