feat: 添加管理端前端 (HMS 基座 React 管理面板)
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

- 从 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:
iven
2026-06-02 10:03:13 +08:00
parent 181bfb1f3e
commit 8111471e93
341 changed files with 72102 additions and 1059 deletions

View 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);
});
});
});