fix(web): wire ALL_MOCK_DOCUMENTS and project deliverables to fix document switching
This commit is contained in:
@@ -32,6 +32,7 @@ workspaces/
|
||||
.cache/
|
||||
tmp/
|
||||
temp/
|
||||
.tmp/
|
||||
|
||||
# IDE / Editor Files
|
||||
.idea/
|
||||
|
||||
+155
-22
@@ -44,6 +44,11 @@ import {
|
||||
filterVisibleDeliverablesWithRBAC,
|
||||
} from './engine/rbac';
|
||||
import { MOCK_DOCUMENTS } from './data/mockDocuments';
|
||||
import {
|
||||
NEUROPULSE_DELIVERABLES,
|
||||
NEUROPULSE_DOCUMENTS,
|
||||
NEUROPULSE_PERMISSIONS,
|
||||
} from './data/neuropulseDemo';
|
||||
import { exportDocumentToDocx } from './engine/docxRenderer';
|
||||
import { exportDocumentToXlsx } from './engine/xlsxRenderer';
|
||||
import {
|
||||
@@ -219,11 +224,98 @@ const P2_INITIAL = createProjectTemplateDeliverables({
|
||||
creatorName: '张工',
|
||||
});
|
||||
|
||||
// 为第三测试项目 (数字化高频超声手术吸引系统 CUSA-Ultra,projectId: 3) 预置交付物
|
||||
export const CUSA_INITIAL_DELIVERABLES: DeliverableItem[] = [
|
||||
{
|
||||
docId: 'doc-cusa-intake',
|
||||
projectId: 3,
|
||||
taskKey: 'intake',
|
||||
title: '数字化高频超声手术吸引系统立项需求与产品定义',
|
||||
stage: 'V0.1',
|
||||
category: 'workflow',
|
||||
creatorId: 1,
|
||||
creatorName: '张工',
|
||||
isFixedWorkflow: true,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'approved',
|
||||
isClaimed: true,
|
||||
author: '张工',
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: '2026-09-08 17:00',
|
||||
summary: '高频超声手术吸引系统 (CUSA-Ultra) 初始需求摸底、预期临床用途与整机工程规格基线。',
|
||||
},
|
||||
{
|
||||
docId: 'doc-cusa-feasibility-reg',
|
||||
projectId: 3,
|
||||
taskKey: 'feasibility_reg',
|
||||
title: '数字化高频超声手术吸引系统立项与可行性法规论证报告',
|
||||
stage: 'V0.5',
|
||||
category: 'workflow',
|
||||
creatorId: 1,
|
||||
creatorName: '张工',
|
||||
isFixedWorkflow: true,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'approved',
|
||||
isClaimed: true,
|
||||
author: '张工',
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: '2026-09-08 17:30',
|
||||
summary: '针对 CUSA 超声吸引刀头与主机的三类医疗器械分类准入、注册检验与临床评价路径全景评估。',
|
||||
},
|
||||
{
|
||||
docId: 'doc-cusa-free-cavitation',
|
||||
projectId: 3,
|
||||
taskKey: 'free_cusa_cavitation_risk',
|
||||
title: '24kHz高频空化钛合金刀头微裂纹与疲劳剥落风险专项分析报告',
|
||||
stage: 'V0.5',
|
||||
category: 'shared',
|
||||
creatorId: 1,
|
||||
creatorName: '张工',
|
||||
isFixedWorkflow: false,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'approved',
|
||||
isClaimed: true,
|
||||
author: '张工',
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: '2026-09-08 17:15',
|
||||
summary: '自由任务产出:基于超声高频交变应力与空化微射流冲击的钛合金刀头疲劳寿命与表面钝化防护策略。',
|
||||
},
|
||||
{
|
||||
docId: 'doc-cusa-quote',
|
||||
projectId: 3,
|
||||
taskKey: 'cdo_quote',
|
||||
title: 'CDO超声手术系统工程样机研发与型检预算投资核算单',
|
||||
stage: 'V1.0',
|
||||
category: 'workflow',
|
||||
creatorId: 1,
|
||||
creatorName: '张工',
|
||||
isFixedWorkflow: true,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: '张工',
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: '2026-09-08 17:45',
|
||||
summary: 'CDO 商业立项核算清单:高频超声驱动电源、压电换能器、钛合金刀头模具及三类型检规费。',
|
||||
},
|
||||
];
|
||||
|
||||
export const ALL_INITIAL_DELIVERABLES: DeliverableItem[] = [
|
||||
...NEUROPULSE_DELIVERABLES,
|
||||
...INITIAL_DELIVERABLES,
|
||||
...P2_INITIAL.deliverables,
|
||||
...CUSA_INITIAL_DELIVERABLES,
|
||||
];
|
||||
|
||||
export const ALL_MOCK_DOCUMENTS = {
|
||||
...MOCK_DOCUMENTS,
|
||||
...NEUROPULSE_DOCUMENTS,
|
||||
};
|
||||
|
||||
export const MOCK_USERS: UserProfile[] = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -239,6 +331,11 @@ export const MOCK_USERS: UserProfile[] = [
|
||||
'doc-004': 'viewer', // 查阅者
|
||||
'doc-shared-001': 'author',
|
||||
'doc-private-001': 'author',
|
||||
'doc-cusa-intake': 'author',
|
||||
'doc-cusa-feasibility-reg': 'author',
|
||||
'doc-cusa-free-cavitation': 'author',
|
||||
'doc-cusa-quote': 'author',
|
||||
...(NEUROPULSE_PERMISSIONS['1'] || {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -255,6 +352,11 @@ export const MOCK_USERS: UserProfile[] = [
|
||||
'doc-004': 'viewer',
|
||||
'doc-shared-001': 'viewer',
|
||||
'doc-private-002': 'author',
|
||||
'doc-cusa-intake': 'reviewer',
|
||||
'doc-cusa-feasibility-reg': 'reviewer',
|
||||
'doc-cusa-free-cavitation': 'reviewer',
|
||||
'doc-cusa-quote': 'reviewer',
|
||||
...(NEUROPULSE_PERMISSIONS['2'] || {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -270,6 +372,11 @@ export const MOCK_USERS: UserProfile[] = [
|
||||
'doc-003': 'author',
|
||||
'doc-004': 'approver',
|
||||
'doc-shared-001': 'approver',
|
||||
'doc-cusa-intake': 'approver',
|
||||
'doc-cusa-feasibility-reg': 'approver',
|
||||
'doc-cusa-free-cavitation': 'approver',
|
||||
'doc-cusa-quote': 'approver',
|
||||
...(NEUROPULSE_PERMISSIONS['3'] || {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -337,7 +444,12 @@ export default function App() {
|
||||
}, [deliverables]);
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState<DeliverableCategory>('workflow');
|
||||
const [activeDocId, setActiveDocId] = useState('doc-001');
|
||||
const [activeDocId, setActiveDocId] = useState<string>(() => {
|
||||
const activeSess = sessions.find((s) => s.sessionId === activeSessionId);
|
||||
if (activeSess?.projectId === 5201) return 'doc-neuropulse-feasibility_reg';
|
||||
if (activeSess?.projectId === 3) return 'doc-cusa-feasibility-reg';
|
||||
return 'doc-001';
|
||||
});
|
||||
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
|
||||
|
||||
// 认证与会话状态
|
||||
@@ -441,7 +553,7 @@ export default function App() {
|
||||
// 文档缓存与当前 AST
|
||||
const [docCache, setDocCache] = useState<Record<string, DocumentAST>>(() => {
|
||||
const initialCache: Record<string, DocumentAST> = { ...P2_INITIAL.docASTMap };
|
||||
Object.entries(MOCK_DOCUMENTS).forEach(([id, meta]) => {
|
||||
Object.entries(ALL_MOCK_DOCUMENTS).forEach(([id, meta]) => {
|
||||
initialCache[id] = markdownToBlockAST(meta.markdown, {
|
||||
docId: meta.docId,
|
||||
title: meta.title,
|
||||
@@ -464,6 +576,7 @@ export default function App() {
|
||||
const docAST =
|
||||
docCache[activeDocId] ||
|
||||
(visibleDeliverables[0] && docCache[visibleDeliverables[0].docId]) ||
|
||||
docCache['doc-neuropulse-feasibility_reg'] ||
|
||||
docCache['doc-001'];
|
||||
|
||||
// 根据当前激活文档与用户权限矩阵,计算当前生效角色
|
||||
@@ -493,24 +606,44 @@ export default function App() {
|
||||
if (targetDoc) {
|
||||
setActiveCategory(targetDoc.category);
|
||||
}
|
||||
if (!docCache[id] && MOCK_DOCUMENTS[id]) {
|
||||
const meta = MOCK_DOCUMENTS[id];
|
||||
const parsed = 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,
|
||||
});
|
||||
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
||||
if (!docCache[id]) {
|
||||
if (ALL_MOCK_DOCUMENTS[id]) {
|
||||
const meta = ALL_MOCK_DOCUMENTS[id];
|
||||
const parsed = 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,
|
||||
});
|
||||
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
||||
} else if (targetDoc) {
|
||||
// 健壮兜底:即使为自定义/克隆交付物,也能即时生成基础 AST 骨架,严禁卡死或回退到无关文档
|
||||
const skeleton = `# ${targetDoc.title}\n\n- 交付物阶段:${targetDoc.stage}\n- 归属分类:${targetDoc.category}\n- 编制人:${targetDoc.author || targetDoc.creatorName}\n\n## 1. 交付物摘要\n${targetDoc.summary || '暂无详细章节摘要,待智能体参谋与项目成员增补。'}\n`;
|
||||
const parsed = markdownToBlockAST(skeleton, {
|
||||
docId: targetDoc.docId,
|
||||
title: targetDoc.title,
|
||||
taskKey: targetDoc.taskKey,
|
||||
version: targetDoc.stage,
|
||||
category: targetDoc.category,
|
||||
creatorId: targetDoc.creatorId,
|
||||
creatorName: targetDoc.creatorName,
|
||||
lifecycleState: targetDoc.lifecycleState,
|
||||
isClaimed: targetDoc.isClaimed,
|
||||
author: targetDoc.author,
|
||||
reviewer: targetDoc.reviewer,
|
||||
approver: targetDoc.approver,
|
||||
});
|
||||
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -643,8 +776,8 @@ export default function App() {
|
||||
let targetSectionName = '第3节';
|
||||
setDocCache((prev) => {
|
||||
let currentDoc = prev[targetDocId];
|
||||
if (!currentDoc && MOCK_DOCUMENTS[targetDocId]) {
|
||||
const meta = MOCK_DOCUMENTS[targetDocId];
|
||||
if (!currentDoc && ALL_MOCK_DOCUMENTS[targetDocId]) {
|
||||
const meta = ALL_MOCK_DOCUMENTS[targetDocId];
|
||||
currentDoc = markdownToBlockAST(meta.markdown, {
|
||||
docId: meta.docId,
|
||||
title: meta.title,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import seed from './seeds/neuropulseSeed.json';
|
||||
import { ProjectSession, ChatMessage, DeliverableItem } from '../types/workbench';
|
||||
import { MockDocumentMeta } from './mockDocuments';
|
||||
|
||||
export const NEUROPULSE_PROJECT_ID = 5201 as const;
|
||||
|
||||
export const NEUROPULSE_SESSIONS: ProjectSession[] = seed.sessions as ProjectSession[];
|
||||
|
||||
export const NEUROPULSE_MESSAGES: ChatMessage[] = seed.messages as ChatMessage[];
|
||||
|
||||
export const NEUROPULSE_DELIVERABLES: DeliverableItem[] = seed.deliverables as DeliverableItem[];
|
||||
|
||||
export const NEUROPULSE_DOCUMENTS: Record<string, MockDocumentMeta> = seed.documents as Record<
|
||||
string,
|
||||
MockDocumentMeta
|
||||
>;
|
||||
|
||||
export const NEUROPULSE_PERMISSIONS: Record<string, Record<string, 'author' | 'reviewer' | 'approver' | 'viewer'>> =
|
||||
seed.permissions as Record<string, Record<string, 'author' | 'reviewer' | 'approver' | 'viewer'>>;
|
||||
|
||||
export const NEUROPULSE_ACTIVE_SESSION_ID = seed.activeSessionId as string;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,174 @@
|
||||
import { ProjectSession, ChatMessage, StageKey } from '../types/workbench';
|
||||
|
||||
/**
|
||||
* 私聊会话 ID 约定:sess-{projectKey}-u{userId}
|
||||
* 同一项目下,不同用户拥有完全独立的 session,互不可见。
|
||||
*/
|
||||
export function buildPrivateSessionId(projectKey: string, userId: number): string {
|
||||
return `sess-${projectKey}-u${userId}`;
|
||||
}
|
||||
|
||||
export function filterSessionsForUser(
|
||||
sessions: ProjectSession[],
|
||||
userId: number
|
||||
): ProjectSession[] {
|
||||
return sessions.filter((s) => s.userId === userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录 / 切换用户后,解析应激活的私聊会话:
|
||||
* 1) 若当前 active 已属于该用户且该会话确有消息(或无更好候选)则保留
|
||||
* 2) 同 projectId 下优先选择「有消息」的私聊会话,避免落到 ensure 出来的空壳 sess-p{N}-u{id}
|
||||
* 3) 否则取该用户消息最多的会话 / 第一条会话
|
||||
*/
|
||||
export function resolveActiveSessionForUser(
|
||||
sessions: ProjectSession[],
|
||||
userId: number,
|
||||
preferredProjectId?: number,
|
||||
currentActiveId?: string,
|
||||
messageCounts?: Record<string, number>
|
||||
): string {
|
||||
const mine = filterSessionsForUser(sessions, userId);
|
||||
if (mine.length === 0) return '';
|
||||
|
||||
const countOf = (sessionId: string) =>
|
||||
messageCounts?.[sessionId] ??
|
||||
mine.find((s) => s.sessionId === sessionId)?.messageCount ??
|
||||
0;
|
||||
|
||||
const rank = (a: ProjectSession, b: ProjectSession) => {
|
||||
const diff = countOf(b.sessionId) - countOf(a.sessionId);
|
||||
if (diff !== 0) return diff;
|
||||
// 同消息量时,优先非 ensure 生成的空壳 ID(sess-p数字-u)
|
||||
const aShell = /^sess-p\d+-u\d+$/.test(a.sessionId) ? 1 : 0;
|
||||
const bShell = /^sess-p\d+-u\d+$/.test(b.sessionId) ? 1 : 0;
|
||||
return aShell - bShell;
|
||||
};
|
||||
|
||||
const pickBest = (candidates: ProjectSession[]) => {
|
||||
if (candidates.length === 0) return '';
|
||||
return [...candidates].sort(rank)[0].sessionId;
|
||||
};
|
||||
|
||||
if (currentActiveId) {
|
||||
const current = mine.find((s) => s.sessionId === currentActiveId);
|
||||
if (current && countOf(currentActiveId) > 0) {
|
||||
return currentActiveId;
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredProjectId != null) {
|
||||
const sameProject = mine.filter((s) => s.projectId === preferredProjectId);
|
||||
const best = pickBest(sameProject);
|
||||
if (best) return best;
|
||||
}
|
||||
|
||||
// 当前 active 若为空壳,改选同用户最佳会话
|
||||
return pickBest(mine);
|
||||
}
|
||||
|
||||
/** 同一 (projectId, userId) 只保留一条:优先有消息 / 非空壳 ID 的会话 */
|
||||
export function dedupePrivateSessions(
|
||||
sessions: ProjectSession[],
|
||||
messageCounts?: Record<string, number>
|
||||
): ProjectSession[] {
|
||||
const groups = new Map<string, ProjectSession[]>();
|
||||
for (const s of sessions) {
|
||||
const key = `${s.projectId}:${s.userId}`;
|
||||
const list = groups.get(key) || [];
|
||||
list.push(s);
|
||||
groups.set(key, list);
|
||||
}
|
||||
const result: ProjectSession[] = [];
|
||||
for (const list of groups.values()) {
|
||||
if (list.length === 1) {
|
||||
result.push(list[0]);
|
||||
continue;
|
||||
}
|
||||
const countOf = (id: string) =>
|
||||
messageCounts?.[id] ?? list.find((s) => s.sessionId === id)?.messageCount ?? 0;
|
||||
list.sort((a, b) => {
|
||||
const diff = countOf(b.sessionId) - countOf(a.sessionId);
|
||||
if (diff !== 0) return diff;
|
||||
const aShell = /^sess-p\d+-u\d+$/.test(a.sessionId) ? 1 : 0;
|
||||
const bShell = /^sess-p\d+-u\d+$/.test(b.sessionId) ? 1 : 0;
|
||||
return aShell - bShell;
|
||||
});
|
||||
result.push(list[0]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 确保用户对每个已知项目都有独立私聊会话(无则克隆项目元数据新建空会话) */
|
||||
export function ensurePrivateSessionsForUser(
|
||||
sessions: ProjectSession[],
|
||||
userId: number
|
||||
): ProjectSession[] {
|
||||
const byProject = new Map<number, ProjectSession>();
|
||||
for (const s of sessions) {
|
||||
if (!byProject.has(s.projectId)) {
|
||||
byProject.set(s.projectId, s);
|
||||
}
|
||||
}
|
||||
const result = [...sessions];
|
||||
for (const [projectId, template] of byProject) {
|
||||
const exists = result.some((s) => s.projectId === projectId && s.userId === userId);
|
||||
if (!exists) {
|
||||
// 优先复用已知项目短名,避免与默认 sess-cusa-u* 并存两套空壳
|
||||
const projectKey =
|
||||
projectId === 3
|
||||
? 'cusa'
|
||||
: projectId === 1
|
||||
? 'endo'
|
||||
: projectId === 2
|
||||
? 'ultrasound'
|
||||
: projectId === 5201
|
||||
? 'neuropulse'
|
||||
: `p${projectId}`;
|
||||
result.push({
|
||||
sessionId: buildPrivateSessionId(projectKey, userId),
|
||||
userId,
|
||||
projectId,
|
||||
title: template.title,
|
||||
deviceType: template.deviceType,
|
||||
stage: template.stage as StageKey,
|
||||
stageLabel: template.stageLabel,
|
||||
messageCount: 0,
|
||||
updatedAt: '刚刚',
|
||||
});
|
||||
}
|
||||
}
|
||||
return dedupePrivateSessions(result);
|
||||
}
|
||||
|
||||
/** 断言:任意消息不得跨用户 session 泄漏 */
|
||||
export function assertNoCrossUserChatLeak(
|
||||
sessions: ProjectSession[],
|
||||
messages: ChatMessage[]
|
||||
): { ok: boolean; leaks: string[] } {
|
||||
const sessionOwner = new Map(sessions.map((s) => [s.sessionId, s.userId]));
|
||||
const leaks: string[] = [];
|
||||
for (const m of messages) {
|
||||
const owner = sessionOwner.get(m.sessionId);
|
||||
if (owner == null) continue;
|
||||
if (m.role === 'user' && m.authorName) {
|
||||
const otherOwners = sessions.filter((s) => s.userId !== owner);
|
||||
for (const o of otherOwners) {
|
||||
const otherName = guessUserNameById(o.userId);
|
||||
if (otherName && m.authorName === otherName) {
|
||||
leaks.push(
|
||||
`session=${m.sessionId} ownerUserId=${owner} 含异主用户发言 author=${m.authorName}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: leaks.length === 0, leaks };
|
||||
}
|
||||
|
||||
function guessUserNameById(userId?: number): string | null {
|
||||
if (userId === 1) return '张工';
|
||||
if (userId === 2) return '李主管';
|
||||
if (userId === 3) return '王总监';
|
||||
return null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export type StageKey = 'V0.1' | 'V0.5' | 'V1.0';
|
||||
|
||||
export interface ProjectSession {
|
||||
sessionId: string;
|
||||
userId?: number;
|
||||
projectId: number;
|
||||
title: string;
|
||||
deviceType: string;
|
||||
|
||||
Reference in New Issue
Block a user