chore: 干净 ERP 基座 — 删除 health/ai/wechat 业务代码
删除内容: - 前端: health/(67文件), ai/(2文件), Copilot, MediaPicker, 相关API/Store/Hook - 后端: wechat_handler, wechat_service, wechat_user entity, analytics handler, ai_workflow_seed - 配置: WechatConfig, AppConfig.wechat, AuthState wechat 字段 - 启动: 微信凭据检查块, ensure_ai_workflows() 调用 - 迁移: 新增 m20260613_000170_drop_wechat_users.rs - 脚本: api_test_health_alert.py, api_test_mp.py, mpsync.sh/ps1 - E2E: health-data page, flows/ 目录 保留: erp-core/auth/workflow/message/config/plugin + 基座前端 + 通用组件
This commit is contained in:
51
apps/web/src/api/auditLogs.test.ts
Normal file
51
apps/web/src/api/auditLogs.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* auditLogs API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as auditLogsApi from './auditLogs'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('auditLogs API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listAuditLogs 应调用 GET /audit-logs 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await auditLogsApi.listAuditLogs({ resource_type: 'user', user_id: 'u-001', page: 1, page_size: 10 })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/audit-logs', {
|
||||
params: expect.objectContaining({
|
||||
resource_type: 'user',
|
||||
user_id: 'u-001',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('listAuditLogs 默认应传 page=1 page_size=20', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await auditLogsApi.listAuditLogs()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/audit-logs', {
|
||||
params: expect.objectContaining({ page: 1, page_size: 20 }),
|
||||
})
|
||||
})
|
||||
})
|
||||
31
apps/web/src/api/auditLogs.ts
Normal file
31
apps/web/src/api/auditLogs.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface AuditLogItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
user_id: string;
|
||||
old_value?: string;
|
||||
new_value?: string;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogQuery {
|
||||
resource_type?: string;
|
||||
user_id?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export async function listAuditLogs(query: AuditLogQuery = {}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<AuditLogItem> }>(
|
||||
'/audit-logs',
|
||||
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
55
apps/web/src/api/auth.test.ts
Normal file
55
apps/web/src/api/auth.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* auth API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as authApi from './auth'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('auth API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('login 应调用 POST /auth/login 并传递用户名密码', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await authApi.login({ username: 'admin', password: '123456' })
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/login', {
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
})
|
||||
})
|
||||
|
||||
it('logout 应调用 POST /auth/logout', async () => {
|
||||
mockPost.mockResolvedValue(undefined)
|
||||
await authApi.logout()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/logout')
|
||||
})
|
||||
|
||||
it('changePassword 应调用 POST /auth/change-password', async () => {
|
||||
mockPost.mockResolvedValue(undefined)
|
||||
await authApi.changePassword('oldPass', 'newPass')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/change-password', {
|
||||
current_password: 'oldPass',
|
||||
new_password: 'newPass',
|
||||
})
|
||||
})
|
||||
})
|
||||
55
apps/web/src/api/auth.ts
Normal file
55
apps/web/src/api/auth.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import client from './client';
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
status: string;
|
||||
roles: RoleInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
export async function login(req: LoginRequest): Promise<LoginResponse> {
|
||||
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
|
||||
'/auth/login',
|
||||
req
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await client.post('/auth/logout');
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<void> {
|
||||
await client.post('/auth/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
261
apps/web/src/api/client.ts
Normal file
261
apps/web/src/api/client.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import axios from "axios";
|
||||
import { message as antMessage } from "antd";
|
||||
|
||||
// 请求缓存:短时间内相同请求复用结果
|
||||
interface CacheEntry {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const requestCache = new Map<string, CacheEntry>();
|
||||
const CACHE_TTL = 5000; // 5 秒缓存
|
||||
|
||||
function getCacheKey(config: {
|
||||
url?: string;
|
||||
params?: unknown;
|
||||
method?: string;
|
||||
}): string {
|
||||
return `${config.method || "get"}:${config.url || ""}:${JSON.stringify(config.params || {})}`;
|
||||
}
|
||||
|
||||
const defaultAdapter = axios.getAdapter(axios.defaults.adapter);
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: "/api/v1",
|
||||
timeout: 10000,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
adapter: (config) => {
|
||||
// GET 请求检查缓存
|
||||
if (config.method === "get" && config.url) {
|
||||
const key = getCacheKey(config);
|
||||
const entry = requestCache.get(key);
|
||||
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
|
||||
return Promise.resolve({
|
||||
data: entry.data,
|
||||
status: 200,
|
||||
statusText: "OK (cached)",
|
||||
headers: new axios.AxiosHeaders(),
|
||||
config,
|
||||
});
|
||||
}
|
||||
}
|
||||
return defaultAdapter(config);
|
||||
},
|
||||
});
|
||||
|
||||
// Decode JWT payload without external library
|
||||
function decodeJwtPayload(
|
||||
token: string,
|
||||
): { exp?: number; sub?: string } | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = JSON.parse(
|
||||
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")),
|
||||
);
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if token is expired or about to expire (within 30s buffer)
|
||||
function isTokenExpiringSoon(token: string): boolean {
|
||||
const payload = decodeJwtPayload(token);
|
||||
if (!payload?.exp) return true;
|
||||
return Date.now() / 1000 > payload.exp - 30;
|
||||
}
|
||||
|
||||
// Request interceptor: attach access token + proactive refresh
|
||||
client.interceptors.request.use(async (config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
// If token is about to expire, proactively refresh before sending the request
|
||||
if (isTokenExpiringSoon(token)) {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (refreshToken && !isRefreshing) {
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const { data } = await axios.post("/api/v1/auth/refresh", {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
const newAccess = data.data.access_token;
|
||||
const newRefresh = data.data.refresh_token;
|
||||
|
||||
// 验证新 token 的用户身份一致
|
||||
const currentUserSub = decodeJwtPayload(token)?.sub;
|
||||
const newTokenSub = decodeJwtPayload(newAccess)?.sub;
|
||||
if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
window.location.hash = "/login";
|
||||
return Promise.reject(new Error("身份验证失败,请重新登录"));
|
||||
}
|
||||
|
||||
localStorage.setItem("access_token", newAccess);
|
||||
localStorage.setItem("refresh_token", newRefresh);
|
||||
processQueue(null, newAccess);
|
||||
config.headers.Authorization = `Bearer ${newAccess}`;
|
||||
return config;
|
||||
} catch {
|
||||
processQueue(new Error("refresh failed"), null);
|
||||
// Continue with old token, let 401 handler deal with it
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器:缓存 GET 响应 + 自动刷新 token
|
||||
client.interceptors.response.use(
|
||||
(response) => {
|
||||
// 缓存 GET 响应
|
||||
if (response.config.method === "get" && response.config.url) {
|
||||
const key = getCacheKey(response.config);
|
||||
requestCache.set(key, { data: response.data, timestamp: Date.now() });
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.url?.includes("/auth/login")
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return client(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (!refreshToken) throw new Error("No refresh token");
|
||||
|
||||
const { data } = await axios.post("/api/v1/auth/refresh", {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const newAccessToken = data.data.access_token;
|
||||
const newRefreshToken = data.data.refresh_token;
|
||||
|
||||
// 验证新 token 的用户身份与当前用户一致,防止并发场景下身份切换
|
||||
const currentToken = localStorage.getItem("access_token");
|
||||
const currentUserSub = currentToken
|
||||
? decodeJwtPayload(currentToken)?.sub
|
||||
: null;
|
||||
const newTokenSub = decodeJwtPayload(newAccessToken)?.sub;
|
||||
if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) {
|
||||
// 身份不一致,强制登出
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
window.location.hash = "/login";
|
||||
return Promise.reject(new Error("身份验证失败,请重新登录"));
|
||||
}
|
||||
|
||||
localStorage.setItem("access_token", newAccessToken);
|
||||
localStorage.setItem("refresh_token", newRefreshToken);
|
||||
|
||||
processQueue(null, newAccessToken);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return client(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.hash = "/login";
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 全局错误提示(仅对未被组件处理的错误显示)
|
||||
let globalErrorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function showGlobalError(msg: string) {
|
||||
// 防止短时间内弹出大量相同提示
|
||||
if (globalErrorTimer) return;
|
||||
antMessage.error(msg, 3);
|
||||
globalErrorTimer = setTimeout(() => {
|
||||
globalErrorTimer = null;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行
|
||||
// 组件可通过 axios config 中设置 skipGlobalError: true 来抑制全局提示
|
||||
declare module "axios" {
|
||||
interface AxiosRequestConfig {
|
||||
skipGlobalError?: boolean;
|
||||
}
|
||||
interface InternalAxiosRequestConfig {
|
||||
skipGlobalError?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.config?.skipGlobalError) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (!error.response) {
|
||||
showGlobalError("网络连接异常,请检查网络");
|
||||
} else if (error.response.status === 403) {
|
||||
// 403 通常是权限不足,不全局提示 — 组件层通过 AuthButton 已隐藏操作入口
|
||||
} else if (error.response.status === 404) {
|
||||
// 404 通常由组件自行处理(如跳转),不全局提示
|
||||
} else if (error.response.status >= 500) {
|
||||
showGlobalError("服务器异常,请稍后重试");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null) {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (token) resolve(token);
|
||||
else reject(error);
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
// 清除缓存(登录/登出时调用)
|
||||
export function clearApiCache() {
|
||||
requestCache.clear();
|
||||
}
|
||||
|
||||
// 通用错误处理:提取后端错误消息并展示
|
||||
export function handleApiError(err: unknown, fallback = "操作失败"): string {
|
||||
const msg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || fallback;
|
||||
antMessage.error(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
export default client;
|
||||
197
apps/web/src/api/config-modules.test.ts
Normal file
197
apps/web/src/api/config-modules.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* config-modules API 契约测试(menus + settings + languages + numberingRules + themes)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as menusApi from './menus'
|
||||
import * as settingsApi from './settings'
|
||||
import * as languagesApi from './languages'
|
||||
import * as numberingApi from './numberingRules'
|
||||
import * as themesApi from './themes'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// menus
|
||||
// ============================================================
|
||||
describe('menus API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('getMenus 应调用 GET /config/menus', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await menusApi.getMenus()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/menus')
|
||||
})
|
||||
|
||||
it('getMenusForUser 应调用 GET /menus/user', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await menusApi.getMenusForUser()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/menus/user')
|
||||
})
|
||||
|
||||
it('batchSaveMenus 应调用 PUT /config/menus 并传递 menus 数组', async () => {
|
||||
mockPut.mockResolvedValue(undefined)
|
||||
const menus = [{ title: '仪表盘', path: '/dashboard' }]
|
||||
await menusApi.batchSaveMenus(menus)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/menus', { menus })
|
||||
})
|
||||
|
||||
it('createMenu 应调用 POST /config/menus', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { title: '新菜单', path: '/new', sort_order: 10 }
|
||||
await menusApi.createMenu(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/config/menus', req)
|
||||
})
|
||||
|
||||
it('deleteMenu 应调用 DELETE /config/menus/:id 并在 body 传递 version', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await menusApi.deleteMenu('menu-001', 3)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/config/menus/menu-001', { data: { version: 3 } })
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// settings
|
||||
// ============================================================
|
||||
describe('settings API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('getSetting 应调用 GET /config/settings/:key 并传递 scope 参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await settingsApi.getSetting('site.name', 'global', 'org-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/settings/site.name', {
|
||||
params: { scope: 'global', scope_id: 'org-001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('updateSetting 应调用 PUT /config/settings/:key', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
await settingsApi.updateSetting('site.name', '新名称', 1)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/settings/site.name', {
|
||||
setting_value: '新名称',
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('deleteSetting 应调用 DELETE /config/settings/:key', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await settingsApi.deleteSetting('site.name', 2)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/config/settings/site.name', { data: { version: 2 } })
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// languages
|
||||
// ============================================================
|
||||
describe('languages API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listLanguages 应调用 GET /config/languages', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await languagesApi.listLanguages()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/languages')
|
||||
})
|
||||
|
||||
it('updateLanguage 应调用 PUT /config/languages/:code', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
await languagesApi.updateLanguage('zh-CN', { is_active: true, name: '简体中文' })
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/languages/zh-CN', {
|
||||
is_active: true,
|
||||
name: '简体中文',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// numberingRules
|
||||
// ============================================================
|
||||
describe('numberingRules API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listNumberingRules 应调用 GET /config/numbering-rules', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await numberingApi.listNumberingRules(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/numbering-rules', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('createNumberingRule 应调用 POST /config/numbering-rules', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '患者编号', code: 'patient', prefix: 'P', seq_length: 6 }
|
||||
await numberingApi.createNumberingRule(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/config/numbering-rules', req)
|
||||
})
|
||||
|
||||
it('updateNumberingRule 应调用 PUT /config/numbering-rules/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { prefix: 'HMS', version: 1 }
|
||||
await numberingApi.updateNumberingRule('nr-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/numbering-rules/nr-001', req)
|
||||
})
|
||||
|
||||
it('generateNumber 应调用 POST /config/numbering-rules/:id/generate', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await numberingApi.generateNumber('nr-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/config/numbering-rules/nr-001/generate')
|
||||
})
|
||||
|
||||
it('deleteNumberingRule 应调用 DELETE /config/numbering-rules/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await numberingApi.deleteNumberingRule('nr-001', 1)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/config/numbering-rules/nr-001', { data: { version: 1 } })
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// themes
|
||||
// ============================================================
|
||||
describe('themes API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('getTheme 应调用 GET /config/themes', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await themesApi.getTheme()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/themes')
|
||||
})
|
||||
|
||||
it('updateTheme 应调用 PUT /config/themes', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const theme = { primary_color: '#1890ff', brand_name: 'HMS' }
|
||||
await themesApi.updateTheme(theme)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/themes', theme)
|
||||
})
|
||||
})
|
||||
96
apps/web/src/api/dictionaries.test.ts
Normal file
96
apps/web/src/api/dictionaries.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* dictionaries API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as dictApi from './dictionaries'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('dictionaries API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listDictionaries 应调用 GET /config/dictionaries 并传递分页参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await dictApi.listDictionaries(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/dictionaries', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('createDictionary 应调用 POST /config/dictionaries', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '性别', code: 'gender', description: '性别字典' }
|
||||
await dictApi.createDictionary(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/config/dictionaries', req)
|
||||
})
|
||||
|
||||
it('updateDictionary 应调用 PUT /config/dictionaries/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '性别(更新)', version: 1 }
|
||||
await dictApi.updateDictionary('dict-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/dictionaries/dict-001', req)
|
||||
})
|
||||
|
||||
it('deleteDictionary 应调用 DELETE /config/dictionaries/:id 并在 body 传递 version', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await dictApi.deleteDictionary('dict-001', 2)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/config/dictionaries/dict-001', {
|
||||
data: { version: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('listItemsByCode 应调用 GET /config/dictionaries/items 并传递 code 参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await dictApi.listItemsByCode('gender')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/config/dictionaries/items', {
|
||||
params: { code: 'gender' },
|
||||
})
|
||||
})
|
||||
|
||||
it('createDictionaryItem 应调用 POST /config/dictionaries/:id/items', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { label: '男', value: 'male', sort_order: 1 }
|
||||
await dictApi.createDictionaryItem('dict-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/config/dictionaries/dict-001/items', req)
|
||||
})
|
||||
|
||||
it('updateDictionaryItem 应调用 PUT /config/dictionaries/:dictId/items/:itemId', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { label: '女', version: 1 }
|
||||
await dictApi.updateDictionaryItem('dict-001', 'item-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/config/dictionaries/dict-001/items/item-001', req)
|
||||
})
|
||||
|
||||
it('deleteDictionaryItem 应调用 DELETE /config/dictionaries/:dictId/items/:itemId', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await dictApi.deleteDictionaryItem('dict-001', 'item-001', 1)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/config/dictionaries/dict-001/items/item-001', {
|
||||
data: { version: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
111
apps/web/src/api/dictionaries.ts
Normal file
111
apps/web/src/api/dictionaries.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface DictionaryItemInfo {
|
||||
id: string;
|
||||
dictionary_id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sort_order: number;
|
||||
color?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface DictionaryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
items: DictionaryItemInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateDictionaryRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDictionaryRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listDictionaries(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<DictionaryInfo> }>(
|
||||
'/config/dictionaries',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createDictionary(req: CreateDictionaryRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: DictionaryInfo }>(
|
||||
'/config/dictionaries',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateDictionary(id: string, req: UpdateDictionaryRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: DictionaryInfo }>(
|
||||
`/config/dictionaries/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteDictionary(id: string, version: number) {
|
||||
await client.delete(`/config/dictionaries/${id}`, { data: { version } });
|
||||
}
|
||||
|
||||
export async function listItemsByCode(code: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: DictionaryItemInfo[] }>(
|
||||
'/config/dictionaries/items',
|
||||
{ params: { code } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface CreateDictionaryItemRequest {
|
||||
label: string;
|
||||
value: string;
|
||||
sort_order?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDictionaryItemRequest {
|
||||
label?: string;
|
||||
value?: string;
|
||||
sort_order?: number;
|
||||
color?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function createDictionaryItem(
|
||||
dictionaryId: string,
|
||||
req: CreateDictionaryItemRequest,
|
||||
) {
|
||||
const { data } = await client.post<{ success: boolean; data: DictionaryItemInfo }>(
|
||||
`/config/dictionaries/${dictionaryId}/items`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateDictionaryItem(
|
||||
dictionaryId: string,
|
||||
itemId: string,
|
||||
req: UpdateDictionaryItemRequest,
|
||||
) {
|
||||
const { data } = await client.put<{ success: boolean; data: DictionaryItemInfo }>(
|
||||
`/config/dictionaries/${dictionaryId}/items/${itemId}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteDictionaryItem(dictionaryId: string, itemId: string, version: number) {
|
||||
await client.delete(`/config/dictionaries/${dictionaryId}/items/${itemId}`, { data: { version } });
|
||||
}
|
||||
34
apps/web/src/api/languages.ts
Normal file
34
apps/web/src/api/languages.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import client from './client';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface LanguageInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateLanguageRequest {
|
||||
is_active: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// --- API Functions ---
|
||||
|
||||
export async function listLanguages(): Promise<LanguageInfo[]> {
|
||||
const { data } = await client.get<{ success: boolean; data: LanguageInfo[] }>(
|
||||
'/config/languages',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateLanguage(
|
||||
code: string,
|
||||
req: UpdateLanguageRequest,
|
||||
): Promise<LanguageInfo> {
|
||||
const { data } = await client.put<{ success: boolean; data: LanguageInfo }>(
|
||||
`/config/languages/${code}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
63
apps/web/src/api/menus.ts
Normal file
63
apps/web/src/api/menus.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import client from './client';
|
||||
|
||||
export interface MenuInfo {
|
||||
id: string;
|
||||
parent_id?: string;
|
||||
title: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
sort_order: number;
|
||||
visible: boolean;
|
||||
menu_type: string;
|
||||
permission?: string;
|
||||
children?: MenuInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface MenuItemReq {
|
||||
id?: string;
|
||||
parent_id?: string;
|
||||
title: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
sort_order?: number;
|
||||
visible?: boolean;
|
||||
menu_type?: string;
|
||||
permission?: string;
|
||||
role_ids?: string[];
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export async function getMenus() {
|
||||
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/config/menus');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getMenusForUser() {
|
||||
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/menus/user');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function batchSaveMenus(menus: MenuItemReq[]) {
|
||||
await client.put('/config/menus', { menus });
|
||||
}
|
||||
|
||||
export async function createMenu(req: MenuItemReq) {
|
||||
const { data } = await client.post<{ success: boolean; data: MenuInfo }>(
|
||||
'/config/menus',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateMenu(id: string, req: MenuItemReq) {
|
||||
const { data } = await client.put<{ success: boolean; data: MenuInfo }>(
|
||||
`/config/menus/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteMenu(id: string, version: number) {
|
||||
await client.delete(`/config/menus/${id}`, { data: { version } });
|
||||
}
|
||||
40
apps/web/src/api/messageTemplates.ts
Normal file
40
apps/web/src/api/messageTemplates.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface MessageTemplateInfo {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
channel: string;
|
||||
title_template: string;
|
||||
body_template: string;
|
||||
language: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplateRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
channel?: string;
|
||||
title_template: string;
|
||||
body_template: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export async function listTemplates(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageTemplateInfo> }>(
|
||||
'/message-templates',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createTemplate(req: CreateTemplateRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: MessageTemplateInfo }>(
|
||||
'/message-templates',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
100
apps/web/src/api/messages.test.ts
Normal file
100
apps/web/src/api/messages.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* messages + messageTemplates API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as messagesApi from './messages'
|
||||
import * as templateApi from './messageTemplates'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('messages API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listMessages 应调用 GET /messages 并传递查询参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await messagesApi.listMessages({ page: 2, page_size: 10, is_read: false, priority: 'high' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/messages', {
|
||||
params: expect.objectContaining({
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
is_read: false,
|
||||
priority: 'high',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('getUnreadCount 应调用 GET /messages/unread-count', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await messagesApi.getUnreadCount()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/messages/unread-count')
|
||||
})
|
||||
|
||||
it('markRead 应调用 PUT /messages/:id/read', async () => {
|
||||
mockPut.mockResolvedValue({ data: { success: true } })
|
||||
await messagesApi.markRead('msg-001')
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/messages/msg-001/read')
|
||||
})
|
||||
|
||||
it('markAllRead 应调用 PUT /messages/read-all', async () => {
|
||||
mockPut.mockResolvedValue({ data: { success: true } })
|
||||
await messagesApi.markAllRead()
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/messages/read-all')
|
||||
})
|
||||
|
||||
it('deleteMessage 应调用 DELETE /messages/:id', async () => {
|
||||
mockDelete.mockResolvedValue({ data: { success: true } })
|
||||
await messagesApi.deleteMessage('msg-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/messages/msg-001')
|
||||
})
|
||||
|
||||
it('sendMessage 应调用 POST /messages 并传递请求体', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { title: '通知', body: '内容', recipient_id: 'u-001' }
|
||||
await messagesApi.sendMessage(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/messages', req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageTemplates API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listTemplates 应调用 GET /message-templates 并传递分页参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await templateApi.listTemplates(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/message-templates', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('createTemplate 应调用 POST /message-templates', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '预约提醒', code: 'appointment_reminder', title_template: '预约提醒', body_template: '您有预约' }
|
||||
await templateApi.createTemplate(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/message-templates', req)
|
||||
})
|
||||
})
|
||||
98
apps/web/src/api/messages.ts
Normal file
98
apps/web/src/api/messages.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface MessageInfo {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
template_id?: string;
|
||||
sender_id?: string;
|
||||
sender_type: string;
|
||||
recipient_id: string;
|
||||
recipient_type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
priority: string;
|
||||
business_type?: string;
|
||||
business_id?: string;
|
||||
is_read: boolean;
|
||||
read_at?: string;
|
||||
is_archived: boolean;
|
||||
status: string;
|
||||
sent_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
title: string;
|
||||
body: string;
|
||||
recipient_id: string;
|
||||
recipient_type?: string;
|
||||
priority?: string;
|
||||
template_id?: string;
|
||||
business_type?: string;
|
||||
business_id?: string;
|
||||
}
|
||||
|
||||
export interface MessageQuery {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
is_read?: boolean;
|
||||
priority?: string;
|
||||
business_type?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export async function listMessages(query: MessageQuery = {}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageInfo> }>(
|
||||
'/messages',
|
||||
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getUnreadCount() {
|
||||
const { data } = await client.get<{ success: boolean; data: { count: number } }>(
|
||||
'/messages/unread-count',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function markRead(id: string) {
|
||||
const { data } = await client.put<{ success: boolean }>(
|
||||
`/messages/${id}/read`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function markAllRead() {
|
||||
const { data } = await client.put<{ success: boolean }>(
|
||||
'/messages/read-all',
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteMessage(id: string) {
|
||||
const { data } = await client.delete<{ success: boolean }>(
|
||||
`/messages/${id}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendMessage(req: SendMessageRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: MessageInfo }>(
|
||||
'/messages',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface SubscriptionUpdateReq {
|
||||
dnd_enabled: boolean;
|
||||
dnd_start?: string;
|
||||
dnd_end?: string;
|
||||
}
|
||||
|
||||
export async function updateSubscription(req: SubscriptionUpdateReq) {
|
||||
await client.put('/message-subscriptions', req);
|
||||
}
|
||||
73
apps/web/src/api/numberingRules.ts
Normal file
73
apps/web/src/api/numberingRules.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface NumberingRuleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
prefix: string;
|
||||
date_format?: string;
|
||||
seq_length: number;
|
||||
seq_start: number;
|
||||
seq_current: number;
|
||||
separator: string;
|
||||
reset_cycle: string;
|
||||
last_reset_date?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateNumberingRuleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
prefix?: string;
|
||||
date_format?: string;
|
||||
seq_length?: number;
|
||||
seq_start?: number;
|
||||
separator?: string;
|
||||
reset_cycle?: string;
|
||||
}
|
||||
|
||||
export interface UpdateNumberingRuleRequest {
|
||||
name?: string;
|
||||
prefix?: string;
|
||||
date_format?: string;
|
||||
seq_length?: number;
|
||||
separator?: string;
|
||||
reset_cycle?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listNumberingRules(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<NumberingRuleInfo> }>(
|
||||
'/config/numbering-rules',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createNumberingRule(req: CreateNumberingRuleRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: NumberingRuleInfo }>(
|
||||
'/config/numbering-rules',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateNumberingRule(id: string, req: UpdateNumberingRuleRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: NumberingRuleInfo }>(
|
||||
`/config/numbering-rules/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function generateNumber(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: { number: string } }>(
|
||||
`/config/numbering-rules/${id}/generate`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteNumberingRule(id: string, version: number) {
|
||||
await client.delete(`/config/numbering-rules/${id}`, { data: { version } });
|
||||
}
|
||||
126
apps/web/src/api/orgs.test.ts
Normal file
126
apps/web/src/api/orgs.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* orgs API 契约测试(组织/部门/岗位)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as orgsApi from './orgs'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('organizations API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listOrgTree 应调用 GET /organizations', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await orgsApi.listOrgTree()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/organizations')
|
||||
})
|
||||
|
||||
it('createOrg 应调用 POST /organizations', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '总公司', code: 'HQ' }
|
||||
await orgsApi.createOrg(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/organizations', req)
|
||||
})
|
||||
|
||||
it('updateOrg 应调用 PUT /organizations/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '改名', version: 1 }
|
||||
await orgsApi.updateOrg('org-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/organizations/org-001', req)
|
||||
})
|
||||
|
||||
it('deleteOrg 应调用 DELETE /organizations/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await orgsApi.deleteOrg('org-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/organizations/org-001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('departments API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listDeptTree 应调用 GET /organizations/:orgId/departments', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await orgsApi.listDeptTree('org-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/organizations/org-001/departments')
|
||||
})
|
||||
|
||||
it('createDept 应调用 POST /organizations/:orgId/departments', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '内科', code: 'NK' }
|
||||
await orgsApi.createDept('org-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/organizations/org-001/departments', req)
|
||||
})
|
||||
|
||||
it('updateDept 应调用 PUT /departments/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '内科(更新)', version: 1 }
|
||||
await orgsApi.updateDept('dept-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/departments/dept-001', req)
|
||||
})
|
||||
|
||||
it('deleteDept 应调用 DELETE /departments/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await orgsApi.deleteDept('dept-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/departments/dept-001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('positions API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listPositions 应调用 GET /departments/:deptId/positions', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await orgsApi.listPositions('dept-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/departments/dept-001/positions')
|
||||
})
|
||||
|
||||
it('createPosition 应调用 POST /departments/:deptId/positions', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '主治医师', code: 'ZYS' }
|
||||
await orgsApi.createPosition('dept-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/departments/dept-001/positions', req)
|
||||
})
|
||||
|
||||
it('updatePosition 应调用 PUT /positions/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '主任医师', version: 1 }
|
||||
await orgsApi.updatePosition('pos-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/positions/pos-001', req)
|
||||
})
|
||||
|
||||
it('deletePosition 应调用 DELETE /positions/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await orgsApi.deletePosition('pos-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/positions/pos-001')
|
||||
})
|
||||
})
|
||||
174
apps/web/src/api/orgs.ts
Normal file
174
apps/web/src/api/orgs.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import client from './client';
|
||||
|
||||
// --- Organization types ---
|
||||
|
||||
export interface OrganizationInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
path?: string;
|
||||
level: number;
|
||||
sort_order: number;
|
||||
children: OrganizationInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateOrganizationRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateOrganizationRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Department types ---
|
||||
|
||||
export interface DepartmentInfo {
|
||||
id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
manager_id?: string;
|
||||
path?: string;
|
||||
sort_order: number;
|
||||
children: DepartmentInfo[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateDepartmentRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
parent_id?: string;
|
||||
manager_id?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateDepartmentRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
manager_id?: string;
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Position types ---
|
||||
|
||||
export interface PositionInfo {
|
||||
id: string;
|
||||
dept_id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
level: number;
|
||||
sort_order: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePositionRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --- Organization API ---
|
||||
|
||||
export async function listOrgTree() {
|
||||
const { data } = await client.get<{ success: boolean; data: OrganizationInfo[] }>(
|
||||
'/organizations',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createOrg(req: CreateOrganizationRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: OrganizationInfo }>(
|
||||
'/organizations',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateOrg(id: string, req: UpdateOrganizationRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: OrganizationInfo }>(
|
||||
`/organizations/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteOrg(id: string) {
|
||||
await client.delete(`/organizations/${id}`);
|
||||
}
|
||||
|
||||
// --- Department API ---
|
||||
|
||||
export async function listDeptTree(orgId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: DepartmentInfo[] }>(
|
||||
`/organizations/${orgId}/departments`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createDept(orgId: string, req: CreateDepartmentRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: DepartmentInfo }>(
|
||||
`/organizations/${orgId}/departments`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteDept(id: string) {
|
||||
await client.delete(`/departments/${id}`);
|
||||
}
|
||||
|
||||
export async function updateDept(id: string, req: UpdateDepartmentRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: DepartmentInfo }>(
|
||||
`/departments/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// --- Position API ---
|
||||
|
||||
export async function listPositions(deptId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PositionInfo[] }>(
|
||||
`/departments/${deptId}/positions`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createPosition(deptId: string, req: CreatePositionRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: PositionInfo }>(
|
||||
`/departments/${deptId}/positions`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deletePosition(id: string) {
|
||||
await client.delete(`/positions/${id}`);
|
||||
}
|
||||
|
||||
export async function updatePosition(id: string, req: UpdatePositionRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: PositionInfo }>(
|
||||
`/positions/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
131
apps/web/src/api/pluginData.test.ts
Normal file
131
apps/web/src/api/pluginData.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* pluginData API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockPatch = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
patch: (...args: unknown[]) => mockPatch(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as pluginDataApi from './pluginData'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('pluginData CRUD', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listPluginData 应调用 GET /plugins/:pid/:entity 并传递分页和过滤参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pluginDataApi.listPluginData('crm', 'customer', 1, 20, {
|
||||
filter: { status: 'active' },
|
||||
search: '张',
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
})
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/plugins/crm/customer', {
|
||||
params: expect.objectContaining({
|
||||
page: '1',
|
||||
page_size: '20',
|
||||
search: '张',
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('getPluginData 应调用 GET /plugins/:pid/:entity/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pluginDataApi.getPluginData('crm', 'customer', 'rec-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/plugins/crm/customer/rec-001')
|
||||
})
|
||||
|
||||
it('createPluginData 应调用 POST /plugins/:pid/:entity 并包裹 data', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const recordData = { name: '客户A', phone: '13800138000' }
|
||||
await pluginDataApi.createPluginData('crm', 'customer', recordData)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/plugins/crm/customer', { data: recordData })
|
||||
})
|
||||
|
||||
it('updatePluginData 应调用 PUT /plugins/:pid/:entity/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const recordData = { name: '客户A(更新)' }
|
||||
await pluginDataApi.updatePluginData('crm', 'customer', 'rec-001', recordData, 2)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/plugins/crm/customer/rec-001', {
|
||||
data: recordData,
|
||||
version: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('deletePluginData 应调用 DELETE /plugins/:pid/:entity/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await pluginDataApi.deletePluginData('crm', 'customer', 'rec-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/plugins/crm/customer/rec-001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pluginData 高级查询', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('countPluginData 应调用 GET /plugins/:pid/:entity/count', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pluginDataApi.countPluginData('crm', 'customer', { filter: { status: 'active' } })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/plugins/crm/customer/count', {
|
||||
params: expect.objectContaining({
|
||||
filter: '{"status":"active"}',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('aggregatePluginData 应调用 GET /plugins/:pid/:entity/aggregate', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pluginDataApi.aggregatePluginData('crm', 'customer', 'status')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/plugins/crm/customer/aggregate', {
|
||||
params: { group_by: 'status' },
|
||||
})
|
||||
})
|
||||
|
||||
it('batchPluginData 应调用 POST /plugins/:pid/:entity/batch', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { action: 'delete', ids: ['rec-1', 'rec-2'] }
|
||||
await pluginDataApi.batchPluginData('crm', 'customer', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/plugins/crm/customer/batch', req)
|
||||
})
|
||||
|
||||
it('resolveRefLabels 应调用 POST /plugins/:pid/:entity/resolve-labels', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const fields = { customer_tag_id: ['tag-1', 'tag-2'] }
|
||||
await pluginDataApi.resolveRefLabels('crm', 'customer', fields)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/plugins/crm/customer/resolve-labels', { fields })
|
||||
})
|
||||
|
||||
it('importPluginData 应调用 POST /plugins/:pid/:entity/import', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const rows = [{ name: '客户A' }, { name: '客户B' }]
|
||||
await pluginDataApi.importPluginData('crm', 'customer', rows)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/plugins/crm/customer/import', { rows })
|
||||
})
|
||||
})
|
||||
281
apps/web/src/api/pluginData.ts
Normal file
281
apps/web/src/api/pluginData.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import client from './client';
|
||||
|
||||
export interface PluginDataRecord {
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
interface PaginatedDataResponse {
|
||||
data: PluginDataRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface PluginDataListOptions {
|
||||
filter?: Record<string, string>;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export async function listPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
options?: PluginDataListOptions,
|
||||
) {
|
||||
const params: Record<string, string> = {
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
};
|
||||
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||||
if (options?.search) params.search = options.search;
|
||||
if (options?.sort_by) params.sort_by = options.sort_by;
|
||||
if (options?.sort_order) params.sort_order = options.sort_order;
|
||||
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedDataResponse }>(
|
||||
`/plugins/${pluginId}/${entity}`,
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getPluginData(pluginId: string, entity: string, id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
recordData: Record<string, unknown>,
|
||||
) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}`,
|
||||
{ data: recordData },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updatePluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
id: string,
|
||||
recordData: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
const { data } = await client.put<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}/${id}`,
|
||||
{ data: recordData, version },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deletePluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
id: string,
|
||||
) {
|
||||
await client.delete(`/plugins/${pluginId}/${entity}/${id}`);
|
||||
}
|
||||
|
||||
export async function countPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
options?: { filter?: Record<string, string>; search?: string },
|
||||
) {
|
||||
const params: Record<string, string> = {};
|
||||
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||||
if (options?.search) params.search = options.search;
|
||||
|
||||
const { data } = await client.get<{ success: boolean; data: number }>(
|
||||
`/plugins/${pluginId}/${entity}/count`,
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface AggregateItem {
|
||||
key: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function aggregatePluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
groupBy: string,
|
||||
filter?: Record<string, string>,
|
||||
) {
|
||||
const params: Record<string, string> = { group_by: groupBy };
|
||||
if (filter) params.filter = JSON.stringify(filter);
|
||||
|
||||
const { data } = await client.get<{ success: boolean; data: AggregateItem[] }>(
|
||||
`/plugins/${pluginId}/${entity}/aggregate`,
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ── 批量操作 ──
|
||||
|
||||
export async function batchPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
req: { action: string; ids: string[]; data?: Record<string, unknown> },
|
||||
) {
|
||||
const { data } = await client.post<{ success: boolean; data: unknown }>(
|
||||
`/plugins/${pluginId}/${entity}/batch`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ── 部分更新 ──
|
||||
|
||||
export async function patchPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
id: string,
|
||||
req: { data: Record<string, unknown>; version: number },
|
||||
) {
|
||||
const { data } = await client.patch<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ── 时间序列 ──
|
||||
|
||||
export async function getPluginTimeseries(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
params: {
|
||||
time_field: string;
|
||||
time_grain: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
},
|
||||
) {
|
||||
const { data } = await client.get<{ success: boolean; data: unknown }>(
|
||||
`/plugins/${pluginId}/${entity}/timeseries`,
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ─── 跨插件引用 API ──────────────────────────────────────────────────
|
||||
|
||||
export interface ResolveLabelsResult {
|
||||
labels: Record<string, Record<string, string | null>>;
|
||||
meta: Record<string, {
|
||||
target_plugin: string;
|
||||
target_entity: string;
|
||||
label_field: string;
|
||||
plugin_installed: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function resolveRefLabels(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
fields: Record<string, string[]>,
|
||||
): Promise<ResolveLabelsResult> {
|
||||
const { data } = await client.post<{ success: boolean; data: ResolveLabelsResult }>(
|
||||
`/plugins/${pluginId}/${entity}/resolve-labels`,
|
||||
{ fields },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface PublicEntity {
|
||||
manifest_id: string;
|
||||
plugin_id: string;
|
||||
entity_name: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export async function getPluginEntityRegistry(): Promise<PublicEntity[]> {
|
||||
const { data } = await client.get<{ success: boolean; data: PublicEntity[] }>(
|
||||
'/plugin-registry/entities',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ─── 数据导入导出 API ──────────────────────────────────────────────────
|
||||
|
||||
export interface ExportOptions {
|
||||
filter?: Record<string, string>;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
format?: 'json' | 'csv' | 'xlsx';
|
||||
}
|
||||
|
||||
export async function exportPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
options?: ExportOptions,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||||
if (options?.search) params.search = options.search;
|
||||
if (options?.sort_by) params.sort_by = options.sort_by;
|
||||
if (options?.sort_order) params.sort_order = options.sort_order;
|
||||
|
||||
const { data } = await client.get<{ success: boolean; data: Record<string, unknown>[] }>(
|
||||
`/plugins/${pluginId}/${entity}/export`,
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function exportPluginDataAsBlob(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
format: 'csv' | 'xlsx',
|
||||
options?: Omit<ExportOptions, 'format'>,
|
||||
): Promise<Blob> {
|
||||
const params: Record<string, string> = { format };
|
||||
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||||
if (options?.search) params.search = options.search;
|
||||
if (options?.sort_by) params.sort_by = options.sort_by;
|
||||
if (options?.sort_order) params.sort_order = options.sort_order;
|
||||
|
||||
const response = await client.get(
|
||||
`/plugins/${pluginId}/${entity}/export`,
|
||||
{ params, responseType: 'blob' },
|
||||
);
|
||||
return response.data as Blob;
|
||||
}
|
||||
|
||||
export interface ImportRowError {
|
||||
row: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success_count: number;
|
||||
error_count: number;
|
||||
errors: ImportRowError[];
|
||||
}
|
||||
|
||||
export async function importPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
rows: Record<string, unknown>[],
|
||||
): Promise<ImportResult> {
|
||||
const { data } = await client.post<{ success: boolean; data: ImportResult }>(
|
||||
`/plugins/${pluginId}/${entity}/import`,
|
||||
{ rows },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
132
apps/web/src/api/plugins.test.ts
Normal file
132
apps/web/src/api/plugins.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* plugins API 契约测试(插件管理 + 市场)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as pluginsApi from './plugins'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('plugins management API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listPlugins 应调用 GET /admin/plugins 并传递分页和状态', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pluginsApi.listPlugins(1, 10, 'enabled')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/admin/plugins', {
|
||||
params: { page: 1, page_size: 10, status: 'enabled' },
|
||||
})
|
||||
})
|
||||
|
||||
it('getPlugin 应调用 GET /admin/plugins/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pluginsApi.getPlugin('plug-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/admin/plugins/plug-001')
|
||||
})
|
||||
|
||||
it('installPlugin 应调用 POST /admin/plugins/:id/install', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await pluginsApi.installPlugin('plug-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/admin/plugins/plug-001/install')
|
||||
})
|
||||
|
||||
it('enablePlugin 应调用 POST /admin/plugins/:id/enable', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await pluginsApi.enablePlugin('plug-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/admin/plugins/plug-001/enable')
|
||||
})
|
||||
|
||||
it('disablePlugin 应调用 POST /admin/plugins/:id/disable', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await pluginsApi.disablePlugin('plug-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/admin/plugins/plug-001/disable')
|
||||
})
|
||||
|
||||
it('purgePlugin 应调用 DELETE /admin/plugins/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await pluginsApi.purgePlugin('plug-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/admin/plugins/plug-001')
|
||||
})
|
||||
|
||||
it('getPluginHealth 应调用 GET /admin/plugins/:id/health', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pluginsApi.getPluginHealth('plug-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/admin/plugins/plug-001/health')
|
||||
})
|
||||
|
||||
it('updatePluginConfig 应调用 PUT /admin/plugins/:id/config', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const config = { theme: 'dark' }
|
||||
await pluginsApi.updatePluginConfig('plug-001', config, 1)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/admin/plugins/plug-001/config', {
|
||||
config,
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('getPluginSchema 应调用 GET /admin/plugins/:id/schema', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pluginsApi.getPluginSchema('plug-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/admin/plugins/plug-001/schema')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin market API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listMarketEntries 应调用 GET /market/entries', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pluginsApi.listMarketEntries({ page: 1, page_size: 10, category: 'crm' })
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/market/entries', {
|
||||
params: { page: 1, page_size: 10, category: 'crm' },
|
||||
})
|
||||
})
|
||||
|
||||
it('getMarketEntry 应调用 GET /market/entries/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await pluginsApi.getMarketEntry('mkt-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/market/entries/mkt-001')
|
||||
})
|
||||
|
||||
it('installFromMarket 应调用 POST /market/entries/:id/install', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await pluginsApi.installFromMarket('mkt-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/market/entries/mkt-001/install')
|
||||
})
|
||||
|
||||
it('submitMarketReview 应调用 POST /market/entries/:id/reviews', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const review = { rating: 5, review_text: '很好用' }
|
||||
await pluginsApi.submitMarketReview('mkt-001', review)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/market/entries/mkt-001/reviews', review)
|
||||
})
|
||||
})
|
||||
375
apps/web/src/api/plugins.ts
Normal file
375
apps/web/src/api/plugins.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface PluginEntityInfo {
|
||||
name: string;
|
||||
display_name: string;
|
||||
table_name: string;
|
||||
}
|
||||
|
||||
export interface PluginPermissionInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type PluginStatus = 'uploaded' | 'installed' | 'enabled' | 'running' | 'disabled' | 'uninstalled';
|
||||
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
status: PluginStatus;
|
||||
config: Record<string, unknown>;
|
||||
installed_at?: string;
|
||||
enabled_at?: string;
|
||||
entities: PluginEntityInfo[];
|
||||
permissions?: PluginPermissionInfo[];
|
||||
record_version: number;
|
||||
}
|
||||
|
||||
export async function listPlugins(page = 1, pageSize = 20, status?: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<PluginInfo> }>(
|
||||
'/admin/plugins',
|
||||
{ params: { page, page_size: pageSize, status: status || undefined } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getPlugin(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function uploadPlugin(wasmFile: File, manifestToml: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('wasm', wasmFile);
|
||||
formData.append('manifest', manifestToml);
|
||||
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
'/admin/plugins/upload',
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function installPlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/install`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function enablePlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/enable`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function disablePlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/disable`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function uninstallPlugin(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/uninstall`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function purgePlugin(id: string) {
|
||||
await client.delete(`/admin/plugins/${id}`);
|
||||
}
|
||||
|
||||
export async function getPluginHealth(id: string) {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: { plugin_id: string; status: string; details: Record<string, unknown> };
|
||||
}>(`/admin/plugins/${id}/health`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updatePluginConfig(id: string, config: Record<string, unknown>, version: number) {
|
||||
const { data } = await client.put<{ success: boolean; data: PluginInfo }>(
|
||||
`/admin/plugins/${id}/config`,
|
||||
{ config, version },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getPluginSchema(id: string): Promise<PluginSchemaResponse> {
|
||||
const { data } = await client.get<{ success: boolean; data: PluginSchemaResponse }>(
|
||||
`/admin/plugins/${id}/schema`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ── Schema 类型定义 ──
|
||||
|
||||
export interface PluginFieldValidation {
|
||||
pattern?: string;
|
||||
message?: string;
|
||||
min_length?: number;
|
||||
max_length?: number;
|
||||
min_value?: number;
|
||||
max_value?: number;
|
||||
}
|
||||
|
||||
export interface PluginFieldSchema {
|
||||
name: string;
|
||||
field_type: string;
|
||||
required: boolean;
|
||||
display_name?: string;
|
||||
ui_widget?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
searchable?: boolean;
|
||||
filterable?: boolean;
|
||||
sortable?: boolean;
|
||||
visible_when?: string;
|
||||
unique?: boolean;
|
||||
ref_entity?: string;
|
||||
ref_label_field?: string;
|
||||
ref_search_fields?: string[];
|
||||
ref_plugin?: string;
|
||||
ref_fallback_label?: string;
|
||||
cascade_from?: string;
|
||||
cascade_filter?: string;
|
||||
validation?: PluginFieldValidation;
|
||||
}
|
||||
|
||||
export interface PluginRelationSchema {
|
||||
entity: string;
|
||||
foreign_key: string;
|
||||
on_delete: 'cascade' | 'nullify' | 'restrict';
|
||||
name?: string;
|
||||
type?: 'one_to_many' | 'many_to_one' | 'many_to_many';
|
||||
display_field?: string;
|
||||
}
|
||||
|
||||
export interface PluginEntitySchema {
|
||||
name: string;
|
||||
display_name: string;
|
||||
fields: PluginFieldSchema[];
|
||||
relations?: PluginRelationSchema[];
|
||||
data_scope?: boolean;
|
||||
is_public?: boolean;
|
||||
importable?: boolean;
|
||||
exportable?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginSchemaResponse {
|
||||
entities: PluginEntitySchema[];
|
||||
ui?: PluginUiSchema;
|
||||
settings?: PluginSettings;
|
||||
numbering?: PluginNumbering[];
|
||||
trigger_events?: PluginTriggerEvent[];
|
||||
}
|
||||
|
||||
export interface PluginUiSchema {
|
||||
pages: PluginPageSchema[];
|
||||
}
|
||||
|
||||
export type PluginPageSchema =
|
||||
| { type: 'crud'; entity: string; label: string; icon?: string; enable_search?: boolean; enable_views?: string[] }
|
||||
| { type: 'tree'; entity: string; label: string; icon?: string; id_field: string; parent_field: string; label_field: string }
|
||||
| { type: 'detail'; entity: string; label: string; sections: PluginSectionSchema[] }
|
||||
| { type: 'tabs'; label: string; icon?: string; tabs: PluginPageSchema[] }
|
||||
| { type: 'graph'; entity: string; label: string; relationship_entity: string; source_field: string; target_field: string; edge_label_field: string; node_label_field: string }
|
||||
| { type: 'dashboard'; label: string; widgets?: DashboardWidget[] }
|
||||
| {
|
||||
type: 'kanban';
|
||||
entity: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
lane_field: string;
|
||||
lane_order?: string[];
|
||||
card_title_field: string;
|
||||
card_subtitle_field?: string;
|
||||
card_fields?: string[];
|
||||
enable_drag?: boolean;
|
||||
};
|
||||
|
||||
export interface DashboardWidget {
|
||||
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart'
|
||||
| 'stat_cards' | 'action_list' | 'funnel' | 'card_list';
|
||||
entity: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
dimension_field?: string;
|
||||
dimension_order?: string[];
|
||||
metric?: string;
|
||||
// stat_cards
|
||||
cards?: StatCardDef[];
|
||||
// action_list
|
||||
max_items?: number;
|
||||
queries?: ActionQueryDef[];
|
||||
// funnel
|
||||
lane_field?: string;
|
||||
value_field?: string;
|
||||
lane_order?: string[];
|
||||
// card_list
|
||||
filter?: string;
|
||||
title_field?: string;
|
||||
subtitle_field?: string;
|
||||
tags?: string[];
|
||||
label?: string;
|
||||
label_field?: string;
|
||||
action?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface StatCardDef {
|
||||
entity: string;
|
||||
aggregate?: string;
|
||||
field?: string;
|
||||
filter?: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ActionQueryDef {
|
||||
entity: string;
|
||||
filter?: string;
|
||||
sort?: string;
|
||||
label_field: string;
|
||||
subtitle_field?: string;
|
||||
action: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export type PluginSectionSchema =
|
||||
| { type: 'fields'; label: string; fields: string[] }
|
||||
| { type: 'crud'; label: string; entity: string; filter_field?: string; enable_views?: string[] };
|
||||
|
||||
// ── P2 平台通用服务 — Settings 类型 ──
|
||||
|
||||
export type PluginSettingType =
|
||||
| 'text' | 'number' | 'boolean' | 'select' | 'multiselect'
|
||||
| 'color' | 'date' | 'datetime' | 'json';
|
||||
|
||||
export interface PluginSettingField {
|
||||
name: string;
|
||||
display_name: string;
|
||||
field_type: PluginSettingType;
|
||||
default_value?: unknown;
|
||||
required: boolean;
|
||||
description?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
range?: [number, number];
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface PluginSettings {
|
||||
fields: PluginSettingField[];
|
||||
}
|
||||
|
||||
// ── P2 平台通用服务 — Numbering 类型 ──
|
||||
|
||||
export interface PluginNumbering {
|
||||
entity: string;
|
||||
field: string;
|
||||
prefix: string;
|
||||
format: string;
|
||||
reset_rule: 'never' | 'daily' | 'monthly' | 'yearly';
|
||||
seq_length: number;
|
||||
separator?: string;
|
||||
}
|
||||
|
||||
// ── P2 平台通用服务 — TriggerEvent 类型 ──
|
||||
|
||||
export interface PluginTriggerEvent {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
entity: string;
|
||||
on: 'create' | 'update' | 'delete' | 'create_or_update';
|
||||
}
|
||||
|
||||
// ── 插件市场 API ──
|
||||
|
||||
export interface MarketEntry {
|
||||
id: string;
|
||||
plugin_id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
icon_url?: string;
|
||||
screenshots?: string[];
|
||||
min_platform_version?: string;
|
||||
status: string;
|
||||
download_count: number;
|
||||
rating_avg: number;
|
||||
rating_count: number;
|
||||
changelog?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface MarketEntryDetail extends MarketEntry {
|
||||
dependency_warnings: string[];
|
||||
}
|
||||
|
||||
export interface MarketReview {
|
||||
id: string;
|
||||
user_id: string;
|
||||
market_entry_id: string;
|
||||
rating: number;
|
||||
review_text?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export async function listMarketEntries(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category?: string;
|
||||
search?: string;
|
||||
}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MarketEntry> }>(
|
||||
'/market/entries',
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getMarketEntry(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: MarketEntryDetail }>(
|
||||
`/market/entries/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function installFromMarket(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/market/entries/${id}/install`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listMarketReviews(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: MarketReview[] }>(
|
||||
`/market/entries/${id}/reviews`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function submitMarketReview(id: string, review: { rating: number; review_text?: string }) {
|
||||
const { data } = await client.post<{ success: boolean; data: MarketReview }>(
|
||||
`/market/entries/${id}/reviews`,
|
||||
review,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
86
apps/web/src/api/roles.test.ts
Normal file
86
apps/web/src/api/roles.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* roles API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as rolesApi from './roles'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('roles API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listRoles 应调用 GET /roles 并传递分页参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await rolesApi.listRoles(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/roles', { params: { page: 1, page_size: 10 } })
|
||||
})
|
||||
|
||||
it('getRole 应调用 GET /roles/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await rolesApi.getRole('r-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/roles/r-001')
|
||||
})
|
||||
|
||||
it('createRole 应调用 POST /roles', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '医生', code: 'doctor', description: '医生角色' }
|
||||
await rolesApi.createRole(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/roles', req)
|
||||
})
|
||||
|
||||
it('updateRole 应调用 PUT /roles/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { name: '高级医生', version: 1 }
|
||||
await rolesApi.updateRole('r-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/roles/r-001', req)
|
||||
})
|
||||
|
||||
it('deleteRole 应调用 DELETE /roles/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await rolesApi.deleteRole('r-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/roles/r-001')
|
||||
})
|
||||
|
||||
it('assignPermissions 应调用 POST /roles/:id/permissions', async () => {
|
||||
mockPost.mockResolvedValue(undefined)
|
||||
await rolesApi.assignPermissions('r-001', ['p-1', 'p-2'])
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/roles/r-001/permissions', { permission_ids: ['p-1', 'p-2'] })
|
||||
})
|
||||
|
||||
it('getRolePermissions 应调用 GET /roles/:id/permissions', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await rolesApi.getRolePermissions('r-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/roles/r-001/permissions')
|
||||
})
|
||||
|
||||
it('listPermissions 应调用 GET /permissions', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await rolesApi.listPermissions()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/permissions')
|
||||
})
|
||||
})
|
||||
75
apps/web/src/api/roles.ts
Normal file
75
apps/web/src/api/roles.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface RoleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_system: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface PermissionInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listRoles(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<RoleInfo> }>(
|
||||
'/roles',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getRole(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: RoleInfo }>(`/roles/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createRole(req: CreateRoleRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: RoleInfo }>('/roles', req);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateRole(id: string, req: UpdateRoleRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: RoleInfo }>(`/roles/${id}`, req);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteRole(id: string) {
|
||||
await client.delete(`/roles/${id}`);
|
||||
}
|
||||
|
||||
export async function assignPermissions(roleId: string, permissionIds: string[]) {
|
||||
await client.post(`/roles/${roleId}/permissions`, { permission_ids: permissionIds });
|
||||
}
|
||||
|
||||
export async function getRolePermissions(roleId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>(
|
||||
`/roles/${roleId}/permissions`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listPermissions() {
|
||||
const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>('/permissions');
|
||||
return data.data;
|
||||
}
|
||||
30
apps/web/src/api/settings.ts
Normal file
30
apps/web/src/api/settings.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import client from './client';
|
||||
|
||||
export interface SettingInfo {
|
||||
id: string;
|
||||
scope: string;
|
||||
scope_id?: string;
|
||||
setting_key: string;
|
||||
setting_value: unknown;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function getSetting(key: string, scope?: string, scopeId?: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: SettingInfo }>(
|
||||
`/config/settings/${encodeURIComponent(key)}`,
|
||||
{ params: { scope, scope_id: scopeId } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateSetting(key: string, settingValue: unknown, version?: number) {
|
||||
const { data } = await client.put<{ success: boolean; data: SettingInfo }>(
|
||||
`/config/settings/${encodeURIComponent(key)}`,
|
||||
{ setting_value: settingValue, version },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteSetting(key: string, version: number) {
|
||||
await client.delete(`/config/settings/${encodeURIComponent(key)}`, { data: { version } });
|
||||
}
|
||||
49
apps/web/src/api/themes.ts
Normal file
49
apps/web/src/api/themes.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import client from './client';
|
||||
|
||||
export interface ThemeConfig {
|
||||
primary_color?: string;
|
||||
logo_url?: string;
|
||||
sidebar_style?: 'light' | 'dark';
|
||||
brand_name?: string;
|
||||
brand_slogan?: string;
|
||||
brand_features?: string;
|
||||
brand_copyright?: string;
|
||||
}
|
||||
|
||||
export async function getTheme() {
|
||||
const { data } = await client.get<{ success: boolean; data: ThemeConfig }>(
|
||||
'/config/themes',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateTheme(theme: ThemeConfig) {
|
||||
const { data } = await client.put<{ success: boolean; data: ThemeConfig }>(
|
||||
'/config/themes',
|
||||
theme,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface BrandConfig {
|
||||
brand_name: string;
|
||||
brand_slogan: string;
|
||||
brand_features: string;
|
||||
brand_copyright: string;
|
||||
}
|
||||
|
||||
const BRAND_DEFAULTS: BrandConfig = {
|
||||
brand_name: 'HMS 健康管理平台',
|
||||
brand_slogan: '新一代健康管理平台',
|
||||
brand_features: '患者管理 · 健康监测 · 随访管理 · AI 智能分析',
|
||||
brand_copyright: 'HMS 健康管理平台 · ©汕头市智界科技有限公司',
|
||||
};
|
||||
|
||||
export async function getPublicBrand(): Promise<BrandConfig> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/public/brand');
|
||||
const json = await res.json();
|
||||
if (json?.success && json?.data) return json.data;
|
||||
} catch {}
|
||||
return BRAND_DEFAULTS;
|
||||
}
|
||||
7
apps/web/src/api/types.ts
Normal file
7
apps/web/src/api/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
16
apps/web/src/api/upload.ts
Normal file
16
apps/web/src/api/upload.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import client from './client';
|
||||
|
||||
export interface UploadResult {
|
||||
url: string;
|
||||
filename?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export async function uploadFile(file: File): Promise<UploadResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const { data: result } = await client.post('/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
83
apps/web/src/api/users.test.ts
Normal file
83
apps/web/src/api/users.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* users API 契约测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as usersApi from './users'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('users API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listUsers 应调用 GET /users 并传递分页和搜索参数', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await usersApi.listUsers(2, 10, '张')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/users', {
|
||||
params: { page: 2, page_size: 10, search: '张' },
|
||||
})
|
||||
})
|
||||
|
||||
it('listUsers 空搜索时应传 search 为 undefined', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await usersApi.listUsers(1, 20, '')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/users', {
|
||||
params: { page: 1, page_size: 20, search: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
it('getUser 应调用 GET /users/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await usersApi.getUser('u-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/users/u-001')
|
||||
})
|
||||
|
||||
it('createUser 应调用 POST /users', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { username: 'newuser', password: 'pass123', display_name: '新用户' }
|
||||
await usersApi.createUser(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/users', req)
|
||||
})
|
||||
|
||||
it('updateUser 应调用 PUT /users/:id', async () => {
|
||||
mockPut.mockResolvedValue(fakeRes)
|
||||
const req = { display_name: '改名', version: 1 }
|
||||
await usersApi.updateUser('u-001', req)
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/users/u-001', req)
|
||||
})
|
||||
|
||||
it('deleteUser 应调用 DELETE /users/:id', async () => {
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
await usersApi.deleteUser('u-001')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/users/u-001')
|
||||
})
|
||||
|
||||
it('assignRoles 应调用 POST /users/:id/roles', async () => {
|
||||
mockPost.mockResolvedValue(undefined)
|
||||
await usersApi.assignRoles('u-001', ['role-1', 'role-2'])
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/users/u-001/roles', { role_ids: ['role-1', 'role-2'] })
|
||||
})
|
||||
})
|
||||
54
apps/web/src/api/users.ts
Normal file
54
apps/web/src/api/users.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import client from './client';
|
||||
import type { UserInfo } from './auth';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
display_name?: string;
|
||||
status?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listUsers(page = 1, pageSize = 20, search = '') {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<UserInfo> }>(
|
||||
'/users',
|
||||
{ params: { page, page_size: pageSize, search: search || undefined } }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getUser(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: UserInfo }>(`/users/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createUser(req: CreateUserRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: UserInfo }>('/users', req);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, req: UpdateUserRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: UserInfo }>(`/users/${id}`, req);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string) {
|
||||
await client.delete(`/users/${id}`);
|
||||
}
|
||||
|
||||
export async function assignRoles(userId: string, roleIds: string[]) {
|
||||
await client.post(`/users/${userId}/roles`, { role_ids: roleIds });
|
||||
}
|
||||
|
||||
export async function resetPassword(id: string, req: { new_password: string; version: number }) {
|
||||
await client.post(`/users/${id}/reset-password`, req);
|
||||
}
|
||||
141
apps/web/src/api/workflow.test.ts
Normal file
141
apps/web/src/api/workflow.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* workflow API 契约测试(definitions + instances + tasks)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as defApi from './workflowDefinitions'
|
||||
import * as instApi from './workflowInstances'
|
||||
import * as taskApi from './workflowTasks'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('workflowDefinitions API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listProcessDefinitions 应调用 GET /workflow/definitions', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await defApi.listProcessDefinitions(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/workflow/definitions', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('getProcessDefinition 应调用 GET /workflow/definitions/:id', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await defApi.getProcessDefinition('wf-001')
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/workflow/definitions/wf-001')
|
||||
})
|
||||
|
||||
it('createProcessDefinition 应调用 POST /workflow/definitions', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { name: '审批流程', key: 'approval', nodes: [], edges: [] }
|
||||
await defApi.createProcessDefinition(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/workflow/definitions', req)
|
||||
})
|
||||
|
||||
it('publishProcessDefinition 应调用 POST /workflow/definitions/:id/publish', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await defApi.publishProcessDefinition('wf-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/workflow/definitions/wf-001/publish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflowInstances API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('startInstance 应调用 POST /workflow/instances', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { definition_id: 'wf-001', business_key: 'BIZ-001' }
|
||||
await instApi.startInstance(req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/workflow/instances', req)
|
||||
})
|
||||
|
||||
it('listInstances 应调用 GET /workflow/instances 并传递分页', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await instApi.listInstances(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/workflow/instances', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('suspendInstance 应调用 POST /workflow/instances/:id/suspend', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await instApi.suspendInstance('inst-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/workflow/instances/inst-001/suspend')
|
||||
})
|
||||
|
||||
it('resumeInstance 应调用 POST /workflow/instances/:id/resume', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await instApi.resumeInstance('inst-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/workflow/instances/inst-001/resume')
|
||||
})
|
||||
|
||||
it('terminateInstance 应调用 POST /workflow/instances/:id/terminate', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
await instApi.terminateInstance('inst-001')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/workflow/instances/inst-001/terminate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflowTasks API', () => {
|
||||
const fakeRes = { data: { success: true, data: {} } }
|
||||
|
||||
it('listPendingTasks 应调用 GET /workflow/tasks/pending', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await taskApi.listPendingTasks(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/workflow/tasks/pending', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('listCompletedTasks 应调用 GET /workflow/tasks/completed', async () => {
|
||||
mockGet.mockResolvedValue(fakeRes)
|
||||
await taskApi.listCompletedTasks(1, 10)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/workflow/tasks/completed', {
|
||||
params: { page: 1, page_size: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('completeTask 应调用 POST /workflow/tasks/:id/complete', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { outcome: 'approved', form_data: { comment: '同意' } }
|
||||
await taskApi.completeTask('task-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/workflow/tasks/task-001/complete', req)
|
||||
})
|
||||
|
||||
it('delegateTask 应调用 POST /workflow/tasks/:id/delegate', async () => {
|
||||
mockPost.mockResolvedValue(fakeRes)
|
||||
const req = { delegate_to: 'u-002' }
|
||||
await taskApi.delegateTask('task-001', req)
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/workflow/tasks/task-001/delegate', req)
|
||||
})
|
||||
})
|
||||
90
apps/web/src/api/workflowDefinitions.ts
Normal file
90
apps/web/src/api/workflowDefinitions.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface NodeDef {
|
||||
id: string;
|
||||
type: 'StartEvent' | 'EndEvent' | 'UserTask' | 'ServiceTask' | 'ExclusiveGateway' | 'ParallelGateway';
|
||||
name: string;
|
||||
assignee_id?: string;
|
||||
candidate_groups?: string[];
|
||||
service_type?: string;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface EdgeDef {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
condition?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ProcessDefinitionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
version: number;
|
||||
category?: string;
|
||||
description?: string;
|
||||
nodes: NodeDef[];
|
||||
edges: EdgeDef[];
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateProcessDefinitionRequest {
|
||||
name: string;
|
||||
key: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
nodes: NodeDef[];
|
||||
edges: EdgeDef[];
|
||||
}
|
||||
|
||||
export interface UpdateProcessDefinitionRequest {
|
||||
name?: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
nodes?: NodeDef[];
|
||||
edges?: EdgeDef[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listProcessDefinitions(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<ProcessDefinitionInfo> }>(
|
||||
'/workflow/definitions',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getProcessDefinition(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: ProcessDefinitionInfo }>(
|
||||
`/workflow/definitions/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createProcessDefinition(req: CreateProcessDefinitionRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: ProcessDefinitionInfo }>(
|
||||
'/workflow/definitions',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateProcessDefinition(id: string, req: UpdateProcessDefinitionRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: ProcessDefinitionInfo }>(
|
||||
`/workflow/definitions/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function publishProcessDefinition(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: ProcessDefinitionInfo }>(
|
||||
`/workflow/definitions/${id}/publish`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
72
apps/web/src/api/workflowInstances.ts
Normal file
72
apps/web/src/api/workflowInstances.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface TokenInfo {
|
||||
id: string;
|
||||
node_id: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProcessInstanceInfo {
|
||||
id: string;
|
||||
definition_id: string;
|
||||
definition_name?: string;
|
||||
business_key?: string;
|
||||
status: string;
|
||||
started_by: string;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
active_tokens: TokenInfo[];
|
||||
}
|
||||
|
||||
export interface StartInstanceRequest {
|
||||
definition_id: string;
|
||||
business_key?: string;
|
||||
variables?: Array<{ name: string; var_type?: string; value: unknown }>;
|
||||
}
|
||||
|
||||
export async function startInstance(req: StartInstanceRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: ProcessInstanceInfo }>(
|
||||
'/workflow/instances',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listInstances(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<ProcessInstanceInfo> }>(
|
||||
'/workflow/instances',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getInstance(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: ProcessInstanceInfo }>(
|
||||
`/workflow/instances/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function suspendInstance(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: null }>(
|
||||
`/workflow/instances/${id}/suspend`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function resumeInstance(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: null }>(
|
||||
`/workflow/instances/${id}/resume`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function terminateInstance(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: null }>(
|
||||
`/workflow/instances/${id}/terminate`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
61
apps/web/src/api/workflowTasks.ts
Normal file
61
apps/web/src/api/workflowTasks.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './types';
|
||||
|
||||
export interface TaskInfo {
|
||||
id: string;
|
||||
instance_id: string;
|
||||
token_id: string;
|
||||
node_id: string;
|
||||
node_name?: string;
|
||||
assignee_id?: string;
|
||||
candidate_groups?: unknown;
|
||||
status: string;
|
||||
outcome?: string;
|
||||
form_data?: unknown;
|
||||
due_date?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
definition_name?: string;
|
||||
business_key?: string;
|
||||
}
|
||||
|
||||
export interface CompleteTaskRequest {
|
||||
outcome: string;
|
||||
form_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DelegateTaskRequest {
|
||||
delegate_to: string;
|
||||
}
|
||||
|
||||
export async function listPendingTasks(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<TaskInfo> }>(
|
||||
'/workflow/tasks/pending',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listCompletedTasks(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<TaskInfo> }>(
|
||||
'/workflow/tasks/completed',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function completeTask(id: string, req: CompleteTaskRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: TaskInfo }>(
|
||||
`/workflow/tasks/${id}/complete`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function delegateTask(id: string, req: DelegateTaskRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: TaskInfo }>(
|
||||
`/workflow/tasks/${id}/delegate`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
Reference in New Issue
Block a user