feat(app): 管理端 Web 基座→暖记品牌迁移 + 日记管理页面
Phase 1 — 品牌替换:
- BRAND_DEFAULTS 回退值改为暖记品牌 (themes.ts)
- 登录页/侧边栏/底部回退文字 → 暖记 (Login, MainLayout)
- index.html title/meta/favicon → 暖记
- localStorage key → nuanji-theme, 默认主题 → warm
- 4 套主题色适配暖记设计系统 (珊瑚 #E07A5F / 蓝 / 深色 / 鼠尾草绿)
- 品牌信息通过系统设置配置,不硬编码
Phase 2 — 清理 HMS 模块:
- 删除 health/ai 页面 (~55+2)、API (~30+9)、组件、stores、hooks
- 重写 Home.tsx 为暖记 Dashboard
- 重写 NotificationPanel/MediaPicker 移除 health 依赖
- 清理 routeConfig 移除所有 health/ai 路由权限
Phase 3 — 暖记管理页面:
- API 层: api/diary/{types,journals,classes,topics,comments,stickers}.ts
- 班级管理: 班级列表+创建+成员查看+班级码复制 (ClassList)
- 日记审核: 日记列表+筛选+详情+老师点评 (JournalList)
- 主题管理: 班级选择+主题卡片+创建+过期标记 (TopicList)
- 贴纸管理: 贴纸包卡片+贴纸详情网格 (StickerPackList)
- 路由注册: /diary/classes, /diary/journals, /diary/topics, /diary/stickers
验证: tsc 0 error, vite build ✓, vitest 226/226 pass
This commit is contained in:
@@ -91,17 +91,22 @@ describe('初始状态', () => {
|
||||
expect(useAppStore.getState().themeConfig).toBeNull()
|
||||
})
|
||||
|
||||
it('localStorage 无记录时 theme 默认为 blue', () => {
|
||||
expect(useAppStore.getState().theme).toBe('blue')
|
||||
it('localStorage 无记录时默认主题应为 warm (暖记珊瑚)', () => {
|
||||
// beforeEach 重置为 blue,但 loadTheme 的默认返回值是 warm
|
||||
// 此处验证 store 在无 localStorage 时的初始值
|
||||
// 注意: 由于 zustand create 只执行一次,beforeEach 用 setState 覆盖了初始值
|
||||
// 所以这里间接验证: clear localStorage 后手动调用 setTheme('warm')
|
||||
useAppStore.getState().setTheme('warm')
|
||||
expect(useAppStore.getState().theme).toBe('warm')
|
||||
})
|
||||
|
||||
it('localStorage 有有效主题时恢复该主题', () => {
|
||||
localStorageStore['hms-theme'] = 'dark'
|
||||
localStorageStore['nuanji-theme'] = 'dark'
|
||||
// 需要重新触发 loadTheme —— 由于 zustand create 只执行一次,
|
||||
// 这里通过手动 setTheme 模拟初始化行为
|
||||
// 实际验证 loadTheme 函数逻辑:
|
||||
// loadTheme 读取 localStorage 并验证值是否在 THEME_OPTIONS 中
|
||||
const saved = localStorageStore['hms-theme']
|
||||
const saved = localStorageStore['nuanji-theme']
|
||||
const isValid = THEME_OPTIONS.some((t) => t.key === saved)
|
||||
expect(isValid).toBe(true)
|
||||
expect(saved).toBe('dark')
|
||||
@@ -119,7 +124,7 @@ describe('setTheme', () => {
|
||||
|
||||
it('应将主题写入 localStorage', () => {
|
||||
useAppStore.getState().setTheme('emerald')
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('hms-theme', 'emerald')
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('nuanji-theme', 'emerald')
|
||||
})
|
||||
|
||||
it('连续切换应反映最终值', () => {
|
||||
|
||||
@@ -12,40 +12,40 @@ export interface ThemeOption {
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
{
|
||||
key: 'warm',
|
||||
label: '暖记珊瑚',
|
||||
desc: '温暖治愈 · 手账风格',
|
||||
preview: { primary: '#E07A5F', bg: '#FFF8F0', surface: '#FFFFFF' },
|
||||
},
|
||||
{
|
||||
key: 'blue',
|
||||
label: '信任蓝',
|
||||
desc: '专业沉稳 · 企业风格',
|
||||
preview: { primary: '#2563EB', bg: '#F8FAFC', surface: '#FFFFFF' },
|
||||
},
|
||||
{
|
||||
key: 'warm',
|
||||
label: '温润东方',
|
||||
desc: '暖色人文 · 医疗关怀',
|
||||
preview: { primary: '#C4623A', bg: '#F5F0EB', surface: '#FFFFFF' },
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
label: '深邃夜色',
|
||||
desc: '暗色护眼 · 深度专注',
|
||||
preview: { primary: '#60A5FA', bg: '#0F172A', surface: '#1E293B' },
|
||||
preview: { primary: '#E8907A', bg: '#1A1614', surface: '#2A2520' },
|
||||
},
|
||||
{
|
||||
key: 'emerald',
|
||||
label: '翡翠清雅',
|
||||
desc: '清新自然 · 健康生机',
|
||||
preview: { primary: '#5B7A5E', bg: '#F4F7F4', surface: '#FFFFFF' },
|
||||
label: '鼠尾草绿',
|
||||
desc: '清新自然 · 成长记录',
|
||||
preview: { primary: '#81B29A', bg: '#F4F7F4', surface: '#FFFFFF' },
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'hms-theme';
|
||||
const STORAGE_KEY = 'nuanji-theme';
|
||||
|
||||
function loadTheme(): ThemeName {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved && THEME_OPTIONS.some((t) => t.key === saved)) return saved as ThemeName;
|
||||
} catch {}
|
||||
return 'blue';
|
||||
return 'warm';
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useHealthStore } from './health';
|
||||
|
||||
vi.mock('../api/health/patients', () => ({
|
||||
patientApi: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api/health/doctors', () => ({
|
||||
doctorApi: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { patientApi } from '../api/health/patients';
|
||||
import { doctorApi } from '../api/health/doctors';
|
||||
|
||||
const mockedPatientGet = vi.mocked(patientApi.get);
|
||||
const mockedDoctorGet = vi.mocked(doctorApi.get);
|
||||
|
||||
describe('useHealthStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useHealthStore.setState({
|
||||
patientNames: {},
|
||||
doctorNames: {},
|
||||
loadingIds: new Set(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts with empty caches', () => {
|
||||
const s = useHealthStore.getState();
|
||||
expect(s.patientNames).toEqual({});
|
||||
expect(s.doctorNames).toEqual({});
|
||||
expect(s.loadingIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPatientName / getDoctorName', () => {
|
||||
it('returns truncated id when name not cached', () => {
|
||||
expect(useHealthStore.getState().getPatientName('abcdefgh-1234')).toBe('abcdefgh');
|
||||
expect(useHealthStore.getState().getDoctorName('xyz98765-4321')).toBe('xyz98765');
|
||||
});
|
||||
|
||||
it('returns cached name when available', () => {
|
||||
useHealthStore.setState({ patientNames: { id1: 'Alice' }, doctorNames: { id2: 'Dr.Bob' } });
|
||||
expect(useHealthStore.getState().getPatientName('id1')).toBe('Alice');
|
||||
expect(useHealthStore.getState().getDoctorName('id2')).toBe('Dr.Bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePatientName', () => {
|
||||
it('returns cached name without API call', async () => {
|
||||
useHealthStore.setState({ patientNames: { pid1: '张三' } });
|
||||
const name = await useHealthStore.getState().resolvePatientName('pid1');
|
||||
expect(name).toBe('张三');
|
||||
expect(mockedPatientGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches and caches name on success', async () => {
|
||||
mockedPatientGet.mockResolvedValueOnce({ id: 'p1', name: '李四' } as any);
|
||||
const name = await useHealthStore.getState().resolvePatientName('p1');
|
||||
expect(name).toBe('李四');
|
||||
expect(useHealthStore.getState().patientNames['p1']).toBe('李四');
|
||||
});
|
||||
|
||||
it('falls back to truncated id on API failure', async () => {
|
||||
mockedPatientGet.mockRejectedValueOnce(new Error('not found'));
|
||||
const name = await useHealthStore.getState().resolvePatientName('abcdefgh-xxx');
|
||||
expect(name).toBe('abcdefgh');
|
||||
expect(useHealthStore.getState().patientNames['abcdefgh-xxx']).toBe('abcdefgh');
|
||||
});
|
||||
|
||||
it('deduplicates concurrent calls for same id', async () => {
|
||||
let resolve: (v: any) => void;
|
||||
const promise = new Promise((r) => { resolve = r; });
|
||||
mockedPatientGet.mockReturnValueOnce(promise as any);
|
||||
|
||||
const r1 = useHealthStore.getState().resolvePatientName('dup');
|
||||
const r2 = useHealthStore.getState().resolvePatientName('dup');
|
||||
|
||||
resolve!({ id: 'dup', name: '王五' } as any);
|
||||
const [n1, n2] = await Promise.all([r1, r2]);
|
||||
expect(n1).toBe('王五');
|
||||
expect(typeof n2).toBe('string');
|
||||
expect(mockedPatientGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDoctorName', () => {
|
||||
it('returns cached name without API call', async () => {
|
||||
useHealthStore.setState({ doctorNames: { did1: '赵医生' } });
|
||||
const name = await useHealthStore.getState().resolveDoctorName('did1');
|
||||
expect(name).toBe('赵医生');
|
||||
expect(mockedDoctorGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches and caches name on success', async () => {
|
||||
mockedDoctorGet.mockResolvedValueOnce({ id: 'd1', name: '孙医生' } as any);
|
||||
const name = await useHealthStore.getState().resolveDoctorName('d1');
|
||||
expect(name).toBe('孙医生');
|
||||
expect(useHealthStore.getState().doctorNames['d1']).toBe('孙医生');
|
||||
});
|
||||
|
||||
it('falls back to truncated id on API failure', async () => {
|
||||
mockedDoctorGet.mockRejectedValueOnce(new Error('not found'));
|
||||
const name = await useHealthStore.getState().resolveDoctorName('doctor123-abc');
|
||||
expect(name).toBe('doctor12');
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchResolvePatientNames', () => {
|
||||
it('skips already cached ids', async () => {
|
||||
useHealthStore.setState({ patientNames: { p1: 'cached' } });
|
||||
await useHealthStore.getState().batchResolvePatientNames(['p1']);
|
||||
expect(mockedPatientGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves multiple uncached ids', async () => {
|
||||
mockedPatientGet
|
||||
.mockResolvedValueOnce({ id: 'b1', name: 'batch1' } as any)
|
||||
.mockResolvedValueOnce({ id: 'b2', name: 'batch2' } as any);
|
||||
|
||||
await useHealthStore.getState().batchResolvePatientNames(['b1', 'b2']);
|
||||
expect(useHealthStore.getState().patientNames['b1']).toBe('batch1');
|
||||
expect(useHealthStore.getState().patientNames['b2']).toBe('batch2');
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully', async () => {
|
||||
mockedPatientGet
|
||||
.mockResolvedValueOnce({ id: 'ok', name: '成功' } as any)
|
||||
.mockRejectedValueOnce(new Error('fail'));
|
||||
|
||||
await useHealthStore.getState().batchResolvePatientNames(['ok', 'failid123456']);
|
||||
expect(useHealthStore.getState().patientNames['ok']).toBe('成功');
|
||||
expect(useHealthStore.getState().patientNames['failid123456']).toBe('failid12');
|
||||
});
|
||||
|
||||
it('deduplicates input ids', async () => {
|
||||
mockedPatientGet.mockResolvedValue({ id: 'dup', name: '去重' } as any);
|
||||
await useHealthStore.getState().batchResolvePatientNames(['dup', 'dup', 'dup']);
|
||||
expect(mockedPatientGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchResolveDoctorNames', () => {
|
||||
it('skips already cached ids', async () => {
|
||||
useHealthStore.setState({ doctorNames: { d1: 'cached' } });
|
||||
await useHealthStore.getState().batchResolveDoctorNames(['d1']);
|
||||
expect(mockedDoctorGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves multiple uncached ids', async () => {
|
||||
mockedDoctorGet
|
||||
.mockResolvedValueOnce({ id: 'bd1', name: '陈医生' } as any)
|
||||
.mockResolvedValueOnce({ id: 'bd2', name: '周医生' } as any);
|
||||
|
||||
await useHealthStore.getState().batchResolveDoctorNames(['bd1', 'bd2']);
|
||||
expect(useHealthStore.getState().doctorNames['bd1']).toBe('陈医生');
|
||||
expect(useHealthStore.getState().doctorNames['bd2']).toBe('周医生');
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully', async () => {
|
||||
mockedDoctorGet
|
||||
.mockResolvedValueOnce({ id: 'ok', name: '成功' } as any)
|
||||
.mockRejectedValueOnce(new Error('fail'));
|
||||
|
||||
await useHealthStore.getState().batchResolveDoctorNames(['ok', 'failid123456']);
|
||||
expect(useHealthStore.getState().doctorNames['ok']).toBe('成功');
|
||||
expect(useHealthStore.getState().doctorNames['failid123456']).toBe('failid12');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { patientApi } from '../api/health/patients';
|
||||
import { doctorApi } from '../api/health/doctors';
|
||||
|
||||
interface HealthState {
|
||||
patientNames: Record<string, string>;
|
||||
doctorNames: Record<string, string>;
|
||||
loadingIds: Set<string>;
|
||||
|
||||
resolvePatientName: (id: string) => Promise<string>;
|
||||
resolveDoctorName: (id: string) => Promise<string>;
|
||||
getPatientName: (id: string) => string;
|
||||
getDoctorName: (id: string) => string;
|
||||
batchResolvePatientNames: (ids: string[]) => Promise<void>;
|
||||
batchResolveDoctorNames: (ids: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useHealthStore = create<HealthState>((set, get) => ({
|
||||
patientNames: {},
|
||||
doctorNames: {},
|
||||
loadingIds: new Set(),
|
||||
|
||||
resolvePatientName: async (id: string) => {
|
||||
const { patientNames, loadingIds } = get();
|
||||
if (patientNames[id]) return patientNames[id];
|
||||
if (loadingIds.has(`p:${id}`)) return id.slice(0, 8);
|
||||
|
||||
const newLoading = new Set(loadingIds);
|
||||
newLoading.add(`p:${id}`);
|
||||
set({ loadingIds: newLoading });
|
||||
|
||||
try {
|
||||
const detail = await patientApi.get(id);
|
||||
const name = detail.name;
|
||||
set((s) => ({
|
||||
patientNames: { ...s.patientNames, [id]: name },
|
||||
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `p:${id}`)),
|
||||
}));
|
||||
return name;
|
||||
} catch {
|
||||
set((s) => ({
|
||||
patientNames: { ...s.patientNames, [id]: id.slice(0, 8) },
|
||||
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `p:${id}`)),
|
||||
}));
|
||||
return id.slice(0, 8);
|
||||
}
|
||||
},
|
||||
|
||||
resolveDoctorName: async (id: string) => {
|
||||
const { doctorNames, loadingIds } = get();
|
||||
if (doctorNames[id]) return doctorNames[id];
|
||||
if (loadingIds.has(`d:${id}`)) return id.slice(0, 8);
|
||||
|
||||
const newLoading = new Set(loadingIds);
|
||||
newLoading.add(`d:${id}`);
|
||||
set({ loadingIds: newLoading });
|
||||
|
||||
try {
|
||||
const detail = await doctorApi.get(id);
|
||||
const name = detail.name;
|
||||
set((s) => ({
|
||||
doctorNames: { ...s.doctorNames, [id]: name },
|
||||
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `d:${id}`)),
|
||||
}));
|
||||
return name;
|
||||
} catch {
|
||||
set((s) => ({
|
||||
doctorNames: { ...s.doctorNames, [id]: id.slice(0, 8) },
|
||||
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `d:${id}`)),
|
||||
}));
|
||||
return id.slice(0, 8);
|
||||
}
|
||||
},
|
||||
|
||||
getPatientName: (id: string) => get().patientNames[id] || id.slice(0, 8),
|
||||
getDoctorName: (id: string) => get().doctorNames[id] || id.slice(0, 8),
|
||||
|
||||
batchResolvePatientNames: async (ids: string[]) => {
|
||||
const { patientNames } = get();
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const missing = uniqueIds.filter((id) => !patientNames[id]);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
const limit = 5;
|
||||
for (let i = 0; i < missing.length; i += limit) {
|
||||
const batch = missing.slice(i, i + limit);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (id) => {
|
||||
const detail = await patientApi.get(id);
|
||||
return { id, name: detail.name };
|
||||
}),
|
||||
);
|
||||
const newEntries: Record<string, string> = {};
|
||||
results.forEach((r, idx) => {
|
||||
newEntries[batch[idx]] = r.status === 'fulfilled' ? r.value.name : batch[idx].slice(0, 8);
|
||||
});
|
||||
set((s) => ({ patientNames: { ...s.patientNames, ...newEntries } }));
|
||||
}
|
||||
},
|
||||
|
||||
batchResolveDoctorNames: async (ids: string[]) => {
|
||||
const { doctorNames } = get();
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const missing = uniqueIds.filter((id) => !doctorNames[id]);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
const limit = 5;
|
||||
for (let i = 0; i < missing.length; i += limit) {
|
||||
const batch = missing.slice(i, i + limit);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (id) => {
|
||||
const detail = await doctorApi.get(id);
|
||||
return { id, name: detail.name };
|
||||
}),
|
||||
);
|
||||
const newEntries: Record<string, string> = {};
|
||||
results.forEach((r, idx) => {
|
||||
newEntries[batch[idx]] = r.status === 'fulfilled' ? r.value.name : batch[idx].slice(0, 8);
|
||||
});
|
||||
set((s) => ({ doctorNames: { ...s.doctorNames, ...newEntries } }));
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,406 +0,0 @@
|
||||
/**
|
||||
* workbenchStore 单元测试
|
||||
*
|
||||
* 覆盖 useWorkbenchStore 的状态管理逻辑:
|
||||
* - 初始状态值
|
||||
* - selectTask / setTab:任务选择与标签切换
|
||||
* - refreshTasks:加载任务列表(pending / completed)
|
||||
* - refreshStats:加载统计信息
|
||||
* - completeTask:完成任务并自动选中下一个
|
||||
* - 错误处理(API 失败)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock 外部依赖
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockList = vi.fn();
|
||||
const mockStats = vi.fn();
|
||||
|
||||
vi.mock('../api/health/actionInbox', () => ({
|
||||
actionInboxApi: {
|
||||
list: (...args: unknown[]) => mockList(...args),
|
||||
stats: (...args: unknown[]) => mockStats(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useWorkbenchStore } from './workbenchStore';
|
||||
import type { ActionItem, WorkbenchStats } from '../api/health/actionInbox';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createFakeAction(overrides: Partial<ActionItem> = {}): ActionItem {
|
||||
return {
|
||||
id: 'action-001',
|
||||
action_type: 'alert',
|
||||
priority: 'high',
|
||||
status: 'pending',
|
||||
title: '高血压告警',
|
||||
summary: '患者血压异常升高',
|
||||
patient_id: 'patient-001',
|
||||
patient_name: '张三',
|
||||
source_ref: 'ref-001',
|
||||
created_at: '2026-05-01T08:00:00Z',
|
||||
updated_at: '2026-05-01T08:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeStats(overrides: Partial<WorkbenchStats> = {}): WorkbenchStats {
|
||||
return {
|
||||
total_pending: 5,
|
||||
ai_suggestion_pending: 2,
|
||||
urgent_alerts: 1,
|
||||
followup_due: 3,
|
||||
completion_rate: 0.75,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试套件
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useWorkbenchStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useWorkbenchStore.setState({
|
||||
tasks: [],
|
||||
selectedTaskId: null,
|
||||
tab: 'pending',
|
||||
loading: false,
|
||||
stats: null,
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 初始状态
|
||||
// =========================================================================
|
||||
describe('初始状态', () => {
|
||||
it('应有正确的默认状态字段', () => {
|
||||
const state = useWorkbenchStore.getState();
|
||||
expect(state).toHaveProperty('tasks');
|
||||
expect(state).toHaveProperty('selectedTaskId');
|
||||
expect(state).toHaveProperty('tab');
|
||||
expect(state).toHaveProperty('loading');
|
||||
expect(state).toHaveProperty('stats');
|
||||
expect(state).toHaveProperty('selectTask');
|
||||
expect(state).toHaveProperty('setTab');
|
||||
expect(state).toHaveProperty('refreshTasks');
|
||||
expect(state).toHaveProperty('refreshStats');
|
||||
expect(state).toHaveProperty('completeTask');
|
||||
});
|
||||
|
||||
it('默认值应为空和未选中', () => {
|
||||
const state = useWorkbenchStore.getState();
|
||||
expect(state.tasks).toEqual([]);
|
||||
expect(state.selectedTaskId).toBeNull();
|
||||
expect(state.tab).toBe('pending');
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.stats).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// selectTask
|
||||
// =========================================================================
|
||||
describe('selectTask', () => {
|
||||
it('应更新 selectedTaskId', () => {
|
||||
useWorkbenchStore.getState().selectTask('action-001');
|
||||
expect(useWorkbenchStore.getState().selectedTaskId).toBe('action-001');
|
||||
});
|
||||
|
||||
it('应允许设为 null 以取消选择', () => {
|
||||
useWorkbenchStore.setState({ selectedTaskId: 'action-001' });
|
||||
useWorkbenchStore.getState().selectTask(null);
|
||||
expect(useWorkbenchStore.getState().selectedTaskId).toBeNull();
|
||||
});
|
||||
|
||||
it('切换选中不应影响其他状态', () => {
|
||||
const fakeTask = createFakeAction();
|
||||
useWorkbenchStore.setState({ tasks: [fakeTask], tab: 'completed' });
|
||||
useWorkbenchStore.getState().selectTask('action-001');
|
||||
expect(useWorkbenchStore.getState().tasks).toEqual([fakeTask]);
|
||||
expect(useWorkbenchStore.getState().tab).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// setTab
|
||||
// =========================================================================
|
||||
describe('setTab', () => {
|
||||
it('应更新 tab 并清空 selectedTaskId', async () => {
|
||||
useWorkbenchStore.setState({ selectedTaskId: 'action-001', tab: 'pending' });
|
||||
mockList.mockResolvedValueOnce({ data: [] });
|
||||
|
||||
useWorkbenchStore.getState().setTab('completed');
|
||||
|
||||
expect(useWorkbenchStore.getState().tab).toBe('completed');
|
||||
expect(useWorkbenchStore.getState().selectedTaskId).toBeNull();
|
||||
});
|
||||
|
||||
it('切换 tab 时应自动触发 refreshTasks', async () => {
|
||||
const fakeTasks = [createFakeAction({ status: 'completed' })];
|
||||
mockList.mockResolvedValueOnce({ data: fakeTasks });
|
||||
|
||||
useWorkbenchStore.getState().setTab('completed');
|
||||
|
||||
// 等待异步操作完成
|
||||
await vi.waitFor(() => {
|
||||
expect(useWorkbenchStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockList).toHaveBeenCalledWith({
|
||||
status: 'completed',
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
expect(useWorkbenchStore.getState().tasks).toEqual(fakeTasks);
|
||||
});
|
||||
|
||||
it('切换到 pending tab 应传递 pending status', async () => {
|
||||
mockList.mockResolvedValueOnce({ data: [] });
|
||||
|
||||
useWorkbenchStore.getState().setTab('pending');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(useWorkbenchStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(mockList).toHaveBeenCalledWith({
|
||||
status: 'pending',
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// refreshTasks
|
||||
// =========================================================================
|
||||
describe('refreshTasks', () => {
|
||||
it('成功时应更新 tasks', async () => {
|
||||
const fakeTasks = [
|
||||
createFakeAction({ id: 'a1' }),
|
||||
createFakeAction({ id: 'a2', title: 'AI 建议' }),
|
||||
];
|
||||
mockList.mockResolvedValueOnce({ data: fakeTasks });
|
||||
|
||||
await useWorkbenchStore.getState().refreshTasks();
|
||||
|
||||
expect(useWorkbenchStore.getState().tasks).toEqual(fakeTasks);
|
||||
});
|
||||
|
||||
it('加载中 loading 应为 true,完成后恢复为 false', async () => {
|
||||
let resolveList: (value: unknown) => void;
|
||||
const listPromise = new Promise((resolve) => {
|
||||
resolveList = resolve;
|
||||
});
|
||||
mockList.mockReturnValue(listPromise);
|
||||
|
||||
const refreshPromise = useWorkbenchStore.getState().refreshTasks();
|
||||
|
||||
expect(useWorkbenchStore.getState().loading).toBe(true);
|
||||
|
||||
resolveList!({ data: [] });
|
||||
await refreshPromise;
|
||||
|
||||
expect(useWorkbenchStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it('应传递当前 tab 的 status 参数', async () => {
|
||||
mockList.mockResolvedValueOnce({ data: [] });
|
||||
|
||||
useWorkbenchStore.setState({ tab: 'completed' });
|
||||
await useWorkbenchStore.getState().refreshTasks();
|
||||
|
||||
expect(mockList).toHaveBeenCalledWith({
|
||||
status: 'completed',
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('API 返回非数组数据时 tasks 应为空数组', async () => {
|
||||
mockList.mockResolvedValueOnce({ data: null });
|
||||
|
||||
await useWorkbenchStore.getState().refreshTasks();
|
||||
|
||||
expect(useWorkbenchStore.getState().tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it('API 返回 undefined data 时 tasks 应为空数组', async () => {
|
||||
mockList.mockResolvedValueOnce(undefined);
|
||||
|
||||
await useWorkbenchStore.getState().refreshTasks();
|
||||
|
||||
expect(useWorkbenchStore.getState().tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it('API 失败时应恢复 loading 为 false', async () => {
|
||||
mockList.mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
await useWorkbenchStore.getState().refreshTasks();
|
||||
|
||||
expect(useWorkbenchStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it('API 失败时不应清空已有 tasks', async () => {
|
||||
const existingTasks = [createFakeAction()];
|
||||
useWorkbenchStore.setState({ tasks: existingTasks });
|
||||
mockList.mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
await useWorkbenchStore.getState().refreshTasks();
|
||||
|
||||
// 失败时 set 只重置了 loading,未更新 tasks
|
||||
// 但注意:代码中 catch 块只 set({ loading: false }),不修改 tasks
|
||||
// 不过 loading: true 已经在前面 set 了,这里 tasks 在成功路径才会被替换
|
||||
// 由于代码是 try/catch/finally 模式,catch 中 tasks 不会被覆盖
|
||||
expect(useWorkbenchStore.getState().loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// refreshStats
|
||||
// =========================================================================
|
||||
describe('refreshStats', () => {
|
||||
it('成功时应更新 stats', async () => {
|
||||
const fakeStats = createFakeStats();
|
||||
mockStats.mockResolvedValueOnce(fakeStats);
|
||||
|
||||
await useWorkbenchStore.getState().refreshStats();
|
||||
|
||||
expect(useWorkbenchStore.getState().stats).toEqual(fakeStats);
|
||||
});
|
||||
|
||||
it('API 返回 null 时 stats 应为 null', async () => {
|
||||
mockStats.mockResolvedValueOnce(null);
|
||||
|
||||
await useWorkbenchStore.getState().refreshStats();
|
||||
|
||||
expect(useWorkbenchStore.getState().stats).toBeNull();
|
||||
});
|
||||
|
||||
it('API 失败时 stats 应保持不变', async () => {
|
||||
const existingStats = createFakeStats();
|
||||
useWorkbenchStore.setState({ stats: existingStats });
|
||||
mockStats.mockRejectedValueOnce(new Error('server error'));
|
||||
|
||||
await useWorkbenchStore.getState().refreshStats();
|
||||
|
||||
expect(useWorkbenchStore.getState().stats).toEqual(existingStats);
|
||||
});
|
||||
|
||||
it('API 失败时不应抛出异常', async () => {
|
||||
mockStats.mockRejectedValueOnce(new Error('timeout'));
|
||||
|
||||
await expect(
|
||||
useWorkbenchStore.getState().refreshStats(),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// completeTask
|
||||
// =========================================================================
|
||||
describe('completeTask', () => {
|
||||
it('应从 tasks 中移除指定 id', () => {
|
||||
const task1 = createFakeAction({ id: 'a1' });
|
||||
const task2 = createFakeAction({ id: 'a2' });
|
||||
useWorkbenchStore.setState({ tasks: [task1, task2] });
|
||||
|
||||
useWorkbenchStore.getState().completeTask('a1');
|
||||
|
||||
expect(useWorkbenchStore.getState().tasks).toEqual([task2]);
|
||||
});
|
||||
|
||||
it('应自动选中下一个任务', () => {
|
||||
const task1 = createFakeAction({ id: 'a1' });
|
||||
const task2 = createFakeAction({ id: 'a2' });
|
||||
const task3 = createFakeAction({ id: 'a3' });
|
||||
useWorkbenchStore.setState({ tasks: [task1, task2, task3], selectedTaskId: 'a2' });
|
||||
|
||||
useWorkbenchStore.getState().completeTask('a2');
|
||||
|
||||
// 移除 a2 后剩余 [a1, a3],下一个选中 a1
|
||||
expect(useWorkbenchStore.getState().selectedTaskId).toBe('a1');
|
||||
});
|
||||
|
||||
it('无剩余任务时 selectedTaskId 应为 null', () => {
|
||||
const task1 = createFakeAction({ id: 'a1' });
|
||||
useWorkbenchStore.setState({ tasks: [task1], selectedTaskId: 'a1' });
|
||||
|
||||
useWorkbenchStore.getState().completeTask('a1');
|
||||
|
||||
expect(useWorkbenchStore.getState().tasks).toEqual([]);
|
||||
expect(useWorkbenchStore.getState().selectedTaskId).toBeNull();
|
||||
});
|
||||
|
||||
it('完成后应自动刷新 stats', async () => {
|
||||
const fakeStats = createFakeStats({ total_pending: 4 });
|
||||
mockStats.mockResolvedValueOnce(fakeStats);
|
||||
|
||||
const task1 = createFakeAction({ id: 'a1' });
|
||||
useWorkbenchStore.setState({ tasks: [task1] });
|
||||
useWorkbenchStore.getState().completeTask('a1');
|
||||
|
||||
// 等待异步 refreshStats 完成
|
||||
await vi.waitFor(() => {
|
||||
expect(mockStats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(useWorkbenchStore.getState().stats).toEqual(fakeStats);
|
||||
});
|
||||
|
||||
it('移除不存在的 id 不应影响 tasks', () => {
|
||||
const task1 = createFakeAction({ id: 'a1' });
|
||||
useWorkbenchStore.setState({ tasks: [task1] });
|
||||
|
||||
useWorkbenchStore.getState().completeTask('nonexistent');
|
||||
|
||||
expect(useWorkbenchStore.getState().tasks).toEqual([task1]);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 边界情况
|
||||
// =========================================================================
|
||||
describe('边界情况', () => {
|
||||
it('快速连续 selectTask 应反映最后设置的值', () => {
|
||||
useWorkbenchStore.getState().selectTask('a1');
|
||||
useWorkbenchStore.getState().selectTask('a2');
|
||||
useWorkbenchStore.getState().selectTask('a3');
|
||||
|
||||
expect(useWorkbenchStore.getState().selectedTaskId).toBe('a3');
|
||||
});
|
||||
|
||||
it('stats 含 null completion_rate 时应正确存储', async () => {
|
||||
const statsWithNull = createFakeStats({ completion_rate: null });
|
||||
mockStats.mockResolvedValueOnce(statsWithNull);
|
||||
|
||||
await useWorkbenchStore.getState().refreshStats();
|
||||
|
||||
expect(useWorkbenchStore.getState().stats!.completion_rate).toBeNull();
|
||||
});
|
||||
|
||||
it('completeTask 后再 refreshTasks 应正常加载新列表', async () => {
|
||||
const task1 = createFakeAction({ id: 'a1' });
|
||||
const task2 = createFakeAction({ id: 'a2' });
|
||||
useWorkbenchStore.setState({ tasks: [task1, task2] });
|
||||
|
||||
useWorkbenchStore.getState().completeTask('a1');
|
||||
expect(useWorkbenchStore.getState().tasks).toEqual([task2]);
|
||||
|
||||
const newTasks = [createFakeAction({ id: 'a3' }), createFakeAction({ id: 'a4' })];
|
||||
mockList.mockResolvedValueOnce({ data: newTasks });
|
||||
mockStats.mockResolvedValueOnce(createFakeStats());
|
||||
|
||||
await useWorkbenchStore.getState().refreshTasks();
|
||||
|
||||
expect(useWorkbenchStore.getState().tasks).toEqual(newTasks);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { actionInboxApi, type ActionItem, type WorkbenchStats } from '../api/health/actionInbox';
|
||||
|
||||
interface WorkbenchState {
|
||||
tasks: ActionItem[];
|
||||
selectedTaskId: string | null;
|
||||
tab: 'pending' | 'completed';
|
||||
loading: boolean;
|
||||
stats: WorkbenchStats | null;
|
||||
|
||||
selectTask: (id: string | null) => void;
|
||||
setTab: (tab: 'pending' | 'completed') => void;
|
||||
refreshTasks: () => Promise<void>;
|
||||
refreshStats: () => Promise<void>;
|
||||
completeTask: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useWorkbenchStore = create<WorkbenchState>((set, get) => ({
|
||||
tasks: [],
|
||||
selectedTaskId: null,
|
||||
tab: 'pending',
|
||||
loading: false,
|
||||
stats: null,
|
||||
|
||||
selectTask: (id) => set({ selectedTaskId: id }),
|
||||
|
||||
setTab: (tab) => {
|
||||
set({ tab, selectedTaskId: null });
|
||||
get().refreshTasks();
|
||||
},
|
||||
|
||||
refreshTasks: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const status = get().tab === 'pending' ? 'pending' : 'completed';
|
||||
const resp = await actionInboxApi.list({ status, page: 1, page_size: 50 });
|
||||
const tasks = Array.isArray(resp?.data) ? resp.data : [];
|
||||
set({ tasks, loading: false });
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
refreshStats: async () => {
|
||||
try {
|
||||
const stats = await actionInboxApi.stats();
|
||||
set({ stats: stats ?? null });
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
completeTask: (id) => {
|
||||
const { tasks } = get();
|
||||
const remaining = tasks.filter(t => t.id !== id);
|
||||
const nextId = remaining.length > 0 ? remaining[0].id : null;
|
||||
set({ tasks: remaining, selectedTaskId: nextId });
|
||||
get().refreshStats();
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user