feat: 添加管理端前端 (HMS 基座 React 管理面板)
- 从 HMS 基座复制 apps/web/ (React + Ant Design + Vite + TypeScript) - 管理端自动代理 API 到 localhost:3000 (vite.config.ts) - 更新 scripts/dev.sh 支持三端启动: backend/admin/app - 登录验证通过, 用户管理/角色权限/审计日志等页面正常 - 添加 .gitignore 排除 node_modules/dist
This commit is contained in:
261
apps/web/src/stores/app.test.ts
Normal file
261
apps/web/src/stores/app.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* App Store 单元测试
|
||||
*
|
||||
* 覆盖 useAppStore 的状态管理逻辑:
|
||||
* - 初始状态值
|
||||
* - 主题切换与持久化
|
||||
* - 侧边栏折叠状态
|
||||
* - 远程主题配置加载
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// --- Mock localStorage ---
|
||||
const localStorageStore: Record<string, string> = {}
|
||||
|
||||
const mockLocalStorage = {
|
||||
getItem: vi.fn((key: string) => localStorageStore[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageStore[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageStore[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k])
|
||||
}),
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// --- Mock getTheme API ---
|
||||
const mockGetTheme = vi.fn()
|
||||
vi.mock('../api/themes', () => ({
|
||||
getTheme: (...args: unknown[]) => mockGetTheme(...args),
|
||||
}))
|
||||
|
||||
// --- Mock zustand 内部不依赖真实存储 ---
|
||||
// 在 mock 生效后导入被测模块
|
||||
import { useAppStore, THEME_OPTIONS } from './app'
|
||||
import type { ThemeName } from './app'
|
||||
import type { ThemeConfig } from '../api/themes'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLocalStorage.clear()
|
||||
// zustand store 每次需要重新创建状态,我们通过 setState 重置
|
||||
useAppStore.setState({
|
||||
theme: 'blue',
|
||||
sidebarCollapsed: false,
|
||||
themeConfig: null,
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// THEME_OPTIONS 常量
|
||||
// ============================================================
|
||||
describe('THEME_OPTIONS', () => {
|
||||
it('应包含 4 个主题选项', () => {
|
||||
expect(THEME_OPTIONS).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('所有选项包含 key / label / desc / preview', () => {
|
||||
for (const opt of THEME_OPTIONS) {
|
||||
expect(opt).toHaveProperty('key')
|
||||
expect(opt).toHaveProperty('label')
|
||||
expect(opt).toHaveProperty('desc')
|
||||
expect(opt).toHaveProperty('preview')
|
||||
expect(opt.preview).toHaveProperty('primary')
|
||||
expect(opt.preview).toHaveProperty('bg')
|
||||
expect(opt.preview).toHaveProperty('surface')
|
||||
}
|
||||
})
|
||||
|
||||
it('key 值互不重复', () => {
|
||||
const keys = THEME_OPTIONS.map((t) => t.key)
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 初始状态
|
||||
// ============================================================
|
||||
describe('初始状态', () => {
|
||||
it('sidebarCollapsed 默认为 false', () => {
|
||||
expect(useAppStore.getState().sidebarCollapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('themeConfig 默认为 null', () => {
|
||||
expect(useAppStore.getState().themeConfig).toBeNull()
|
||||
})
|
||||
|
||||
it('localStorage 无记录时 theme 默认为 blue', () => {
|
||||
expect(useAppStore.getState().theme).toBe('blue')
|
||||
})
|
||||
|
||||
it('localStorage 有有效主题时恢复该主题', () => {
|
||||
localStorageStore['hms-theme'] = 'dark'
|
||||
// 需要重新触发 loadTheme —— 由于 zustand create 只执行一次,
|
||||
// 这里通过手动 setTheme 模拟初始化行为
|
||||
// 实际验证 loadTheme 函数逻辑:
|
||||
// loadTheme 读取 localStorage 并验证值是否在 THEME_OPTIONS 中
|
||||
const saved = localStorageStore['hms-theme']
|
||||
const isValid = THEME_OPTIONS.some((t) => t.key === saved)
|
||||
expect(isValid).toBe(true)
|
||||
expect(saved).toBe('dark')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// setTheme — 主题切换与持久化
|
||||
// ============================================================
|
||||
describe('setTheme', () => {
|
||||
it('应更新 theme 状态', () => {
|
||||
useAppStore.getState().setTheme('warm')
|
||||
expect(useAppStore.getState().theme).toBe('warm')
|
||||
})
|
||||
|
||||
it('应将主题写入 localStorage', () => {
|
||||
useAppStore.getState().setTheme('emerald')
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('hms-theme', 'emerald')
|
||||
})
|
||||
|
||||
it('连续切换应反映最终值', () => {
|
||||
const { setTheme } = useAppStore.getState()
|
||||
setTheme('dark')
|
||||
setTheme('warm')
|
||||
expect(useAppStore.getState().theme).toBe('warm')
|
||||
})
|
||||
|
||||
it('切换到每个有效主题名都应成功', () => {
|
||||
const validThemes: ThemeName[] = ['blue', 'warm', 'dark', 'emerald']
|
||||
for (const t of validThemes) {
|
||||
useAppStore.getState().setTheme(t)
|
||||
expect(useAppStore.getState().theme).toBe(t)
|
||||
}
|
||||
})
|
||||
|
||||
it('localStorage 写入失败时不应抛出异常', () => {
|
||||
mockLocalStorage.setItem.mockImplementationOnce(() => {
|
||||
throw new Error('QuotaExceededError')
|
||||
})
|
||||
expect(() => useAppStore.getState().setTheme('dark')).not.toThrow()
|
||||
// 状态仍然更新(内存中生效)
|
||||
expect(useAppStore.getState().theme).toBe('dark')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// toggleSidebar — 侧边栏折叠切换
|
||||
// ============================================================
|
||||
describe('toggleSidebar', () => {
|
||||
it('应从 false 切换到 true', () => {
|
||||
expect(useAppStore.getState().sidebarCollapsed).toBe(false)
|
||||
useAppStore.getState().toggleSidebar()
|
||||
expect(useAppStore.getState().sidebarCollapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('应从 true 切换回 false', () => {
|
||||
useAppStore.setState({ sidebarCollapsed: true })
|
||||
useAppStore.getState().toggleSidebar()
|
||||
expect(useAppStore.getState().sidebarCollapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('连续切换应交替变化', () => {
|
||||
const { toggleSidebar } = useAppStore.getState()
|
||||
expect(useAppStore.getState().sidebarCollapsed).toBe(false)
|
||||
|
||||
toggleSidebar()
|
||||
expect(useAppStore.getState().sidebarCollapsed).toBe(true)
|
||||
|
||||
toggleSidebar()
|
||||
expect(useAppStore.getState().sidebarCollapsed).toBe(false)
|
||||
|
||||
toggleSidebar()
|
||||
expect(useAppStore.getState().sidebarCollapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('不应影响其他状态字段', () => {
|
||||
useAppStore.getState().setTheme('warm')
|
||||
const themeBefore = useAppStore.getState().theme
|
||||
const configBefore = useAppStore.getState().themeConfig
|
||||
|
||||
useAppStore.getState().toggleSidebar()
|
||||
|
||||
expect(useAppStore.getState().theme).toBe(themeBefore)
|
||||
expect(useAppStore.getState().themeConfig).toBe(configBefore)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// loadThemeConfig — 远程主题配置加载
|
||||
// ============================================================
|
||||
describe('loadThemeConfig', () => {
|
||||
const fakeConfig: ThemeConfig = {
|
||||
primary_color: '#C4623A',
|
||||
logo_url: '/logo.png',
|
||||
sidebar_style: 'dark',
|
||||
brand_name: '测试机构',
|
||||
brand_slogan: '测试标语',
|
||||
}
|
||||
|
||||
it('成功时应设置 themeConfig', async () => {
|
||||
mockGetTheme.mockResolvedValueOnce(fakeConfig)
|
||||
await useAppStore.getState().loadThemeConfig()
|
||||
expect(useAppStore.getState().themeConfig).toEqual(fakeConfig)
|
||||
})
|
||||
|
||||
it('调用 getTheme API 一次', async () => {
|
||||
mockGetTheme.mockResolvedValueOnce(fakeConfig)
|
||||
await useAppStore.getState().loadThemeConfig()
|
||||
expect(mockGetTheme).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('API 失败时不应修改 themeConfig(保持 null)', async () => {
|
||||
mockGetTheme.mockRejectedValueOnce(new Error('Network error'))
|
||||
await useAppStore.getState().loadThemeConfig()
|
||||
expect(useAppStore.getState().themeConfig).toBeNull()
|
||||
})
|
||||
|
||||
it('API 失败时不应抛出异常', async () => {
|
||||
mockGetTheme.mockRejectedValueOnce(new Error('Server error'))
|
||||
await expect(useAppStore.getState().loadThemeConfig()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('不应影响 theme 和 sidebarCollapsed', async () => {
|
||||
useAppStore.getState().setTheme('emerald')
|
||||
useAppStore.getState().toggleSidebar()
|
||||
|
||||
mockGetTheme.mockResolvedValueOnce(fakeConfig)
|
||||
await useAppStore.getState().loadThemeConfig()
|
||||
|
||||
expect(useAppStore.getState().theme).toBe('emerald')
|
||||
expect(useAppStore.getState().sidebarCollapsed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 状态隔离 — 多次操作互不干扰
|
||||
// ============================================================
|
||||
describe('状态隔离', () => {
|
||||
it('主题切换不应影响侧边栏', () => {
|
||||
useAppStore.setState({ sidebarCollapsed: true })
|
||||
useAppStore.getState().setTheme('dark')
|
||||
expect(useAppStore.getState().sidebarCollapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('侧边栏切换不应影响主题', () => {
|
||||
useAppStore.getState().setTheme('warm')
|
||||
useAppStore.getState().toggleSidebar()
|
||||
expect(useAppStore.getState().theme).toBe('warm')
|
||||
})
|
||||
|
||||
it('加载配置不应重置已有主题', async () => {
|
||||
useAppStore.getState().setTheme('emerald')
|
||||
mockGetTheme.mockResolvedValueOnce({ primary_color: '#000' })
|
||||
await useAppStore.getState().loadThemeConfig()
|
||||
expect(useAppStore.getState().theme).toBe('emerald')
|
||||
})
|
||||
})
|
||||
75
apps/web/src/stores/app.ts
Normal file
75
apps/web/src/stores/app.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { create } from 'zustand';
|
||||
import type { ThemeConfig } from '../api/themes';
|
||||
import { getTheme } from '../api/themes';
|
||||
|
||||
export type ThemeName = 'blue' | 'warm' | 'dark' | 'emerald';
|
||||
|
||||
export interface ThemeOption {
|
||||
key: ThemeName;
|
||||
label: string;
|
||||
desc: string;
|
||||
preview: { primary: string; bg: string; surface: string };
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
{
|
||||
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' },
|
||||
},
|
||||
{
|
||||
key: 'emerald',
|
||||
label: '翡翠清雅',
|
||||
desc: '清新自然 · 健康生机',
|
||||
preview: { primary: '#5B7A5E', bg: '#F4F7F4', surface: '#FFFFFF' },
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'hms-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';
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
theme: ThemeName;
|
||||
sidebarCollapsed: boolean;
|
||||
themeConfig: ThemeConfig | null;
|
||||
toggleSidebar: () => void;
|
||||
setTheme: (theme: ThemeName) => void;
|
||||
loadThemeConfig: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
theme: loadTheme(),
|
||||
sidebarCollapsed: false,
|
||||
themeConfig: null,
|
||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||
setTheme: (theme) => {
|
||||
try { localStorage.setItem(STORAGE_KEY, theme); } catch {}
|
||||
set({ theme });
|
||||
},
|
||||
loadThemeConfig: async () => {
|
||||
try {
|
||||
const config = await getTheme();
|
||||
set({ themeConfig: config });
|
||||
} catch {}
|
||||
},
|
||||
}));
|
||||
490
apps/web/src/stores/auth.test.ts
Normal file
490
apps/web/src/stores/auth.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* auth store 单元测试
|
||||
*
|
||||
* 覆盖:初始状态、登录/登出流程、权限提取、localStorage 持久化、状态重置。
|
||||
* 所有外部依赖(API 调用、localStorage)均被 mock。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock 外部依赖(必须在 import 被测模块之前)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mock localStorage — auth.ts 模块顶层直接读取 localStorage,
|
||||
// 必须在 vi.mock 工厂中提供实现,否则 atob/jwt 解码会失败。
|
||||
const localStorageStore: Record<string, string> = {};
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn((key: string) => localStorageStore[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageStore[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageStore[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(localStorageStore).length;
|
||||
},
|
||||
key: vi.fn((index: number) => Object.keys(localStorageStore)[index] ?? null),
|
||||
});
|
||||
|
||||
// Mock auth API
|
||||
const mockApiLogin = vi.fn();
|
||||
const mockApiLogout = vi.fn();
|
||||
|
||||
vi.mock('../api/auth', () => ({
|
||||
login: (...args: unknown[]) => mockApiLogin(...args),
|
||||
logout: (...args: unknown[]) => mockApiLogout(...args),
|
||||
}));
|
||||
|
||||
// Mock clearApiCache
|
||||
const mockClearApiCache = vi.fn();
|
||||
vi.mock('../api/client', () => ({
|
||||
clearApiCache: (...args: unknown[]) => mockClearApiCache(...args),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 在 mock 生效后导入被测模块
|
||||
// ---------------------------------------------------------------------------
|
||||
import { useAuthStore } from './auth';
|
||||
import type { UserInfo, LoginResponse } from '../api/auth';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 生成一个包含 permissions 数组的 JWT payload 并编码为合法 token */
|
||||
function createFakeToken(permissions: string[], extra: Record<string, unknown> = {}): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const payload = btoa(JSON.stringify({ permissions, ...extra }))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
const signature = btoa('fake-signature');
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
function createFakeUser(overrides: Partial<UserInfo> = {}): UserInfo {
|
||||
return {
|
||||
id: 'user-001',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
phone: '13800138000',
|
||||
display_name: '测试用户',
|
||||
avatar_url: undefined,
|
||||
status: 'active',
|
||||
roles: [
|
||||
{ id: 'role-001', name: '管理员', code: 'admin', is_system: true },
|
||||
],
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeLoginResponse(overrides: Partial<LoginResponse> = {}): LoginResponse {
|
||||
return {
|
||||
access_token: createFakeToken(['health.patient.list', 'health.alerts.manage']),
|
||||
refresh_token: 'refresh-token-xxx',
|
||||
expires_in: 3600,
|
||||
user: createFakeUser(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试套件
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
// 重置所有 mock 调用记录
|
||||
vi.clearAllMocks();
|
||||
// 清空 localStorage 存储和 store 状态
|
||||
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
|
||||
// 将 store 状态重置为未认证
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
permissions: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 初始状态
|
||||
// =========================================================================
|
||||
describe('初始状态', () => {
|
||||
it('应有正确的默认状态字段', () => {
|
||||
const state = useAuthStore.getState();
|
||||
expect(state).toHaveProperty('user');
|
||||
expect(state).toHaveProperty('isAuthenticated');
|
||||
expect(state).toHaveProperty('loading');
|
||||
expect(state).toHaveProperty('permissions');
|
||||
expect(state).toHaveProperty('login');
|
||||
expect(state).toHaveProperty('logout');
|
||||
expect(state).toHaveProperty('loadFromStorage');
|
||||
});
|
||||
|
||||
it('无 token 时应为未认证状态', () => {
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.permissions).toEqual([]);
|
||||
expect(state.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 登录流程
|
||||
// =========================================================================
|
||||
describe('login', () => {
|
||||
it('成功登录应更新 user、isAuthenticated、permissions 并写入 localStorage', async () => {
|
||||
const fakeUser = createFakeUser();
|
||||
const fakeResponse = createFakeLoginResponse({ user: fakeUser });
|
||||
mockApiLogin.mockResolvedValue(fakeResponse);
|
||||
|
||||
await useAuthStore.getState().login('testuser', 'password123');
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
// store 状态正确
|
||||
expect(state.user).toEqual(fakeUser);
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.permissions).toEqual(['health.patient.list', 'health.alerts.manage']);
|
||||
|
||||
// API 被正确调用
|
||||
expect(mockApiLogin).toHaveBeenCalledWith({ username: 'testuser', password: 'password123' });
|
||||
|
||||
// localStorage 被写入
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('access_token', fakeResponse.access_token);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', fakeResponse.refresh_token);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('user', JSON.stringify(fakeUser));
|
||||
|
||||
// API 缓存被清除
|
||||
expect(mockClearApiCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('登录中 loading 应为 true,完成后恢复为 false', async () => {
|
||||
let resolveLogin: (value: unknown) => void;
|
||||
const loginPromise = new Promise((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
});
|
||||
mockApiLogin.mockReturnValue(loginPromise);
|
||||
|
||||
const loginAction = useAuthStore.getState().login('user', 'pass');
|
||||
|
||||
// 登录进行中
|
||||
expect(useAuthStore.getState().loading).toBe(true);
|
||||
|
||||
resolveLogin!(createFakeLoginResponse());
|
||||
await loginAction;
|
||||
|
||||
expect(useAuthStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it('登录失败应重置 loading 为 false 并向上抛出错误', async () => {
|
||||
const error = new Error('Invalid credentials');
|
||||
mockApiLogin.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
useAuthStore.getState().login('baduser', 'wrongpass'),
|
||||
).rejects.toThrow('Invalid credentials');
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.user).toBeNull();
|
||||
|
||||
// 失败不应写入 localStorage
|
||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('登录失败不应清除 API 缓存', async () => {
|
||||
mockApiLogin.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(
|
||||
useAuthStore.getState().login('user', 'pass'),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockClearApiCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 登出流程
|
||||
// =========================================================================
|
||||
describe('logout', () => {
|
||||
it('登出应清除所有状态和 localStorage', async () => {
|
||||
// 先登录
|
||||
const fakeUser = createFakeUser();
|
||||
const fakeResponse = createFakeLoginResponse({ user: fakeUser });
|
||||
mockApiLogin.mockResolvedValue(fakeResponse);
|
||||
await useAuthStore.getState().login('testuser', 'password');
|
||||
|
||||
// 确认已登录
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
|
||||
// 登出
|
||||
mockApiLogout.mockResolvedValue(undefined);
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.permissions).toEqual([]);
|
||||
|
||||
// localStorage 被清除
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('access_token');
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('refresh_token');
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('user');
|
||||
|
||||
// API 缓存被清除
|
||||
expect(mockClearApiCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('登出 API 报错时仍应清除本地状态(不抛出错误)', async () => {
|
||||
// 先设置一个已认证状态
|
||||
useAuthStore.setState({
|
||||
user: createFakeUser(),
|
||||
isAuthenticated: true,
|
||||
permissions: ['health.patient.list'],
|
||||
});
|
||||
localStorageStore['access_token'] = 'some-token';
|
||||
|
||||
// logout API 失败
|
||||
mockApiLogout.mockRejectedValue(new Error('Server unreachable'));
|
||||
|
||||
// 不应抛出
|
||||
await expect(useAuthStore.getState().logout()).resolves.toBeUndefined();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.permissions).toEqual([]);
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('access_token');
|
||||
});
|
||||
|
||||
it('登出时应调用 logout API', async () => {
|
||||
mockApiLogout.mockResolvedValue(undefined);
|
||||
await useAuthStore.getState().logout();
|
||||
expect(mockApiLogout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 权限提取(extractPermissions 内部逻辑)
|
||||
// =========================================================================
|
||||
describe('权限提取', () => {
|
||||
it('登录后 permissions 应从 JWT token 中正确解析', async () => {
|
||||
const permissions = ['health.patient.list', 'health.alerts.manage', 'health.report.review'];
|
||||
const token = createFakeToken(permissions);
|
||||
const fakeResponse = createFakeLoginResponse({ access_token: token });
|
||||
mockApiLogin.mockResolvedValue(fakeResponse);
|
||||
|
||||
await useAuthStore.getState().login('user', 'pass');
|
||||
|
||||
expect(useAuthStore.getState().permissions).toEqual(permissions);
|
||||
});
|
||||
|
||||
it('token 无 permissions 字段时 permissions 应为空数组', async () => {
|
||||
// 手动构造一个没有 permissions 的 token
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const payload = btoa(JSON.stringify({ sub: 'user-001' }))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
const signature = btoa('sig');
|
||||
const token = `${header}.${payload}.${signature}`;
|
||||
|
||||
const fakeResponse = createFakeLoginResponse({ access_token: token });
|
||||
mockApiLogin.mockResolvedValue(fakeResponse);
|
||||
|
||||
await useAuthStore.getState().login('user', 'pass');
|
||||
|
||||
expect(useAuthStore.getState().permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('token 格式非法时 permissions 应为空数组', async () => {
|
||||
const fakeResponse = createFakeLoginResponse({ access_token: 'not-a-jwt' });
|
||||
mockApiLogin.mockResolvedValue(fakeResponse);
|
||||
|
||||
await useAuthStore.getState().login('user', 'pass');
|
||||
|
||||
expect(useAuthStore.getState().permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('空 permissions 数组应正确解析', async () => {
|
||||
const token = createFakeToken([]);
|
||||
const fakeResponse = createFakeLoginResponse({ access_token: token });
|
||||
mockApiLogin.mockResolvedValue(fakeResponse);
|
||||
|
||||
await useAuthStore.getState().login('user', 'pass');
|
||||
|
||||
expect(useAuthStore.getState().permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// localStorage 持久化 / loadFromStorage
|
||||
// =========================================================================
|
||||
describe('loadFromStorage', () => {
|
||||
it('localStorage 有有效 token 和 user 时应恢复认证状态', () => {
|
||||
const fakeUser = createFakeUser();
|
||||
const permissions = ['health.patient.list'];
|
||||
const token = createFakeToken(permissions);
|
||||
|
||||
localStorageStore['access_token'] = token;
|
||||
localStorageStore['user'] = JSON.stringify(fakeUser);
|
||||
|
||||
useAuthStore.getState().loadFromStorage();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.user).toEqual(fakeUser);
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.permissions).toEqual(permissions);
|
||||
});
|
||||
|
||||
it('localStorage 无 token 时应保持未认证', () => {
|
||||
localStorageStore['user'] = JSON.stringify(createFakeUser());
|
||||
// 不设置 access_token
|
||||
|
||||
useAuthStore.getState().loadFromStorage();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.user).toBeNull();
|
||||
});
|
||||
|
||||
it('localStorage 无 user 时应保持未认证', () => {
|
||||
localStorageStore['access_token'] = createFakeToken(['health.patient.list']);
|
||||
// 不设置 user
|
||||
|
||||
useAuthStore.getState().loadFromStorage();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('localStorage user JSON 损坏时应移除损坏数据并保持未认证', () => {
|
||||
localStorageStore['access_token'] = createFakeToken([]);
|
||||
localStorageStore['user'] = '{invalid json!!!';
|
||||
|
||||
useAuthStore.getState().loadFromStorage();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.user).toBeNull();
|
||||
// 损坏的 user 数据应被清理
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('user');
|
||||
});
|
||||
|
||||
it('token 和 user 都为空时应返回干净的未认证状态', () => {
|
||||
useAuthStore.getState().loadFromStorage();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 状态重置验证
|
||||
// =========================================================================
|
||||
describe('状态重置', () => {
|
||||
it('登出后再次登录应正确设置新状态', async () => {
|
||||
// 第一次登录
|
||||
const user1 = createFakeUser({ id: 'user-001', username: 'user1' });
|
||||
const token1 = createFakeToken(['perm.a']);
|
||||
mockApiLogin.mockResolvedValue(createFakeLoginResponse({ user: user1, access_token: token1 }));
|
||||
await useAuthStore.getState().login('user1', 'pass1');
|
||||
|
||||
expect(useAuthStore.getState().user?.id).toBe('user-001');
|
||||
expect(useAuthStore.getState().permissions).toEqual(['perm.a']);
|
||||
|
||||
// 登出
|
||||
mockApiLogout.mockResolvedValue(undefined);
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
expect(useAuthStore.getState().user).toBeNull();
|
||||
expect(useAuthStore.getState().permissions).toEqual([]);
|
||||
|
||||
// 第二次登录(不同用户、不同权限)
|
||||
const user2 = createFakeUser({ id: 'user-002', username: 'user2' });
|
||||
const token2 = createFakeToken(['perm.b', 'perm.c']);
|
||||
mockApiLogin.mockResolvedValue(createFakeLoginResponse({ user: user2, access_token: token2 }));
|
||||
await useAuthStore.getState().login('user2', 'pass2');
|
||||
|
||||
expect(useAuthStore.getState().user?.id).toBe('user-002');
|
||||
expect(useAuthStore.getState().permissions).toEqual(['perm.b', 'perm.c']);
|
||||
});
|
||||
|
||||
it('登出后的状态应与初始状态一致', async () => {
|
||||
// 登录
|
||||
mockApiLogin.mockResolvedValue(createFakeLoginResponse());
|
||||
await useAuthStore.getState().login('user', 'pass');
|
||||
|
||||
// 登出
|
||||
mockApiLogout.mockResolvedValue(undefined);
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 边界情况
|
||||
// =========================================================================
|
||||
describe('边界情况', () => {
|
||||
it('并发登录不应导致状态不一致', async () => {
|
||||
const user1 = createFakeUser({ id: 'user-001' });
|
||||
const user2 = createFakeUser({ id: 'user-002' });
|
||||
|
||||
let resolveLogin1: (value: unknown) => void;
|
||||
let resolveLogin2: (value: unknown) => void;
|
||||
|
||||
mockApiLogin.mockImplementationOnce(() => new Promise((r) => { resolveLogin1 = r; }));
|
||||
mockApiLogin.mockImplementationOnce(() => new Promise((r) => { resolveLogin2 = r; }));
|
||||
|
||||
const login1 = useAuthStore.getState().login('user1', 'pass1');
|
||||
const login2 = useAuthStore.getState().login('user2', 'pass2');
|
||||
|
||||
// 第二次登录先完成
|
||||
resolveLogin2!(createFakeLoginResponse({ user: user2 }));
|
||||
await login2;
|
||||
expect(useAuthStore.getState().user?.id).toBe('user-002');
|
||||
|
||||
// 第一次登录后完成(覆盖第二次)
|
||||
resolveLogin1!(createFakeLoginResponse({ user: user1 }));
|
||||
await login1;
|
||||
expect(useAuthStore.getState().user?.id).toBe('user-001');
|
||||
|
||||
// 最终 loading 为 false
|
||||
expect(useAuthStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it('JWT payload 含 permissions 非数组时应返回空权限', async () => {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const payload = btoa(JSON.stringify({ permissions: 'not-an-array' }))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
const signature = btoa('sig');
|
||||
const token = `${header}.${payload}.${signature}`;
|
||||
|
||||
mockApiLogin.mockResolvedValue(createFakeLoginResponse({ access_token: token }));
|
||||
await useAuthStore.getState().login('user', 'pass');
|
||||
|
||||
expect(useAuthStore.getState().permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
82
apps/web/src/stores/auth.ts
Normal file
82
apps/web/src/stores/auth.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { create } from 'zustand';
|
||||
import { login as apiLogin, logout as apiLogout, type UserInfo } from '../api/auth';
|
||||
import { clearApiCache } from '../api/client';
|
||||
|
||||
function extractPermissions(): string[] {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return [];
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return [];
|
||||
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return Array.isArray(payload.permissions) ? payload.permissions : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function restoreInitialState(): { user: UserInfo | null; isAuthenticated: boolean; permissions: string[] } {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr) as UserInfo;
|
||||
return { user, isAuthenticated: true, permissions: extractPermissions() };
|
||||
} catch {
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
return { user: null, isAuthenticated: false, permissions: [] };
|
||||
}
|
||||
|
||||
const initial = restoreInitialState();
|
||||
|
||||
interface AuthState {
|
||||
user: UserInfo | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
permissions: string[];
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
loadFromStorage: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: initial.user,
|
||||
isAuthenticated: initial.isAuthenticated,
|
||||
loading: false,
|
||||
permissions: initial.permissions,
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const resp = await apiLogin({ username, password });
|
||||
localStorage.setItem('access_token', resp.access_token);
|
||||
localStorage.setItem('refresh_token', resp.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(resp.user));
|
||||
set({ user: resp.user, isAuthenticated: true, loading: false, permissions: extractPermissions() });
|
||||
clearApiCache();
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch {
|
||||
// Ignore logout API errors
|
||||
}
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
clearApiCache();
|
||||
set({ user: null, isAuthenticated: false, permissions: [] });
|
||||
},
|
||||
|
||||
loadFromStorage: () => {
|
||||
const state = restoreInitialState();
|
||||
set({ user: state.user, isAuthenticated: state.isAuthenticated, permissions: state.permissions });
|
||||
},
|
||||
}));
|
||||
175
apps/web/src/stores/health.test.ts
Normal file
175
apps/web/src/stores/health.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
123
apps/web/src/stores/health.ts
Normal file
123
apps/web/src/stores/health.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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 } }));
|
||||
}
|
||||
},
|
||||
}));
|
||||
468
apps/web/src/stores/message.test.ts
Normal file
468
apps/web/src/stores/message.test.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* useMessageStore 单元测试
|
||||
*
|
||||
* 覆盖:初始状态、未读计数、消息加载、已读标记、乐观回滚、请求去重、SSE 连接。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// --- Mock 消息 API ---
|
||||
const mockGetUnreadCount = vi.fn()
|
||||
const mockListMessages = vi.fn()
|
||||
const mockMarkRead = vi.fn()
|
||||
|
||||
vi.mock('../api/messages', () => ({
|
||||
getUnreadCount: (...args: unknown[]) => mockGetUnreadCount(...args),
|
||||
listMessages: (...args: unknown[]) => mockListMessages(...args),
|
||||
markRead: (...args: unknown[]) => mockMarkRead(...args),
|
||||
}))
|
||||
|
||||
// --- Mock EventSource ---
|
||||
const mockEventSourceClose = vi.fn()
|
||||
const mockEventSourceAddEventListener = vi.fn()
|
||||
const mockEventSourceCtor = vi.fn()
|
||||
|
||||
class MockEventSource {
|
||||
onopen: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onmessage: (() => void) | null = null
|
||||
|
||||
constructor(url: string) {
|
||||
mockEventSourceCtor(url)
|
||||
}
|
||||
|
||||
addEventListener = mockEventSourceAddEventListener
|
||||
close = mockEventSourceClose
|
||||
}
|
||||
|
||||
// 保存原始引用以便恢复
|
||||
const OriginalEventSource = globalThis.EventSource
|
||||
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.EventSource = MockEventSource as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.EventSource = OriginalEventSource
|
||||
})
|
||||
|
||||
// 在 mock 生效后导入被测模块
|
||||
// 必须用动态 import 重置 zustand 状态,但 zustand create 只执行一次,
|
||||
// 所以需要手动重置模块级变量
|
||||
import { useMessageStore } from './message'
|
||||
|
||||
// --- 测试用 fixture ---
|
||||
const fakeMessages = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
tenant_id: 't-1',
|
||||
sender_type: 'system',
|
||||
recipient_id: 'u-1',
|
||||
recipient_type: 'user',
|
||||
title: '预约提醒',
|
||||
body: '您有一个即将到来的预约',
|
||||
priority: 'high',
|
||||
is_read: false,
|
||||
is_archived: false,
|
||||
status: 'sent',
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
tenant_id: 't-1',
|
||||
sender_type: 'doctor',
|
||||
recipient_id: 'u-1',
|
||||
recipient_type: 'user',
|
||||
title: '随访通知',
|
||||
body: '请完成本周随访',
|
||||
priority: 'normal',
|
||||
is_read: false,
|
||||
is_archived: false,
|
||||
status: 'sent',
|
||||
created_at: '2026-05-02T00:00:00Z',
|
||||
updated_at: '2026-05-02T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
|
||||
// 重置 store 到初始状态
|
||||
useMessageStore.setState({
|
||||
unreadCount: 0,
|
||||
recentMessages: [],
|
||||
})
|
||||
|
||||
// 重置模块级去重 promise(通过重新设置状态间接处理)
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 初始状态
|
||||
// ============================================================
|
||||
describe('初始状态', () => {
|
||||
it('unreadCount 应为 0', () => {
|
||||
expect(useMessageStore.getState().unreadCount).toBe(0)
|
||||
})
|
||||
|
||||
it('recentMessages 应为空数组', () => {
|
||||
expect(useMessageStore.getState().recentMessages).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// fetchUnreadCount
|
||||
// ============================================================
|
||||
describe('fetchUnreadCount', () => {
|
||||
it('成功时设置 unreadCount', async () => {
|
||||
mockGetUnreadCount.mockResolvedValue({ count: 7 })
|
||||
|
||||
await useMessageStore.getState().fetchUnreadCount()
|
||||
|
||||
expect(mockGetUnreadCount).toHaveBeenCalledTimes(1)
|
||||
expect(useMessageStore.getState().unreadCount).toBe(7)
|
||||
})
|
||||
|
||||
it('API 失败时保持原值不变', async () => {
|
||||
useMessageStore.setState({ unreadCount: 3 })
|
||||
mockGetUnreadCount.mockRejectedValue(new Error('network'))
|
||||
|
||||
await useMessageStore.getState().fetchUnreadCount()
|
||||
|
||||
expect(useMessageStore.getState().unreadCount).toBe(3)
|
||||
})
|
||||
|
||||
it('并发调用应去重(仅发一次请求)', async () => {
|
||||
let resolveApi: (value: { count: number }) => void
|
||||
const pending = new Promise<{ count: number }>((resolve) => {
|
||||
resolveApi = resolve
|
||||
})
|
||||
mockGetUnreadCount.mockReturnValue(pending)
|
||||
|
||||
// 同时发起两次调用
|
||||
const call1 = useMessageStore.getState().fetchUnreadCount()
|
||||
const call2 = useMessageStore.getState().fetchUnreadCount()
|
||||
|
||||
// API 只被调用一次
|
||||
expect(mockGetUnreadCount).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 解除 pending
|
||||
resolveApi!({ count: 5 })
|
||||
await Promise.all([call1, call2])
|
||||
|
||||
expect(useMessageStore.getState().unreadCount).toBe(5)
|
||||
})
|
||||
|
||||
it('前一次完成后再次调用应发起新请求', async () => {
|
||||
mockGetUnreadCount.mockResolvedValue({ count: 1 })
|
||||
await useMessageStore.getState().fetchUnreadCount()
|
||||
|
||||
mockGetUnreadCount.mockResolvedValue({ count: 2 })
|
||||
await useMessageStore.getState().fetchUnreadCount()
|
||||
|
||||
expect(mockGetUnreadCount).toHaveBeenCalledTimes(2)
|
||||
expect(useMessageStore.getState().unreadCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// fetchRecentMessages
|
||||
// ============================================================
|
||||
describe('fetchRecentMessages', () => {
|
||||
it('成功时设置 recentMessages', async () => {
|
||||
mockListMessages.mockResolvedValue({ data: fakeMessages })
|
||||
|
||||
await useMessageStore.getState().fetchRecentMessages()
|
||||
|
||||
expect(mockListMessages).toHaveBeenCalledWith({ page: 1, page_size: 5 })
|
||||
expect(useMessageStore.getState().recentMessages).toEqual(fakeMessages)
|
||||
})
|
||||
|
||||
it('API 失败时保持原值不变', async () => {
|
||||
useMessageStore.setState({ recentMessages: fakeMessages })
|
||||
mockListMessages.mockRejectedValue(new Error('network'))
|
||||
|
||||
await useMessageStore.getState().fetchRecentMessages()
|
||||
|
||||
expect(useMessageStore.getState().recentMessages).toEqual(fakeMessages)
|
||||
})
|
||||
|
||||
it('并发调用应去重', async () => {
|
||||
let resolveApi: (value: { data: typeof fakeMessages }) => void
|
||||
const pending = new Promise<{ data: typeof fakeMessages }>((resolve) => {
|
||||
resolveApi = resolve
|
||||
})
|
||||
mockListMessages.mockReturnValue(pending)
|
||||
|
||||
const call1 = useMessageStore.getState().fetchRecentMessages()
|
||||
const call2 = useMessageStore.getState().fetchRecentMessages()
|
||||
|
||||
expect(mockListMessages).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveApi!({ data: fakeMessages })
|
||||
await Promise.all([call1, call2])
|
||||
|
||||
expect(useMessageStore.getState().recentMessages).toEqual(fakeMessages)
|
||||
})
|
||||
|
||||
it('返回空列表时 recentMessages 为空数组', async () => {
|
||||
mockListMessages.mockResolvedValue({ data: [] })
|
||||
|
||||
await useMessageStore.getState().fetchRecentMessages()
|
||||
|
||||
expect(useMessageStore.getState().recentMessages).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// markAsRead
|
||||
// ============================================================
|
||||
describe('markAsRead', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.setState({
|
||||
unreadCount: 2,
|
||||
recentMessages: [...fakeMessages],
|
||||
})
|
||||
})
|
||||
|
||||
it('乐观更新:立即减少 unreadCount 并标记消息已读', async () => {
|
||||
mockMarkRead.mockResolvedValue({ success: true })
|
||||
|
||||
await useMessageStore.getState().markAsRead('msg-1')
|
||||
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1)
|
||||
const updated = useMessageStore.getState().recentMessages
|
||||
expect(updated.find((m) => m.id === 'msg-1')?.is_read).toBe(true)
|
||||
expect(updated.find((m) => m.id === 'msg-2')?.is_read).toBe(false)
|
||||
})
|
||||
|
||||
it('调用 markRead API 传入正确的 id', async () => {
|
||||
mockMarkRead.mockResolvedValue({ success: true })
|
||||
|
||||
await useMessageStore.getState().markAsRead('msg-1')
|
||||
|
||||
expect(mockMarkRead).toHaveBeenCalledWith('msg-1')
|
||||
})
|
||||
|
||||
it('API 失败时回滚到之前的状态', async () => {
|
||||
mockMarkRead.mockRejectedValue(new Error('server error'))
|
||||
|
||||
await useMessageStore.getState().markAsRead('msg-1')
|
||||
|
||||
// 应回滚到原始状态
|
||||
expect(useMessageStore.getState().unreadCount).toBe(2)
|
||||
expect(
|
||||
useMessageStore.getState().recentMessages.find((m) => m.id === 'msg-1')?.is_read,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('unreadCount 不应为负数', async () => {
|
||||
useMessageStore.setState({ unreadCount: 0 })
|
||||
mockMarkRead.mockResolvedValue({ success: true })
|
||||
|
||||
await useMessageStore.getState().markAsRead('msg-1')
|
||||
|
||||
expect(useMessageStore.getState().unreadCount).toBe(0)
|
||||
})
|
||||
|
||||
it('对不在 recentMessages 中的消息标记已读仍减少 unreadCount', async () => {
|
||||
mockMarkRead.mockResolvedValue({ success: true })
|
||||
|
||||
await useMessageStore.getState().markAsRead('msg-other')
|
||||
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1)
|
||||
// recentMessages 中的消息不受影响
|
||||
expect(
|
||||
useMessageStore.getState().recentMessages.every((m) => !m.is_read),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// connectSSE
|
||||
// ============================================================
|
||||
describe('connectSSE', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockGetUnreadCount.mockResolvedValue({ count: 0 })
|
||||
mockListMessages.mockResolvedValue({ data: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('返回 dispose 函数', () => {
|
||||
localStorage.setItem('access_token', 'test-token')
|
||||
const dispose = useMessageStore.getState().connectSSE()
|
||||
expect(typeof dispose).toBe('function')
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('无 token 时创建 EventSource 后立即返回', () => {
|
||||
// 不设置 token
|
||||
const dispose = useMessageStore.getState().connectSSE()
|
||||
|
||||
// EventSource 构造未被调用(因为没有 token)
|
||||
expect(mockEventSourceCtor).not.toHaveBeenCalled()
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('有 token 时创建 EventSource 并传递正确 URL', () => {
|
||||
localStorage.setItem('access_token', 'my-jwt-token')
|
||||
vi.stubEnv('VITE_API_BASE_URL', '')
|
||||
|
||||
const dispose = useMessageStore.getState().connectSSE()
|
||||
|
||||
expect(mockEventSourceCtor).toHaveBeenCalledTimes(1)
|
||||
const url = mockEventSourceCtor.mock.calls[0][0] as string
|
||||
expect(url).toContain('/messages/stream?token=')
|
||||
expect(url).toContain(encodeURIComponent('my-jwt-token'))
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('dispose 关闭 EventSource', () => {
|
||||
localStorage.setItem('access_token', 'token')
|
||||
|
||||
const dispose = useMessageStore.getState().connectSSE()
|
||||
dispose()
|
||||
|
||||
expect(mockEventSourceClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('注册 message 和 alert 事件监听器', () => {
|
||||
localStorage.setItem('access_token', 'token')
|
||||
|
||||
const dispose = useMessageStore.getState().connectSSE()
|
||||
|
||||
const eventTypes = mockEventSourceAddEventListener.mock.calls.map(
|
||||
(call: any[]) => call[0],
|
||||
)
|
||||
expect(eventTypes).toContain('message')
|
||||
expect(eventTypes).toContain('alert')
|
||||
expect(eventTypes).toContain('vital_update')
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('message 事件触发 fetchUnreadCount 和 fetchRecentMessages', async () => {
|
||||
localStorage.setItem('access_token', 'token')
|
||||
|
||||
const dispose = useMessageStore.getState().connectSSE()
|
||||
|
||||
// 找到 message 事件的回调
|
||||
const messageCall = mockEventSourceAddEventListener.mock.calls.find(
|
||||
(call: any[]) => call[0] === 'message',
|
||||
)
|
||||
const messageCallback = messageCall![1] as () => void
|
||||
|
||||
messageCallback()
|
||||
|
||||
// 等待异步操作完成
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockGetUnreadCount).toHaveBeenCalled()
|
||||
expect(mockListMessages).toHaveBeenCalled()
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('alert 事件仅触发 fetchUnreadCount', async () => {
|
||||
localStorage.setItem('access_token', 'token')
|
||||
|
||||
const dispose = useMessageStore.getState().connectSSE()
|
||||
|
||||
const alertCall = mockEventSourceAddEventListener.mock.calls.find(
|
||||
(call: any[]) => call[0] === 'alert',
|
||||
)
|
||||
const alertCallback = alertCall![1] as () => void
|
||||
|
||||
alertCallback()
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockGetUnreadCount).toHaveBeenCalled()
|
||||
// alert 不应触发 fetchRecentMessages
|
||||
expect(mockListMessages).not.toHaveBeenCalled()
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('onerror 关闭连接并启动重连定时器', () => {
|
||||
localStorage.setItem('access_token', 'token')
|
||||
|
||||
// 需要捕获 EventSource 实例以设置 onerror
|
||||
let instance: MockEventSource | null = null
|
||||
const OrigMock = MockEventSource
|
||||
const CapturingEventSource = class extends OrigMock {
|
||||
constructor(url: string) {
|
||||
super(url)
|
||||
instance = this
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.EventSource = CapturingEventSource as any
|
||||
|
||||
const dispose = useMessageStore.getState().connectSSE()
|
||||
|
||||
expect(instance).not.toBeNull()
|
||||
instance!.onerror!()
|
||||
|
||||
expect(mockEventSourceClose).toHaveBeenCalled()
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('dispose 后不会重连', () => {
|
||||
localStorage.setItem('access_token', 'token')
|
||||
|
||||
let instance: MockEventSource | null = null
|
||||
const OrigMock = MockEventSource
|
||||
const CapturingEventSource = class extends OrigMock {
|
||||
constructor(url: string) {
|
||||
super(url)
|
||||
instance = this
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.EventSource = CapturingEventSource as any
|
||||
|
||||
const dispose = useMessageStore.getState().connectSSE()
|
||||
|
||||
instance!.onerror!()
|
||||
dispose()
|
||||
|
||||
// 清除 constructor 调用计数
|
||||
mockEventSourceCtor.mockClear()
|
||||
|
||||
// 推进定时器 — 不应触发新的 EventSource 创建
|
||||
vi.advanceTimersByTime(60_000)
|
||||
|
||||
expect(mockEventSourceCtor).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 状态重置
|
||||
// ============================================================
|
||||
describe('状态重置', () => {
|
||||
it('通过 setState 可手动重置到初始状态', async () => {
|
||||
mockGetUnreadCount.mockResolvedValue({ count: 10 })
|
||||
mockListMessages.mockResolvedValue({ data: fakeMessages })
|
||||
|
||||
await useMessageStore.getState().fetchUnreadCount()
|
||||
await useMessageStore.getState().fetchRecentMessages()
|
||||
|
||||
expect(useMessageStore.getState().unreadCount).toBe(10)
|
||||
expect(useMessageStore.getState().recentMessages).toEqual(fakeMessages)
|
||||
|
||||
// 重置
|
||||
useMessageStore.setState({ unreadCount: 0, recentMessages: [] })
|
||||
|
||||
expect(useMessageStore.getState().unreadCount).toBe(0)
|
||||
expect(useMessageStore.getState().recentMessages).toEqual([])
|
||||
})
|
||||
})
|
||||
155
apps/web/src/stores/message.ts
Normal file
155
apps/web/src/stores/message.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { create } from 'zustand';
|
||||
import { getUnreadCount, listMessages, markRead, type MessageInfo } from '../api/messages';
|
||||
|
||||
const INITIAL_DELAY_MS = 1000;
|
||||
const MAX_DELAY_MS = 30_000;
|
||||
const MAX_RETRIES = 10;
|
||||
|
||||
interface MessageState {
|
||||
unreadCount: number;
|
||||
recentMessages: MessageInfo[];
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
fetchRecentMessages: () => Promise<void>;
|
||||
markAsRead: (id: string) => Promise<void>;
|
||||
connectSSE: () => () => void;
|
||||
}
|
||||
|
||||
// 请求去重:记录正在进行的请求,防止并发重复调用
|
||||
let unreadCountPromise: Promise<void> | null = null;
|
||||
let recentMessagesPromise: Promise<void> | null = null;
|
||||
|
||||
export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
unreadCount: 0,
|
||||
recentMessages: [],
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
// 如果已有进行中的请求,复用该 Promise
|
||||
if (unreadCountPromise) {
|
||||
await unreadCountPromise;
|
||||
return;
|
||||
}
|
||||
unreadCountPromise = (async () => {
|
||||
try {
|
||||
const result = await getUnreadCount();
|
||||
set({ unreadCount: result.count });
|
||||
} catch {
|
||||
// 静默失败,不影响用户体验
|
||||
} finally {
|
||||
unreadCountPromise = null;
|
||||
}
|
||||
})();
|
||||
await unreadCountPromise;
|
||||
},
|
||||
|
||||
fetchRecentMessages: async () => {
|
||||
if (recentMessagesPromise) {
|
||||
await recentMessagesPromise;
|
||||
return;
|
||||
}
|
||||
recentMessagesPromise = (async () => {
|
||||
try {
|
||||
const result = await listMessages({ page: 1, page_size: 5 });
|
||||
set({ recentMessages: result.data });
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
recentMessagesPromise = null;
|
||||
}
|
||||
})();
|
||||
await recentMessagesPromise;
|
||||
},
|
||||
|
||||
markAsRead: async (id: string) => {
|
||||
const prev = { unreadCount: get().unreadCount, recentMessages: get().recentMessages };
|
||||
set((state) => ({
|
||||
unreadCount: Math.max(0, state.unreadCount - 1),
|
||||
recentMessages: state.recentMessages.map((m) =>
|
||||
m.id === id ? { ...m, is_read: true } : m,
|
||||
),
|
||||
}));
|
||||
try {
|
||||
await markRead(id);
|
||||
} catch {
|
||||
set({ unreadCount: prev.unreadCount, recentMessages: prev.recentMessages });
|
||||
}
|
||||
},
|
||||
|
||||
connectSSE: () => {
|
||||
let es: EventSource | null = null;
|
||||
let retryCount = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token || disposed) return;
|
||||
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1';
|
||||
const url = `${baseUrl}/messages/stream?token=${encodeURIComponent(token)}`;
|
||||
es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
retryCount = 0;
|
||||
};
|
||||
|
||||
es.addEventListener('message', () => {
|
||||
get().fetchUnreadCount();
|
||||
get().fetchRecentMessages();
|
||||
});
|
||||
|
||||
es.addEventListener('alert', () => {
|
||||
get().fetchUnreadCount();
|
||||
});
|
||||
|
||||
es.addEventListener('vital_update', () => {
|
||||
// 体征数据更新事件 — 预留:未来可触发趋势图刷新
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
|
||||
retryCount += 1;
|
||||
if (retryCount > MAX_RETRIES || disposed) return;
|
||||
|
||||
const delay = Math.min(
|
||||
INITIAL_DELAY_MS * Math.pow(2, retryCount - 1),
|
||||
MAX_DELAY_MS,
|
||||
);
|
||||
const jitter = delay * (0.5 + Math.random() * 0.5);
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (disposed) return;
|
||||
const tokenNow = localStorage.getItem('access_token');
|
||||
if (!tokenNow) return;
|
||||
connect();
|
||||
}, jitter);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
};
|
||||
},
|
||||
}));
|
||||
603
apps/web/src/stores/plugin.test.ts
Normal file
603
apps/web/src/stores/plugin.test.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
/**
|
||||
* plugin store 单元测试
|
||||
*
|
||||
* 覆盖 usePluginStore 的状态管理逻辑:
|
||||
* - 初始状态值
|
||||
* - fetchPlugins:加载列表、并行 schema、去重
|
||||
* - refreshMenuItems:菜单生成、分组、各页面类型路由
|
||||
* - schema 缓存
|
||||
* - 错误处理(schema 加载失败、API 失败)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock 外部依赖
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockListPlugins = vi.fn();
|
||||
const mockGetPluginSchema = vi.fn();
|
||||
|
||||
vi.mock('../api/plugins', () => ({
|
||||
listPlugins: (...args: unknown[]) => mockListPlugins(...args),
|
||||
getPluginSchema: (...args: unknown[]) => mockGetPluginSchema(...args),
|
||||
}));
|
||||
|
||||
import { usePluginStore } from './plugin';
|
||||
import type { PluginInfo, PluginSchemaResponse } from '../api/plugins';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createFakePlugin(overrides: Partial<PluginInfo> = {}): PluginInfo {
|
||||
return {
|
||||
id: 'plugin-001',
|
||||
name: '测试插件',
|
||||
version: '1.0.0',
|
||||
description: '用于单元测试',
|
||||
status: 'running',
|
||||
config: {},
|
||||
entities: [
|
||||
{ name: 'customer', display_name: '客户', table_name: 'plugin_001_customer' },
|
||||
],
|
||||
record_version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeSchema(
|
||||
overrides: Partial<PluginSchemaResponse> = {},
|
||||
): PluginSchemaResponse {
|
||||
return {
|
||||
entities: [
|
||||
{
|
||||
name: 'customer',
|
||||
display_name: '客户',
|
||||
fields: [
|
||||
{ name: 'id', field_type: 'uuid', required: true },
|
||||
{ name: 'name', field_type: 'text', required: true, display_name: '名称' },
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 重置 fetchPluginsPromise(模块级变量)
|
||||
// 因为 fetchPluginsPromise 是模块作用域变量,无法直接重置,
|
||||
// 但在 fetchPlugins finally 块中会被设为 null。
|
||||
// 只要每次 fetchPlugins 正常结束,就不会残留。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('usePluginStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
usePluginStore.setState({
|
||||
plugins: [],
|
||||
loading: false,
|
||||
pluginMenuItems: [],
|
||||
pluginMenuGroups: [],
|
||||
schemaCache: {},
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 初始状态
|
||||
// =========================================================================
|
||||
describe('初始状态', () => {
|
||||
it('应有正确的默认状态字段', () => {
|
||||
const state = usePluginStore.getState();
|
||||
expect(state).toHaveProperty('plugins');
|
||||
expect(state).toHaveProperty('loading');
|
||||
expect(state).toHaveProperty('pluginMenuItems');
|
||||
expect(state).toHaveProperty('pluginMenuGroups');
|
||||
expect(state).toHaveProperty('schemaCache');
|
||||
expect(state).toHaveProperty('fetchPlugins');
|
||||
expect(state).toHaveProperty('refreshMenuItems');
|
||||
});
|
||||
|
||||
it('默认值应为空', () => {
|
||||
const state = usePluginStore.getState();
|
||||
expect(state.plugins).toEqual([]);
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.pluginMenuItems).toEqual([]);
|
||||
expect(state.pluginMenuGroups).toEqual([]);
|
||||
expect(state.schemaCache).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// fetchPlugins
|
||||
// =========================================================================
|
||||
describe('fetchPlugins', () => {
|
||||
it('成功时应更新 plugins 和 loading 状态', async () => {
|
||||
const fakePlugin = createFakePlugin();
|
||||
mockListPlugins.mockResolvedValueOnce({ data: [fakePlugin] });
|
||||
mockGetPluginSchema.mockResolvedValueOnce(createFakeSchema());
|
||||
|
||||
await usePluginStore.getState().fetchPlugins();
|
||||
|
||||
const state = usePluginStore.getState();
|
||||
expect(state.plugins).toEqual([fakePlugin]);
|
||||
expect(state.loading).toBe(false);
|
||||
expect(mockListPlugins).toHaveBeenCalledWith(1, 100, undefined);
|
||||
});
|
||||
|
||||
it('加载中 loading 应为 true,完成后恢复为 false', async () => {
|
||||
let resolveList: (value: unknown) => void;
|
||||
const listPromise = new Promise((resolve) => {
|
||||
resolveList = resolve;
|
||||
});
|
||||
mockListPlugins.mockReturnValue(listPromise);
|
||||
|
||||
const fetchPromise = usePluginStore.getState().fetchPlugins();
|
||||
|
||||
expect(usePluginStore.getState().loading).toBe(true);
|
||||
|
||||
resolveList!({ data: [] });
|
||||
await fetchPromise;
|
||||
|
||||
expect(usePluginStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it('应并行加载所有运行中/启用插件的 schema', async () => {
|
||||
const plugin1 = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
const plugin2 = createFakePlugin({ id: 'p2', status: 'enabled' });
|
||||
const plugin3 = createFakePlugin({ id: 'p3', status: 'disabled' });
|
||||
|
||||
mockListPlugins.mockResolvedValueOnce({ data: [plugin1, plugin2, plugin3] });
|
||||
mockGetPluginSchema
|
||||
.mockResolvedValueOnce(createFakeSchema())
|
||||
.mockResolvedValueOnce(createFakeSchema());
|
||||
|
||||
await usePluginStore.getState().fetchPlugins();
|
||||
|
||||
// 只有 running 和 enabled 的插件加载了 schema
|
||||
expect(mockGetPluginSchema).toHaveBeenCalledWith('p1');
|
||||
expect(mockGetPluginSchema).toHaveBeenCalledWith('p2');
|
||||
expect(mockGetPluginSchema).not.toHaveBeenCalledWith('p3');
|
||||
});
|
||||
|
||||
it('schema 加载应写入 schemaCache', async () => {
|
||||
const fakePlugin = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
const fakeSchema = createFakeSchema();
|
||||
|
||||
mockListPlugins.mockResolvedValueOnce({ data: [fakePlugin] });
|
||||
mockGetPluginSchema.mockResolvedValueOnce(fakeSchema);
|
||||
|
||||
await usePluginStore.getState().fetchPlugins();
|
||||
|
||||
expect(usePluginStore.getState().schemaCache['p1']).toEqual(fakeSchema);
|
||||
});
|
||||
|
||||
it('单个 schema 加载失败不应阻止其他 schema', async () => {
|
||||
const plugin1 = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
const plugin2 = createFakePlugin({ id: 'p2', status: 'running' });
|
||||
|
||||
mockListPlugins.mockResolvedValueOnce({ data: [plugin1, plugin2] });
|
||||
mockGetPluginSchema
|
||||
.mockRejectedValueOnce(new Error('schema fail'))
|
||||
.mockResolvedValueOnce(createFakeSchema());
|
||||
|
||||
await usePluginStore.getState().fetchPlugins();
|
||||
|
||||
// p1 失败不应出现在 cache 中
|
||||
expect(usePluginStore.getState().schemaCache['p1']).toBeUndefined();
|
||||
// p2 应该成功
|
||||
expect(usePluginStore.getState().schemaCache['p2']).toBeDefined();
|
||||
});
|
||||
|
||||
it('无运行中插件时不应调用 getPluginSchema', async () => {
|
||||
mockListPlugins.mockResolvedValueOnce({ data: [] });
|
||||
|
||||
await usePluginStore.getState().fetchPlugins();
|
||||
|
||||
expect(mockGetPluginSchema).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('API 失败时应恢复 loading 为 false', async () => {
|
||||
mockListPlugins.mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
// fetchPlugins 使用 try/finally,不捕获异常,错误会向上传播
|
||||
await expect(usePluginStore.getState().fetchPlugins()).rejects.toThrow('network error');
|
||||
|
||||
expect(usePluginStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it('应传递 page 和 status 参数', async () => {
|
||||
mockListPlugins.mockResolvedValueOnce({ data: [] });
|
||||
|
||||
await usePluginStore.getState().fetchPlugins(2, 'enabled' as any);
|
||||
|
||||
expect(mockListPlugins).toHaveBeenCalledWith(2, 100, 'enabled');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// refreshMenuItems — 无 schema 回退模式
|
||||
// =========================================================================
|
||||
describe('refreshMenuItems(无 schema 回退)', () => {
|
||||
it('无运行中插件时菜单应为空', () => {
|
||||
usePluginStore.setState({
|
||||
plugins: [createFakePlugin({ status: 'disabled' })],
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
expect(usePluginStore.getState().pluginMenuItems).toEqual([]);
|
||||
});
|
||||
|
||||
it('无 schema 时应从 entities 生成回退菜单', () => {
|
||||
const plugin = createFakePlugin({
|
||||
id: 'p1',
|
||||
status: 'running',
|
||||
entities: [
|
||||
{ name: 'customer', display_name: '客户', table_name: 't1' },
|
||||
{ name: 'order', display_name: '订单', table_name: 't2' },
|
||||
],
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin],
|
||||
schemaCache: {},
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
const items = usePluginStore.getState().pluginMenuItems;
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toEqual({
|
||||
key: '/plugins/p1/customer',
|
||||
icon: 'AppstoreOutlined',
|
||||
label: '客户',
|
||||
pluginId: 'p1',
|
||||
entity: 'customer',
|
||||
pageType: 'crud',
|
||||
});
|
||||
expect(items[1]).toEqual({
|
||||
key: '/plugins/p1/order',
|
||||
icon: 'AppstoreOutlined',
|
||||
label: '订单',
|
||||
pluginId: 'p1',
|
||||
entity: 'order',
|
||||
pageType: 'crud',
|
||||
});
|
||||
});
|
||||
|
||||
it('entity 无 display_name 时应使用 name 作为 label', () => {
|
||||
const plugin = createFakePlugin({
|
||||
id: 'p2',
|
||||
status: 'enabled',
|
||||
entities: [
|
||||
{ name: 'product', display_name: '', table_name: 't3' },
|
||||
],
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin],
|
||||
schemaCache: {},
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
const items = usePluginStore.getState().pluginMenuItems;
|
||||
expect(items[0].label).toBe('product');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// refreshMenuItems — 有 schema 的各页面类型
|
||||
// =========================================================================
|
||||
describe('refreshMenuItems(有 schema 页面类型)', () => {
|
||||
it('crud 类型应生成正确的菜单项', () => {
|
||||
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
const schema = createFakeSchema({
|
||||
ui: {
|
||||
pages: [
|
||||
{ type: 'crud' as const, entity: 'customer', label: '客户管理', icon: 'UserOutlined' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin],
|
||||
schemaCache: { p1: schema },
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
const items = usePluginStore.getState().pluginMenuItems;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toEqual({
|
||||
key: '/plugins/p1/customer',
|
||||
icon: 'UserOutlined',
|
||||
label: '客户管理',
|
||||
pluginId: 'p1',
|
||||
entity: 'customer',
|
||||
pageType: 'crud',
|
||||
});
|
||||
});
|
||||
|
||||
it('tree 类型应生成正确的菜单项', () => {
|
||||
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
const schema = createFakeSchema({
|
||||
ui: {
|
||||
pages: [
|
||||
{
|
||||
type: 'tree' as const,
|
||||
entity: 'department',
|
||||
label: '部门树',
|
||||
id_field: 'id',
|
||||
parent_field: 'parent_id',
|
||||
label_field: 'name',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin],
|
||||
schemaCache: { p1: schema },
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
const items = usePluginStore.getState().pluginMenuItems;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toEqual({
|
||||
key: '/plugins/p1/tree/department',
|
||||
icon: 'ApartmentOutlined',
|
||||
label: '部门树',
|
||||
pluginId: 'p1',
|
||||
entity: 'department',
|
||||
pageType: 'tree',
|
||||
});
|
||||
});
|
||||
|
||||
it('tabs 类型应生成正确的菜单项', () => {
|
||||
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
const schema = createFakeSchema({
|
||||
ui: {
|
||||
pages: [
|
||||
{ type: 'tabs' as const, label: '综合视图', icon: 'LayoutOutlined', tabs: [] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin],
|
||||
schemaCache: { p1: schema },
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
const items = usePluginStore.getState().pluginMenuItems;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].pageType).toBe('tabs');
|
||||
expect(items[0].key).toContain('/plugins/p1/tabs/');
|
||||
expect(items[0].key).toContain(encodeURIComponent('综合视图'));
|
||||
});
|
||||
|
||||
it('graph 类型应生成正确的菜单项', () => {
|
||||
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
const schema = createFakeSchema({
|
||||
ui: {
|
||||
pages: [
|
||||
{
|
||||
type: 'graph' as const,
|
||||
entity: 'relation',
|
||||
label: '关系图',
|
||||
relationship_entity: 'rel',
|
||||
source_field: 'src',
|
||||
target_field: 'tgt',
|
||||
edge_label_field: 'label',
|
||||
node_label_field: 'name',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin],
|
||||
schemaCache: { p1: schema },
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
const items = usePluginStore.getState().pluginMenuItems;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toEqual({
|
||||
key: '/plugins/p1/graph/relation',
|
||||
icon: 'ApartmentOutlined',
|
||||
label: '关系图',
|
||||
pluginId: 'p1',
|
||||
entity: 'relation',
|
||||
pageType: 'graph',
|
||||
});
|
||||
});
|
||||
|
||||
it('dashboard 类型应生成正确的菜单项', () => {
|
||||
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
const schema = createFakeSchema({
|
||||
ui: {
|
||||
pages: [
|
||||
{ type: 'dashboard' as const, label: '仪表盘', widgets: [] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin],
|
||||
schemaCache: { p1: schema },
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
const items = usePluginStore.getState().pluginMenuItems;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toEqual({
|
||||
key: '/plugins/p1/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
label: '仪表盘',
|
||||
pluginId: 'p1',
|
||||
pageType: 'dashboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('kanban 类型应生成正确的菜单项', () => {
|
||||
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
const schema = createFakeSchema({
|
||||
ui: {
|
||||
pages: [
|
||||
{
|
||||
type: 'kanban' as const,
|
||||
entity: 'task',
|
||||
label: '看板',
|
||||
lane_field: 'status',
|
||||
card_title_field: 'title',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin],
|
||||
schemaCache: { p1: schema },
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
const items = usePluginStore.getState().pluginMenuItems;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toEqual({
|
||||
key: '/plugins/p1/kanban/task',
|
||||
icon: 'UnorderedListOutlined',
|
||||
label: '看板',
|
||||
pluginId: 'p1',
|
||||
entity: 'task',
|
||||
pageType: 'kanban',
|
||||
});
|
||||
});
|
||||
|
||||
it('detail 类型不应生成菜单项', () => {
|
||||
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
const schema = createFakeSchema({
|
||||
ui: {
|
||||
pages: [
|
||||
{
|
||||
type: 'detail' as const,
|
||||
entity: 'customer',
|
||||
label: '客户详情',
|
||||
sections: [{ type: 'fields', label: '基本信息', fields: ['name'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin],
|
||||
schemaCache: { p1: schema },
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
expect(usePluginStore.getState().pluginMenuItems).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// pluginMenuGroups 分组
|
||||
// =========================================================================
|
||||
describe('pluginMenuGroups 分组', () => {
|
||||
it('应按 pluginId 正确分组', () => {
|
||||
const plugin1 = createFakePlugin({
|
||||
id: 'p1',
|
||||
name: '插件一',
|
||||
status: 'running',
|
||||
entities: [
|
||||
{ name: 'a', display_name: '实体A', table_name: 't1' },
|
||||
{ name: 'b', display_name: '实体B', table_name: 't2' },
|
||||
],
|
||||
});
|
||||
const plugin2 = createFakePlugin({
|
||||
id: 'p2',
|
||||
name: '插件二',
|
||||
status: 'enabled',
|
||||
entities: [
|
||||
{ name: 'c', display_name: '实体C', table_name: 't3' },
|
||||
],
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin1, plugin2],
|
||||
schemaCache: {},
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
const groups = usePluginStore.getState().pluginMenuGroups;
|
||||
expect(groups).toHaveLength(2);
|
||||
|
||||
const g1 = groups.find((g) => g.pluginId === 'p1');
|
||||
expect(g1).toBeDefined();
|
||||
expect(g1!.pluginName).toBe('插件一');
|
||||
expect(g1!.items).toHaveLength(2);
|
||||
|
||||
const g2 = groups.find((g) => g.pluginId === 'p2');
|
||||
expect(g2).toBeDefined();
|
||||
expect(g2!.pluginName).toBe('插件二');
|
||||
expect(g2!.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('无运行中插件时 groups 应为空', () => {
|
||||
usePluginStore.setState({
|
||||
plugins: [createFakePlugin({ status: 'disabled' })],
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
expect(usePluginStore.getState().pluginMenuGroups).toEqual([]);
|
||||
});
|
||||
|
||||
it('pluginName 应从 plugins 列表查找', () => {
|
||||
const plugin = createFakePlugin({
|
||||
id: 'px',
|
||||
name: '名称查找插件',
|
||||
status: 'running',
|
||||
entities: [
|
||||
{ name: 'x', display_name: 'X', table_name: 't1' },
|
||||
],
|
||||
});
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [plugin],
|
||||
schemaCache: {},
|
||||
});
|
||||
usePluginStore.getState().refreshMenuItems();
|
||||
|
||||
const groups = usePluginStore.getState().pluginMenuGroups;
|
||||
expect(groups[0].pluginName).toBe('名称查找插件');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 集成:fetchPlugins 后菜单自动生成
|
||||
// =========================================================================
|
||||
describe('fetchPlugins 集成', () => {
|
||||
it('fetchPlugins 完成后应自动生成菜单', async () => {
|
||||
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
mockListPlugins.mockResolvedValueOnce({ data: [plugin] });
|
||||
mockGetPluginSchema.mockResolvedValueOnce(createFakeSchema());
|
||||
|
||||
await usePluginStore.getState().fetchPlugins();
|
||||
|
||||
// 应该有菜单项(来自回退或 schema)
|
||||
const items = usePluginStore.getState().pluginMenuItems;
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('多次 fetchPlugins 不应导致菜单重复', async () => {
|
||||
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
|
||||
mockListPlugins.mockResolvedValue({ data: [plugin] });
|
||||
mockGetPluginSchema.mockResolvedValue(createFakeSchema());
|
||||
|
||||
await usePluginStore.getState().fetchPlugins();
|
||||
await usePluginStore.getState().fetchPlugins();
|
||||
|
||||
// 每次都是完全替换,不会累积
|
||||
const items = usePluginStore.getState().pluginMenuItems;
|
||||
const keys = items.map((i) => i.key);
|
||||
const uniqueKeys = [...new Set(keys)];
|
||||
expect(keys.length).toBe(uniqueKeys.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
190
apps/web/src/stores/plugin.ts
Normal file
190
apps/web/src/stores/plugin.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { create } from 'zustand';
|
||||
import type { PluginInfo, PluginStatus, PluginPageSchema, PluginSchemaResponse } from '../api/plugins';
|
||||
import { listPlugins, getPluginSchema } from '../api/plugins';
|
||||
|
||||
export interface PluginMenuItem {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
pluginId: string;
|
||||
entity?: string;
|
||||
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard' | 'kanban';
|
||||
}
|
||||
|
||||
export interface PluginMenuGroup {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
items: PluginMenuItem[];
|
||||
}
|
||||
|
||||
interface PluginStore {
|
||||
plugins: PluginInfo[];
|
||||
loading: boolean;
|
||||
pluginMenuItems: PluginMenuItem[];
|
||||
pluginMenuGroups: PluginMenuGroup[];
|
||||
schemaCache: Record<string, PluginSchemaResponse>;
|
||||
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
|
||||
refreshMenuItems: () => void;
|
||||
}
|
||||
|
||||
// 请求去重:防止并发重复调用 fetchPlugins
|
||||
let fetchPluginsPromise: Promise<void> | null = null;
|
||||
|
||||
export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||
plugins: [],
|
||||
loading: false,
|
||||
pluginMenuItems: [],
|
||||
pluginMenuGroups: [],
|
||||
schemaCache: {},
|
||||
|
||||
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
||||
// 如果已有进行中的请求,复用该 Promise
|
||||
if (fetchPluginsPromise) {
|
||||
await fetchPluginsPromise;
|
||||
return;
|
||||
}
|
||||
fetchPluginsPromise = (async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const result = await listPlugins(page, 100, status);
|
||||
set({ plugins: result.data });
|
||||
|
||||
// 先基于 entities 生成回退菜单,确保侧边栏快速渲染
|
||||
get().refreshMenuItems();
|
||||
|
||||
// 并行加载所有运行中插件的 schema,完成后更新菜单
|
||||
const activePlugins = result.data.filter(
|
||||
(p) => p.status === 'running' || p.status === 'enabled'
|
||||
);
|
||||
if (activePlugins.length === 0) return;
|
||||
|
||||
const entries = await Promise.allSettled(
|
||||
activePlugins.map(async (plugin) => {
|
||||
try {
|
||||
const schema = await getPluginSchema(plugin.id) as PluginSchemaResponse;
|
||||
return [plugin.id, schema] as const;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const schemas: Record<string, PluginSchemaResponse> = { ...get().schemaCache };
|
||||
for (const entry of entries) {
|
||||
if (entry.status === 'fulfilled' && entry.value) {
|
||||
schemas[entry.value[0]] = entry.value[1];
|
||||
}
|
||||
}
|
||||
set({ schemaCache: schemas });
|
||||
get().refreshMenuItems();
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
fetchPluginsPromise = null;
|
||||
}
|
||||
})();
|
||||
await fetchPluginsPromise;
|
||||
},
|
||||
|
||||
refreshMenuItems: () => {
|
||||
const { plugins, schemaCache } = get();
|
||||
const items: PluginMenuItem[] = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
|
||||
|
||||
const schema = schemaCache[plugin.id];
|
||||
const pages = (schema as { ui?: { pages: PluginPageSchema[] } })?.ui?.pages;
|
||||
|
||||
if (pages && pages.length > 0) {
|
||||
for (const page of pages) {
|
||||
if (page.type === 'tabs') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/tabs/${encodeURIComponent(page.label)}`,
|
||||
icon: page.icon || 'AppstoreOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
pageType: 'tabs' as const,
|
||||
});
|
||||
} else if (page.type === 'tree') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/tree/${page.entity}`,
|
||||
icon: page.icon || 'ApartmentOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
entity: page.entity,
|
||||
pageType: 'tree' as const,
|
||||
});
|
||||
} else if (page.type === 'crud') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/${page.entity}`,
|
||||
icon: page.icon || 'TableOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
entity: page.entity,
|
||||
pageType: 'crud' as const,
|
||||
});
|
||||
} else if (page.type === 'graph') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/graph/${page.entity}`,
|
||||
icon: 'ApartmentOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
entity: page.entity,
|
||||
pageType: 'graph' as const,
|
||||
});
|
||||
} else if (page.type === 'dashboard') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/dashboard`,
|
||||
icon: 'DashboardOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
pageType: 'dashboard' as const,
|
||||
});
|
||||
} else if (page.type === 'kanban') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/kanban/${page.entity}`,
|
||||
icon: 'UnorderedListOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
entity: page.entity,
|
||||
pageType: 'kanban' as const,
|
||||
});
|
||||
}
|
||||
// detail 类型不生成菜单项
|
||||
}
|
||||
} else {
|
||||
// 回退:从 entities 生成菜单
|
||||
for (const entity of plugin.entities) {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/${entity.name}`,
|
||||
icon: 'AppstoreOutlined',
|
||||
label: entity.display_name || entity.name,
|
||||
pluginId: plugin.id,
|
||||
entity: entity.name,
|
||||
pageType: 'crud',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({ pluginMenuItems: items });
|
||||
|
||||
// 按 pluginId 分组生成三级菜单(复用上方已解构的 plugins)
|
||||
const groupMap = new Map<string, PluginMenuItem[]>();
|
||||
for (const item of items) {
|
||||
const list = groupMap.get(item.pluginId) || [];
|
||||
list.push(item);
|
||||
groupMap.set(item.pluginId, list);
|
||||
}
|
||||
const groups: PluginMenuGroup[] = [];
|
||||
for (const [pluginId, groupItems] of groupMap) {
|
||||
const plugin = plugins.find((p) => p.id === pluginId);
|
||||
groups.push({
|
||||
pluginId,
|
||||
pluginName: plugin?.name || pluginId,
|
||||
items: groupItems,
|
||||
});
|
||||
}
|
||||
set({ pluginMenuGroups: groups });
|
||||
},
|
||||
}));
|
||||
406
apps/web/src/stores/workbenchStore.test.ts
Normal file
406
apps/web/src/stores/workbenchStore.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
apps/web/src/stores/workbenchStore.ts
Normal file
58
apps/web/src/stores/workbenchStore.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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