Task 13: ProjectGallery + ProjectWizard(3 步创建向导)
Files:
- Create:
src/pages/ProjectGallery.tsx - Create:
src/pages/ProjectWizard.tsx - Test:
src/__tests__/wizard.test.tsx
Interfaces:
-
Consumes: Task 10
api.projects、Task 11 tokens、Task 12 路由 -
Produces:
ProjectGallery:/首页——项目卡片网格(名称/种子想法/更新时间/软删除后 30s 内显示"恢复"按钮)、"新建项目"入口 →/wizardProjectWizard:3 步——① 种子想法(textarea + 名称)② 验证模式 4 选 1(deductive/simulation/hybrid/experimental)③ 价值权重 5 滑块(和为 1)+ 隐私模式开关;完成后POST /api/v1/projects→ 跳转/workspace/:id- 步骤导航:上一步/下一步/完成;步骤校验不通过禁用下一步
-
Step 1: 写失败测试
Create src/__tests__/wizard.test.tsx:
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ProjectWizard } from '../pages/ProjectWizard';
beforeEach(() => { vi.restoreAllMocks(); });
describe('ProjectWizard', () => {
it('three steps with validation gates', async () => {
render(<MemoryRouter><ProjectWizard /></MemoryRouter>);
// Step 1: 名称 + 种子想法必填
fireEvent.click(screen.getByRole('button', { name: /下一步/ }));
expect(screen.getByText(/请填写/)).toBeDefined();
fireEvent.change(screen.getByLabelText(/项目名称/), { target: { value: '量子认知' } });
fireEvent.change(screen.getByLabelText(/种子想法/), { target: { value: '意识是量子现象吗' } });
fireEvent.click(screen.getByRole('button', { name: /下一步/ }));
// Step 2: 验证模式
expect(screen.getByText(/验证模式/)).toBeDefined();
fireEvent.click(screen.getByRole('radio', { name: /混合验证/ }));
fireEvent.click(screen.getByRole('button', { name: /下一步/ }));
// Step 3: 价值权重
expect(screen.getByText(/价值权重/)).toBeDefined();
fireEvent.change(screen.getByLabelText(/认知/), { target: { value: '30' } });
fireEvent.click(screen.getByRole('button', { name: /完成/ }));
// 提交被 stub 拦截
await waitFor(() => expect(screen.getByText(/创建中/)).toBeDefined());
});
it('sum of value weights must equal 1', async () => {
render(<MemoryRouter><ProjectWizard /></MemoryRouter>);
fireEvent.change(screen.getByLabelText(/项目名称/), { target: { value: 'x' } });
fireEvent.change(screen.getByLabelText(/种子想法/), { target: { value: 'y' } });
fireEvent.click(screen.getByRole('button', { name: /下一步/ }));
fireEvent.click(screen.getByRole('radio', { name: /混合验证/ }));
fireEvent.click(screen.getByRole('button', { name: /下一步/ }));
// 全部调到 0.1 → 和 0.5 ≠ 1
for (const label of ['生存', '认知', '审美', '伦理', '超越']) {
fireEvent.change(screen.getByLabelText(label), { target: { value: '10' } });
}
expect(screen.getByText(/权重之和须为 1/)).toBeDefined();
expect((screen.getByRole('button', { name: /完成/ }) as HTMLButtonElement).disabled).toBe(true);
});
});
- Step 2: 运行确认失败
Run: npx vitest run src/__tests__/wizard.test.tsx
Expected: FAIL(页面不存在)。
- Step 3: 实现 ProjectGallery
Create src/pages/ProjectGallery.tsx:
import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { api } from '../lib/api';
import { tokens } from '../design/tokens';
import type { ProjectRecord } from '../types';
export function ProjectGallery() {
const [projects, setProjects] = useState<ProjectRecord[]>([]);
const [graveyard, setGraveyard] = useState<Record<string, string>>({}); // id → deletedAt
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
const refresh = async () => {
const { rows } = await api.projects.list();
setProjects(rows);
setLoading(false);
};
useEffect(() => { void refresh(); }, []);
// 软删除 30s 恢复窗口计时(对应 undoStore 回收站语义)
useEffect(() => {
const timer = setInterval(() => {
const now = Date.now();
setGraveyard((prev) => {
const next = { ...prev };
for (const [id, t] of Object.entries(next)) {
if (now - new Date(t).getTime() > 30_000) delete next[id];
}
return next;
});
}, 1000);
return () => clearInterval(timer);
}, []);
const handleDelete = async (id: string) => {
await api.projects.softDelete(id);
setGraveyard((prev) => ({ ...prev, [id]: new Date().toISOString() }));
await refresh();
};
const handleRestore = async (id: string) => {
await api.projects.restore(id);
setGraveyard((prev) => { const next = { ...prev }; delete next[id]; return next; });
await refresh();
};
return (
<div style={{ minHeight: '100vh', background: tokens.colors.semantic.canvas, color: tokens.colors.semantic.text, padding: tokens.spacing[8] }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', maxWidth: 960, margin: '0 auto' }}>
<h1 style={{ fontSize: tokens.typography.size['2xl'], fontWeight: tokens.typography.weight.bold }}>菌丝空间超图</h1>
<button onClick={() => navigate('/wizard')} style={{ background: tokens.colors.semantic.accent, color: '#000', border: 'none', borderRadius: tokens.radius.md, padding: `${tokens.spacing[2]} ${tokens.spacing[4]}`, cursor: 'pointer', fontWeight: tokens.typography.weight.semibold }}>
新建项目
</button>
</div>
{loading ? <p style={{ textAlign: 'center', color: tokens.colors.semantic.textMuted, marginTop: tokens.spacing[12] }}>加载中…</p> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: tokens.spacing[4], maxWidth: 960, margin: `${tokens.spacing[6]} auto 0` }}>
{projects.length === 0 && <p style={{ color: tokens.colors.semantic.textMuted }}>还没有项目——点击"新建项目"开启第一次思维实验。</p>}
{projects.map((p) => (
<div key={p.id} style={{ background: tokens.colors.domain.graphite['900'], border: `1px solid ${tokens.colors.domain.graphite['800']}`, borderRadius: tokens.radius.lg, padding: tokens.spacing[5], display: 'flex', flexDirection: 'column', gap: tokens.spacing[3] }}>
<h2 style={{ fontSize: tokens.typography.size.lg, fontWeight: tokens.typography.weight.semibold }}>{p.name}</h2>
<p style={{ fontSize: tokens.typography.size.sm, color: tokens.colors.semantic.textMuted }}>{p.rootSeedIdea}</p>
<p style={{ fontSize: tokens.typography.size.xs, color: tokens.colors.domain.graphite['400'] }}>更新于 {new Date(p.updatedAt).toLocaleString()}</p>
<div style={{ display: 'flex', gap: tokens.spacing[2], marginTop: 'auto' }}>
<Link to={`/workspace/${p.id}`} style={{ flex: 1, textAlign: 'center', background: tokens.colors.domain.ultramarine['600'], color: tokens.colors.semantic.text, borderRadius: tokens.radius.md, padding: `${tokens.spacing[2]} ${tokens.spacing[3]}`, textDecoration: 'none', fontSize: tokens.typography.size.sm }}>
进入实验室
</Link>
{graveyard[p.id]
? <button onClick={() => void handleRestore(p.id)} style={{ background: tokens.colors.domain.amber['600'], border: 'none', borderRadius: tokens.radius.md, padding: tokens.spacing[2], cursor: 'pointer', fontSize: tokens.typography.size.sm }}>恢复</button>
: <button onClick={() => void handleDelete(p.id)} style={{ background: 'transparent', border: `1px solid ${tokens.colors.domain.vermilion['600']}`, color: tokens.colors.domain.vermilion['400'], borderRadius: tokens.radius.md, padding: tokens.spacing[2], cursor: 'pointer', fontSize: tokens.typography.size.sm }}>删除</button>}
</div>
</div>
))}
</div>
)}
</div>
);
}
- Step 4: 实现 ProjectWizard
Create src/pages/ProjectWizard.tsx:
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../lib/api';
import { tokens } from '../design/tokens';
import type { ValidationMode, ValueScores } from '../types';
const VALIDATION_OPTIONS: { id: ValidationMode; label: string; desc: string }[] = [
{ id: 'deductive', label: '演绎推理', desc: '从公理严格推导,最严谨' },
{ id: 'simulation', label: '仿真模拟', desc: '在虚拟环境中运行假设' },
{ id: 'hybrid', label: '混合验证', desc: '演绎 + 仿真双轨' },
{ id: 'experimental', label: '实验验证', desc: '真实世界干预测试' },
];
const VALUE_KEYS: (keyof ValueScores)[] = ['survival', 'cognitive', 'aesthetic', 'ethical', 'transcendent'];
const VALUE_LABELS: Record<keyof ValueScores, string> = { survival: '生存', cognitive: '认知', aesthetic: '审美', ethical: '伦理', transcendent: '超越' };
export function ProjectWizard() {
const navigate = useNavigate();
const [step, setStep] = useState(0);
const [name, setName] = useState('');
const [seed, setSeed] = useState('');
const [validationMode, setValidationMode] = useState<ValidationMode | null>(null);
const [weights, setWeights] = useState<ValueScores>({ survival: 20, cognitive: 20, aesthetic: 20, ethical: 20, transcendent: 20 });
const [privacy, setPrivacy] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const weightSum = useMemo(() => Object.values(weights).reduce((a, b) => a + b, 0), [weights]);
const canNext = step === 0 ? name.trim().length > 0 && seed.trim().length > 0 : step === 1 ? validationMode !== null : true;
const weightsValid = Math.abs(weightSum - 100) <= 0.5;
const setWeight = (k: keyof ValueScores, v: number) => setWeights((w) => ({ ...w, [k]: v }));
const submit = async () => {
if (!weightsValid) { setError('权重之和须为 1(当前 ' + (weightSum / 100).toFixed(2) + ')'); return; }
setSubmitting(true);
setError('');
try {
const project = await api.projects.create({
name, rootSeedIdea: seed, validationMode: validationMode!,
valueWeights: Object.fromEntries(VALUE_KEYS.map((k) => [k, weights[k] / 100])) as unknown as ValueScores,
privacyMode: privacy,
});
navigate(`/workspace/${project.id}`);
} catch (e) {
setError(e instanceof Error ? e.message : '创建失败');
setSubmitting(false);
}
};
return (
<div style={{ minHeight: '100vh', background: tokens.colors.semantic.canvas, color: tokens.colors.semantic.text, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 520, background: tokens.colors.domain.graphite['900'], border: `1px solid ${tokens.colors.domain.graphite['800']}`, borderRadius: tokens.radius.lg, padding: tokens.spacing[8] }}>
<div style={{ display: 'flex', gap: tokens.spacing[2], marginBottom: tokens.spacing[6] }}>
{['种子想法', '验证模式', '价值权重'].map((s, i) => (
<span key={s} style={{ flex: 1, textAlign: 'center', padding: `${tokens.spacing[1]} 0`, borderRadius: tokens.radius.sm, fontSize: tokens.typography.size.xs, background: i === step ? tokens.colors.domain.ultramarine['700'] : tokens.colors.domain.graphite['800'], color: i === step ? tokens.colors.semantic.text : tokens.colors.semantic.textMuted }}>{s}</span>
))}
</div>
{step === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: tokens.spacing[4] }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: tokens.spacing[2], fontSize: tokens.typography.size.sm }}>
项目名称
<input value={name} onChange={(e) => setName(e.target.value)} aria-label="项目名称" placeholder="如:量子认知实验室" style={{ background: tokens.colors.domain.graphite['950'], border: `1px solid ${tokens.colors.domain.graphite['700']}`, borderRadius: tokens.radius.md, padding: tokens.spacing[3], color: tokens.colors.semantic.text }} />
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: tokens.spacing[2], fontSize: tokens.typography.size.sm }}>
种子想法
<textarea value={seed} onChange={(e) => setSeed(e.target.value)} aria-label="种子想法" rows={5} placeholder="你想要探索的核心问题…" style={{ background: tokens.colors.domain.graphite['950'], border: `1px solid ${tokens.colors.domain.graphite['700']}`, borderRadius: tokens.radius.md, padding: tokens.spacing[3], color: tokens.colors.semantic.text, resize: 'vertical' }} />
</label>
{!canNext && <p style={{ color: tokens.colors.semantic.danger, fontSize: tokens.typography.size.xs }}>请填写项目名称与种子想法</p>}
</div>
)}
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: tokens.spacing[3] }}>
<p style={{ fontSize: tokens.typography.size.sm, color: tokens.colors.semantic.textMuted }}>验证模式</p>
{VALIDATION_OPTIONS.map((o) => (
<label key={o.id} style={{ display: 'flex', gap: tokens.spacing[3], alignItems: 'flex-start', padding: tokens.spacing[3], border: `1px solid ${validationMode === o.id ? tokens.colors.domain.ultramarine['500'] : tokens.colors.domain.graphite['700']}`, borderRadius: tokens.radius.md, cursor: 'pointer', background: validationMode === o.id ? tokens.colors.domain.ultramarine['950'] : 'transparent' }}>
<input type="radio" name="validation" aria-label={o.label} checked={validationMode === o.id} onChange={() => setValidationMode(o.id)} />
<span>
<span style={{ display: 'block', fontSize: tokens.typography.size.md }}>{o.label}</span>
<span style={{ fontSize: tokens.typography.size.xs, color: tokens.colors.semantic.textMuted }}>{o.desc}</span>
</span>
</label>
))}
</div>
)}
{step === 2 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: tokens.spacing[4] }}>
<p style={{ fontSize: tokens.typography.size.sm, color: tokens.colors.semantic.textMuted }}>价值权重(总和须为 1)</p>
{VALUE_KEYS.map((k) => (
<label key={k} style={{ display: 'flex', alignItems: 'center', gap: tokens.spacing[3], fontSize: tokens.typography.size.sm }}>
<span style={{ width: 48 }}>{VALUE_LABELS[k]}</span>
<input type="range" min={0} max={100} step={5} value={weights[k]} aria-label={VALUE_LABELS[k]} onChange={(e) => setWeight(k, Number(e.target.value))} style={{ flex: 1 }} />
<span style={{ width: 40, textAlign: 'right', fontSize: tokens.typography.size.xs, color: tokens.colors.semantic.textMuted }}>{weights[k]}%</span>
</label>
))}
{!weightsValid && <p style={{ color: tokens.colors.semantic.danger, fontSize: tokens.typography.size.xs }}>权重之和须为 1(当前 {(weightSum / 100).toFixed(2)})</p>}
<label style={{ display: 'flex', gap: tokens.spacing[2], alignItems: 'center', fontSize: tokens.typography.size.sm }}>
<input type="checkbox" checked={privacy} onChange={(e) => setPrivacy(e.target.checked)} />
隐私模式(联邦数据不出域,M4 生效)
</label>
</div>
)}
{error && <p style={{ color: tokens.colors.semantic.danger, fontSize: tokens.typography.size.sm, marginTop: tokens.spacing[4] }}>{error}</p>}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: tokens.spacing[6] }}>
<button onClick={() => (step === 0 ? navigate('/') : setStep(step - 1))} style={{ background: 'transparent', border: `1px solid ${tokens.colors.domain.graphite['600']}`, color: tokens.colors.semantic.text, borderRadius: tokens.radius.md, padding: `${tokens.spacing[2]} ${tokens.spacing[5]}`, cursor: 'pointer' }}>{step === 0 ? '返回' : '上一步'}</button>
{step < 2 ? (
<button disabled={!canNext} onClick={() => setStep(step + 1)} style={{ background: tokens.colors.domain.ultramarine['600'], border: 'none', color: tokens.colors.semantic.text, borderRadius: tokens.radius.md, padding: `${tokens.spacing[2]} ${tokens.spacing[5]}`, cursor: canNext ? 'pointer' : 'not-allowed', opacity: canNext ? 1 : 0.4 }}>下一步</button>
) : (
<button disabled={submitting || !weightsValid} onClick={() => void submit()} style={{ background: tokens.colors.semantic.accent, border: 'none', color: '#000', borderRadius: tokens.radius.md, padding: `${tokens.spacing[2]} ${tokens.spacing[5]}`, cursor: submitting || !weightsValid ? 'not-allowed' : 'pointer', opacity: submitting || !weightsValid ? 0.4 : 1, fontWeight: tokens.typography.weight.semibold }}>{submitting ? '创建中…' : '完成'}</button>
)}
</div>
</div>
</div>
);
}
- Step 5: 运行测试验证
Run: npx vitest run src/__tests__/wizard.test.tsx
Expected: PASS(2 个用例)。若"创建中"断言失败,检查 submit 内 api.projects.create 被 fetch stub 拦截或改为 vi.spyOn(api.projects, 'create')。
- Step 6: Commit
git add src/pages/ProjectGallery.tsx src/pages/ProjectWizard.tsx src/__tests__/wizard.test.tsx
git commit -m "feat(pages): project gallery with 30s restore window + 3-step wizard"
Task 14: OperationPanel 外壳 + ConfirmDialog + autoSnapshot + 崩溃恢复
Files:
- Create:
src/components/OperationPanel.tsx - Create:
src/components/ui/ConfirmDialog.tsx - Create:
src/lib/autoSnapshot.ts - Modify:
src/pages/WorkspacePage.tsx(挂载 OperationPanel) - Test:
src/__tests__/operations.test.tsx+src/lib/__tests__/autoSnapshot.test.ts
Interfaces:
-
Consumes: Task 9 undoStore/hypergraphStore、Task 10 api.history、Task 11 interactionSpecs.systems
-
Produces:
OperationPanel:右面板内容区替换占位——操作历史列表(undoStore.past 渲染:类型徽标/时间/描述)、撤销/重做按钮、清空回收站(30s 确认对话框)ConfirmDialog:受控对话框{open, title, message, confirmLabel, danger, onConfirm, onCancel}(Esc/遮罩关闭,回车确认)autoSnapshot:startAutoSnapshot(projectId, {opsThreshold?, intervalMs?})——订阅 undoStore,每 opsThreshold 个操作或 intervalMs 间隔触发api.history.createSnapshot;返回 stop 函数- 启动崩溃恢复:WorkspacePage 挂载时
hypergraphStore.loadProject失败(后端 500/连接中断)→ 读取localStorage['mindgraph.crash.snapshot']恢复本地节点 → 提示"已从本地快照恢复"
-
Step 1: 写失败测试
Create src/lib/__tests__/autoSnapshot.test.ts:
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { startAutoSnapshot } from '../autoSnapshot';
import { useUndoStore } from '../../stores';
beforeEach(() => { vi.useFakeTimers(); vi.restoreAllMocks(); });
afterEach(() => { vi.useRealTimers(); });
describe('autoSnapshot', () => {
it('snapshots every 5 ops', async () => {
const post = vi.fn(async () => ({ id: 'snap_1' }));
vi.spyOn(await import('../api'), 'api').mockReturnValueOnce(undefined as never);
// 直接 mock fetch:snapshot 走 api.history.createSnapshot → api.post → fetch
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ok: true, data: { id: 'snap_1' } }), { status: 200 })));
const stop = startAutoSnapshot('proj_1', { opsThreshold: 5, intervalMs: 300_000 });
for (let i = 0; i < 5; i++) useUndoStore.getState().push({ type: 'node.create', payload: { id: `node_${i}` }, prev: null });
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/v1/projects/proj_1/snapshots'), expect.anything());
stop();
});
it('snapshots on interval', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ok: true, data: { id: 'snap_1' } }), { status: 200 })));
const stop = startAutoSnapshot('proj_1', { opsThreshold: 100, intervalMs: 60_000 });
vi.advanceTimersByTime(61_000);
expect(fetch).toHaveBeenCalled();
stop();
});
});
Create src/__tests__/operations.test.tsx:
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { OperationPanel } from '../components/OperationPanel';
import { ConfirmDialog } from '../components/ui/ConfirmDialog';
import { useUndoStore } from '../stores';
beforeEach(() => { useUndoStore.setState({ past: [], future: [], canUndo: false, canRedo: false }); });
describe('OperationPanel', () => {
it('renders undo history entries with undo/redo buttons', () => {
useUndoStore.getState().push({ type: 'node.create', payload: { id: 'node_1', title: '种子' }, prev: null });
render(<OperationPanel />);
expect(screen.getByText(/node\.create/)).toBeDefined();
fireEvent.click(screen.getByRole('button', { name: /撤销/ }));
expect(useUndoStore.getState().future.length).toBe(1);
});
});
describe('ConfirmDialog', () => {
it('confirms and cancels', () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(<ConfirmDialog open title="清空回收站" message="将永久删除这些节点" confirmLabel="确认" danger onConfirm={onConfirm} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /确认/ }));
expect(onConfirm).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: /取消/ }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
});
- Step 2: 运行确认失败
Run: npx vitest run src/__tests__/operations.test.tsx src/lib/__tests__/autoSnapshot.test.ts
Expected: FAIL(模块不存在)。
- Step 3: 实现 ConfirmDialog + OperationPanel
Create src/components/ui/ConfirmDialog.tsx:
import { useEffect } from 'react';
import { tokens } from '../../design/tokens';
interface Props {
open: boolean;
title: string;
message: string;
confirmLabel: string;
danger?: boolean;
onConfirm(): void;
onCancel(): void;
}
export function ConfirmDialog({ open, title, message, confirmLabel, danger, onConfirm, onCancel }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
if (e.key === 'Enter') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onConfirm, onCancel]);
if (!open) return null;
return (
<div onClick={onCancel} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} role="dialog" aria-modal>
<div onClick={(e) => e.stopPropagation()} style={{ width: 380, background: tokens.colors.domain.graphite['900'], border: `1px solid ${tokens.colors.domain.graphite['700']}`, borderRadius: tokens.radius.lg, padding: tokens.spacing[6], display: 'flex', flexDirection: 'column', gap: tokens.spacing[4] }}>
<h3 style={{ fontSize: tokens.typography.size.lg, fontWeight: tokens.typography.weight.semibold }}>{title}</h3>
<p style={{ fontSize: tokens.typography.size.sm, color: tokens.colors.semantic.textMuted, lineHeight: tokens.typography.lineHeight.relaxed }}>{message}</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: tokens.spacing[3], marginTop: tokens.spacing[2] }}>
<button onClick={onCancel} style={{ background: 'transparent', border: `1px solid ${tokens.colors.domain.graphite['600']}`, color: tokens.colors.semantic.text, borderRadius: tokens.radius.md, padding: `${tokens.spacing[2]} ${tokens.spacing[4]}`, cursor: 'pointer' }}>取消</button>
<button onClick={onConfirm} style={{ background: danger ? tokens.colors.semantic.danger : tokens.colors.semantic.accent, border: 'none', borderRadius: tokens.radius.md, padding: `${tokens.spacing[2]} ${tokens.spacing[4]}`, cursor: 'pointer', color: '#000', fontWeight: tokens.typography.weight.semibold }}>{confirmLabel}</button>
</div>
</div>
</div>
);
}
Create src/components/OperationPanel.tsx:
import { useState } from 'react';
import { useUndoStore, useHypergraphStore } from '../stores';
import { tokens } from '../design/tokens';
import { ConfirmDialog } from './ui/ConfirmDialog';
const TYPE_LABEL: Record<string, string> = {
'node.create': '创建节点', 'node.update': '更新节点', 'node.soft_delete': '删除节点', 'node.restore': '恢复节点',
'hypha.create': '连接菌丝', 'hypha.soft_delete': '剪断菌丝', 'hypha.restore': '修复菌丝',
'knot.create': '菌丝结涌现', 'knot.confirm': '确认菌丝结', 'knot.dissolve': '解散菌丝结',
};
export function OperationPanel() {
const past = useUndoStore((s) => s.past);
const future = useUndoStore((s) => s.future);
const canUndo = useUndoStore((s) => s.canUndo);
const canRedo = useUndoStore((s) => s.canRedo);
const undo = useUndoStore((s) => s.undo);
const redo = useUndoStore((s) => s.redo);
const graveyard = useUndoStore((s) => s.graveyard);
const removeFromGraveyard = useUndoStore((s) => s.removeFromGraveyard);
const purgeAll = useHypergraphStore((s) => s.purgeAll ?? (async () => undefined));
const [confirmOpen, setConfirmOpen] = useState(false);
const clearGraveyard = () => {
for (const id of [...graveyard.keys()]) removeFromGraveyard(id);
void purgeAll();
setConfirmOpen(false);
};
return (
<div style={{ padding: tokens.spacing[4], display: 'flex', flexDirection: 'column', gap: tokens.spacing[4], overflowY: 'auto', flex: 1 }}>
<div style={{ display: 'flex', gap: tokens.spacing[2] }}>
<button onClick={() => undo()} disabled={!canUndo} aria-label="撤销" style={btnStyle(canUndo)}>撤销</button>
<button onClick={() => redo()} disabled={!canRedo} aria-label="重做" style={btnStyle(canRedo)}>重做</button>
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: tokens.spacing[2] }}>
<span style={{ fontSize: tokens.typography.size.sm, fontWeight: tokens.typography.weight.semibold }}>操作历史({past.length})</span>
{graveyard.size > 0 && (
<button onClick={() => setConfirmOpen(true)} style={{ background: 'transparent', border: 'none', color: tokens.colors.semantic.danger, fontSize: tokens.typography.size.xs, cursor: 'pointer' }}>
清空回收站({graveyard.size})
</button>
)}
</div>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: tokens.spacing[1] }}>
{[...past].reverse().map((e, i) => (
<li key={`${e.type}-${i}`} style={{ display: 'flex', justifyContent: 'space-between', fontSize: tokens.typography.size.xs, padding: `${tokens.spacing[1]} ${tokens.spacing[2]}`, background: tokens.colors.domain.graphite['950'], borderRadius: tokens.radius.sm }}>
<span style={{ color: tokens.colors.semantic.text }}>{TYPE_LABEL[e.type] ?? e.type}</span>
<span style={{ color: tokens.colors.domain.graphite['400'] }}>{(e.payload.title as string) ?? (e.payload.id as string) ?? ''}</span>
</li>
))}
{past.length === 0 && <li style={{ fontSize: tokens.typography.size.xs, color: tokens.colors.domain.graphite['400'] }}>暂无操作</li>}
</ul>
</div>
<ConfirmDialog
open={confirmOpen} title="清空回收站" danger confirmLabel="永久删除"
message={`回收站内有 ${graveyard.size} 个实体,删除后将不可恢复。30 秒恢复期未过的实体可正常恢复。`}
onConfirm={clearGraveyard} onCancel={() => setConfirmOpen(false)}
/>
</div>
);
}
function btnStyle(enabled: boolean) {
return {
background: enabled ? tokens.colors.domain.ultramarine['600'] : tokens.colors.domain.graphite['800'],
border: 'none', color: enabled ? tokens.colors.semantic.text : tokens.colors.domain.graphite['500'],
borderRadius: tokens.radius.md, padding: `${tokens.spacing[1]} ${tokens.spacing[3]}`, fontSize: tokens.typography.size.sm, cursor: enabled ? 'pointer' : 'not-allowed',
};
}
- Step 4: 实现 autoSnapshot + 崩溃恢复
Create src/lib/autoSnapshot.ts:
import { api } from './api';
import { useUndoStore, useHypergraphStore } from '../stores';
interface AutoSnapshotOptions { opsThreshold?: number; intervalMs?: number }
export function startAutoSnapshot(projectId: string, opts: AutoSnapshotOptions = {}): () => void {
const opsThreshold = opts.opsThreshold ?? 50;
const intervalMs = opts.intervalMs ?? 5 * 60_000;
let opsSince = 0;
let running = true;
let interval: ReturnType<typeof setInterval> | null = null;
const snapshot = async () => {
if (!running || !projectId) return;
try {
const data = { nodes: Object.values(useHypergraphStore.getState().nodes), hyphae: Object.values(useHypergraphStore.getState().hyphae), knots: Object.values(useHypergraphStore.getState().knots) };
await api.history.createSnapshot(projectId, { snapshotType: 'auto', data, reason: opsSince >= opsThreshold ? '50-ops-threshold' : 'interval' });
opsSince = 0;
} catch { /* 快照失败静默,下轮重试 */ }
};
const unsubscribe = useUndoStore.subscribe(() => {
opsSince++;
if (opsSince >= opsThreshold) void snapshot();
});
interval = setInterval(() => void snapshot(), intervalMs);
interval.unref?.();
return () => {
running = false;
unsubscribe();
if (interval) clearInterval(interval);
};
}
/** 启动崩溃恢复:loadProject 失败时回退本地快照 */
export function crashRecovery(): void {
const raw = localStorage.getItem('mindgraph.crash.snapshot');
if (!raw) return;
try {
const data = JSON.parse(raw) as { projectId: string; nodes: unknown[] };
void data;
} catch { localStorage.removeItem('mindgraph.crash.snapshot'); }
}
注:
crashRecoveryM1 为脚手架(快照写入在hypergraphStore.applyCommand内按interactionSpecs.systems.autoSnapshotOps节流写入 localStorage),WorkspacePage 挂载时调用并在恢复时 toast 提示。
- Step 5: 挂载 OperationPanel 到右面板
Modify src/components/layout/RightPanel.tsx:占位内容替换为 <OperationPanel />(import 并替换 <p>节点详情…</p> 区域)。
Modify src/pages/WorkspacePage.tsx:加入 useEffect 启动 startAutoSnapshot(projectId)(依赖 projectId)与 crashRecovery(),返回 cleanup 调 stop。
- Step 6: 运行测试验证
Run: npx vitest run src/__tests__/operations.test.tsx src/lib/__tests__/autoSnapshot.test.ts
Expected: PASS(4 个用例)。autoSnapshot 的 ops 触发用例若未命中,检查 useUndoStore.subscribe 是否在 push 后同步回调(zustand 默认 sync 监听,OK)。
- Step 7: Commit
git add src/components/OperationPanel.tsx src/components/ui/ConfirmDialog.tsx src/lib/autoSnapshot.ts src/pages/WorkspacePage.tsx src/components/layout/RightPanel.tsx src/__tests__/operations.test.tsx src/lib/__tests__/autoSnapshot.test.ts
git commit -m "feat(ops): operation panel with undo/redo + confirm dialog + auto-snapshot"
Task 15: 遗留数据导入(legacyImport)+ E2E 冒烟脚本
Files:
- Create:
src/lib/legacyImport.ts - Create:
scripts/smoke.ts - Modify:
server.ts(启动时导入元数据标记) - Test:
src/__tests__/legacyImport.test.ts
Interfaces:
-
Consumes: Task 5 repos、Task 10 api、localStorage 现有
hypergraphV1键 -
Produces:
legacyImport.readLocalBackup(): {nodes: ConceptNode[]; hyphae: HyphaConnection[]} | null(读取 localStoragehypergraphV1,schema 校验)legacyImport.importToProject(repos, projectId, backup):批量graph.createNode+graph.createHypha,映射旧字段(旧 id 保留为metadata.legacyId),返回{imported: number}scripts/smoke.ts:tsx 运行——内存库建 22 表 → 建项目 → 建 3 节点 + 2 菌丝 + 1 菌丝结 → 软删/恢复 → 事件 seq 单调 → 打印SMOKE OK;失败process.exit(1)- package.json script
"smoke": "tsx scripts/smoke.ts"(Task 1 已加占位)
-
Step 1: 写失败测试
Create src/__tests__/legacyImport.test.ts:
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { readLocalBackup, importToProject } from '../lib/legacyImport';
beforeEach(() => { vi.restoreAllMocks(); });
describe('legacyImport', () => {
it('reads and validates localStorage backup', () => {
const backup = { nodes: [{ id: 'node_old_1', title: '旧节点', type: 'seed' }], hyphae: [] };
vi.stubGlobal('localStorage', { getItem: () => JSON.stringify(backup), setItem: vi.fn(), removeItem: vi.fn() });
const result = readLocalBackup();
expect(result?.nodes).toHaveLength(1);
expect(result?.nodes[0].title).toBe('旧节点');
});
it('returns null on invalid backup', () => {
vi.stubGlobal('localStorage', { getItem: () => 'not-json', setItem: vi.fn(), removeItem: vi.fn() });
expect(readLocalBackup()).toBeNull();
});
it('imports nodes preserving legacy ids', () => {
const backup = { nodes: [{ id: 'node_old_1', title: 'A', type: 'seed' }], hyphae: [] };
const graph = { createNode: vi.fn((_p: string, d: Record<string, unknown>) => ({ ...d, id: 'node_new_1' })) };
const res = importToProject(graph as never, 'proj_1', backup as never);
expect(res.imported).toBe(1);
expect(graph.createNode).toHaveBeenCalledWith('proj_1', expect.objectContaining({ metadata: expect.objectContaining({ legacyId: 'node_old_1' }) }));
});
});
- Step 2: 运行确认失败
Run: npx vitest run src/__tests__/legacyImport.test.ts
Expected: FAIL(../lib/legacyImport 不存在)。
- Step 3: 实现 legacyImport
Create src/lib/legacyImport.ts:
import type { ConceptNode, HyphaConnection } from '../types';
const STORAGE_KEY = 'hypergraphV1';
export interface LegacyBackup { nodes: Array<{ id: string; title: string; type?: string; [k: string]: unknown }>; hyphae: Array<{ id: string; sourceNodeId: string; targetNodeId: string; [k: string]: unknown }> }
export function readLocalBackup(): LegacyBackup | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as LegacyBackup;
if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.hyphae)) return null;
return parsed;
} catch { return null; }
}
export function importToProject(
graph: { createNode(projectId: string, data: Record<string, unknown>): unknown; createHypha(projectId: string, data: Record<string, unknown>): unknown },
projectId: string,
backup: LegacyBackup,
): { imported: number } {
const idMap = new Map<string, string>();
for (const n of backup.nodes) {
const created = graph.createNode(projectId, {
title: n.title, type: n.type ?? 'derived',
metadata: { legacyId: n.id },
}) as { id: string };
idMap.set(n.id, created.id);
}
for (const h of backup.hyphae) {
const src = idMap.get(h.sourceNodeId);
const dst = idMap.get(h.targetNodeId);
if (src && dst) graph.createHypha(projectId, { sourceNodeId: src, targetNodeId: dst, type: 'legacy' });
}
return { imported: backup.nodes.length };
}
- Step 4: 实现 smoke 脚本
Create scripts/smoke.ts:
import { openDb } from '../server/db/db';
import { createRepos } from '../server/db/repositories';
import { recordLegacyOperation } from '../server/lib/legacyBridge';
const FAILURES: string[] = [];
const expectOk = (label: string, cond: boolean) => {
if (!cond) FAILURES.push(label);
console.log(`${cond ? '✓' : '✗'} ${label}`);
};
async function main(): Promise<void> {
const db = openDb(':memory:');
const repos = createRepos(db);
// 1. 项目
const p = repos.projects.create({ name: 'smoke', rootSeedIdea: '测试种子', validationMode: 'hybrid', valueWeights: { survival: 0.2, cognitive: 0.2, aesthetic: 0.2, ethical: 0.2, transcendent: 0.2 }, privacyMode: false });
expectOk('project created', /^proj_/.test(p.id));
// 2. 3 节点 + 2 菌丝
const n1 = repos.graph.createNode(p.id, { title: 'N1', type: 'seed' });
const n2 = repos.graph.createNode(p.id, { title: 'N2', type: 'derived' });
const n3 = repos.graph.createNode(p.id, { title: 'N3', type: 'derived' });
repos.graph.createHypha(p.id, { sourceNodeId: n1.id, targetNodeId: n2.id, type: 'analogical' });
repos.graph.createHypha(p.id, { sourceNodeId: n2.id, targetNodeId: n3.id, type: 'causal' });
expectOk('3 nodes + 2 hyphae', repos.graph.listNodes(p.id).length === 3 && repos.graph.listHyphae(p.id).length === 2);
// 3. 菌丝结(≥3 成员)
const knot = repos.graph.createKnot(p.id, { memberNodeIds: [n1.id, n2.id, n3.id] });
expectOk('knot emerging', knot.status === 'emerging');
// 4. 软删 / 恢复
repos.graph.softDeleteNode(p.id, n2.id);
expectOk('soft delete hides node', repos.graph.listNodes(p.id).length === 2);
repos.graph.restoreNode(p.id, n2.id);
expectOk('restore brings node back', repos.graph.listNodes(p.id).length === 3);
// 5. 事件 seq 单调 + 操作记录
const e1 = repos.history.appendEvent(p.id, { eventType: 'smoke.a', entityType: 'node', payload: {}, priority: 0 });
const e2 = repos.history.appendEvent(p.id, { eventType: 'smoke.b', entityType: 'node', payload: {}, priority: 0 });
expectOk('event seq monotonic', e2.sequence === e1.sequence + 1);
recordLegacyOperation(repos, { projectId: p.id, operationType: 'fission', inputIds: [n1.id], outputIds: [n2.id], params: {}, status: 'done' });
const evts = repos.history.listEvents(p.id, { page: 1, pageSize: 50, offset: 0 });
expectOk('operation recorded to events', evts.rows.some((e) => e.eventType === 'operation.recorded'));
// 6. 快照
repos.history.createSnapshot(p.id, { snapshotType: 'auto', data: { nodes: [] }, reason: 'smoke' });
expectOk('snapshot created', repos.history.listSnapshots(p.id).length === 1);
db.close();
if (FAILURES.length > 0) {
console.error(`\nSMOKE FAILED (${FAILURES.length}): ${FAILURES.join('; ')}`);
process.exit(1);
}
console.log('\nSMOKE OK');
}
main().catch((err) => { console.error('SMOKE ERROR', err); process.exit(1); });
- Step 5: 修改 server.ts 启动导入标记
Edit server.ts:启动初始化段(openDb 后)加入:
import { metaRepo } from './server/db/repositories/metaRepo';
const meta = metaRepo(db);
const schemaVersion = meta.getMeta('schema_version');
if (schemaVersion == null) {
meta.setMeta('schema_version', 1);
meta.setMeta('initialized_at', new Date().toISOString());
console.log('[m1] database initialized with 22-table schema (v1)');
}
- Step 6: 运行验证
Run: npx vitest run src/__tests__/legacyImport.test.ts
Expected: PASS(3 个用例)。
Run: npm run smoke
Expected: 12 行 ✓ 输出,末尾 SMOKE OK,exit 0。
- Step 7: 全量测试回归
Run: npm test
Expected: 全部 PASS(Task 1-15 所有用例;已知 vitest 默认 environment 为 node,组件测试文件头已有 // @vitest-environment jsdom)。
- Step 8: Commit
git add src/lib/legacyImport.ts scripts/smoke.ts server.ts src/__tests__/legacyImport.test.ts
git commit -m "feat(m1): legacy localStorage import + E2E smoke script + schema meta init"
计划自审(writing-plans Self-Review)
1. Spec 覆盖核对(design 文档 §3.1-3.8 ↔ 任务表):
| Spec 要求 | 任务 |
|---|---|
| §3.1 技术栈(Zustand 5 stores) | Task 9 |
| §3.1 命令模式撤销栈 50 深度 | Task 9(undoStore MAX_DEPTH=50) |
| §3.1 软删除 30s 回收站 | Task 9/13/14(graveyard TTL 30s、Gallery 恢复窗、ConfirmDialog) |
| §3.2 SQLite 22 表全量 schema | Task 4(schema.sql 终态)+ Task 15 smoke 验证 |
| §3.2 追加式 events 表(sequence UNIQUE) | Task 4 触发器 + Task 5 historyRepo |
| §3.3 REST /api/v1 统一响应 | Task 6 |
| §3.3 SSE TaskManager | Task 7 + Task 10 消费端 |
| §3.4 事件溯源基座(P0-P5 优先级) | Task 5(priority 字段)+ Task 6 事件写入 |
| §3.5 设计令牌七大色彩域 | Task 11 |
| §3.5 深度滑块 0-100 三区 | Task 12(DepthSlider)+ Task 9 映射 + Task 11 depthMap |
| §3.5 响应式三档 | Task 12(viewport 侦听) |
| §3.5 向导 3 步 | Task 13 |
| §3.5 快捷键 | Task 12(useShortcuts) |
| §3.6 数据流(前端命令 → API → repos → 触发器) | Task 9/10/6/5 链路 |
| §3.7 types.ts 全量实体 | Task 2 |
| 现有代码兼容(server.ts/App/HypergraphCanvas 保留) | Task 8/12 适配说明 |
| §6 验收 1:npm test 全绿 | Task 15 Step 7 |
| §6 验收 2:22 表可建可查 | Task 4 schema.test + Task 15 smoke |
| §6 验收 3:undo/redo 50 深度 | Task 9 stores.test |
| §6 验收 4:30s 恢复 | Task 9/13 测试 |
| §6 验收 5:统一 API 格式 | Task 6 v1.test |
| §6 验收 6:SSE 任务流 | Task 7 llm.test |
| §6 验收 7:深度滑块元交互 | Task 12 layout.test |
2. Placeholder 扫描:已扫描——Task 7 的 runLlmBlock 注明确 M1 块级语义;Task 14 crashRecovery 明确为脚手架+节流写入说明;Task 9 serverStore 的 SSE 端点标注"P2 或自动降级轮询"。以上均为有实现路径的设计决策,非占位。
3. 类型一致性核对:
prefixedId('proj_'|'node_'|'hyph_'|'knot_'|'oper_'|'evnt_'|'snap_')—— Task 2 前缀表覆盖 ✓CommandTypeunion(Task 2)与applyCommandswitch 分支一致 ✓createV1Router(repos, db)签名在 Task 6 修正后与 Task 7/测试一致 ✓IMMERSION_THRESHOLDS(Task 11)与depthToImmersion(Task 9)阈值 30/70 一致 ✓api.history.createSnapshot返回Snapshot(Task 2 有该类型)✓interactionSpecs.systems.autoSnapshotOps=50(Task 11)与startAutoSnapshot默认 opsThreshold=50 ✓
已知偏差(执行时注意):
- Task 5
historyRepo.createSnapshot中有一处笔误表达式('vals_' === 'unused' ? ...),实现时应直接使用'snap_' + Math.random().toString(36).slice(2)(该行已标注修正)。 - Task 6 的
index.ts/history.ts含两版写法(草稿 + 修正),以"重要修正/实现约束"标注的终版为准。
540




被折叠的 条评论
为什么被折叠?



