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:
iven
2026-06-13 00:32:50 +08:00
commit 3772afd987
438 changed files with 86511 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import { test as base } from '@playwright/test';
const API_BASE = 'http://localhost:3000/api/v1';
let loginPromise: Promise<{ token: string; user: unknown }> | null = null;
function login(): Promise<{ token: string; user: unknown }> {
if (!loginPromise) {
loginPromise = (async () => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Admin@2026' }),
});
const json = await res.json();
if (json.success) {
return { token: json.data.access_token, user: json.data.user };
}
} catch {}
// Wait before retry on collision
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
throw new Error('Login failed after 3 attempts');
})();
}
return loginPromise;
}
export const test = base.extend({
page: async ({ page }, use) => {
const { token, user } = await login();
await page.addInitScript((args) => {
localStorage.setItem('access_token', args.token);
localStorage.setItem('refresh_token', args.token);
localStorage.setItem('user', JSON.stringify(args.user));
}, { token, user });
await use(page);
},
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,22 @@
// apps/web/e2e/check-readiness.ts
import type { FullConfig } from '@playwright/test';
async function check(url: string, label: string): Promise<void> {
for (let i = 0; i < 5; i++) {
try {
const res = await fetch(url);
if (res.ok) return;
} catch { /* retry */ }
console.log(`${label} 未就绪,等待重试 (${i + 1}/5)...`);
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`${label} 未就绪: ${url}。请确认后端服务已启动 (cd crates/erp-server && cargo run)`);
}
export default async function globalSetup(_config: FullConfig) {
const apiBase = process.env.E2E_API_URL || 'http://localhost:3000';
const webBase = process.env.E2E_BASE_URL || 'http://localhost:5174';
await check(`${apiBase}/api/v1/health`, '后端 API');
await check(webBase, '前端 SPA');
console.log('✅ E2E 环境就绪');
}

View File

@@ -0,0 +1,200 @@
// apps/web/e2e/fixtures/api-client.ts
import type {
PatientData, DoctorData, VitalSignsData, ScheduleData,
AppointmentData, FollowUpTemplateData, FollowUpTaskData, AlertRuleData,
} from './test-data';
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
interface ApiResponse<T> { success: boolean; data: T }
interface Versioned { id: string; version: number }
type VEntity<T> = T & Versioned;
interface LoginResponse {
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string; username: string; display_name: string; roles: string[] };
}
export class ApiClient {
private token = '';
async login(username?: string, password?: string): Promise<LoginResponse> {
const res = await this.rawPost<{ success: boolean; data: LoginResponse }>(
'/auth/login',
{
username: username || process.env.E2E_ADMIN_USER || 'admin',
password: password || process.env.E2E_ADMIN_PASS || 'Admin@2026',
},
);
this.token = res.data.access_token;
return res.data;
}
async loginAsAdmin(): Promise<LoginResponse> {
return this.login();
}
getToken(): string { return this.token; }
async createPatient(overrides?: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/patients', overrides ?? {});
}
async updatePatient(id: string, version: number, data: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/patients/${id}`, { ...data, version });
}
async deletePatient(id: string, version: number): Promise<void> {
await this.del(`/health/patients/${id}`, { version });
}
async createDoctor(overrides?: Partial<DoctorData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/doctors', overrides ?? {});
}
async deleteDoctor(id: string, version: number): Promise<void> {
await this.del(`/health/doctors/${id}`, { version });
}
async createVitalSigns(patientId: string, overrides?: Partial<VitalSignsData>): Promise<VEntity<Record<string, unknown>>> {
return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {});
}
async deleteVitalSigns(patientId: string, id: string, version: number): Promise<void> {
await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version });
}
async createSchedule(overrides: ScheduleData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/doctor-schedules', overrides);
}
async deleteSchedule(id: string, version: number): Promise<void> {
await this.del(`/health/doctor-schedules/${id}`, { version });
}
async createAppointment(overrides: AppointmentData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/appointments', overrides);
}
async updateAppointmentStatus(id: string, version: number, status: string): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/appointments/${id}/status`, { status, version });
}
async deleteAppointment(id: string, version: number): Promise<void> {
await this.del(`/health/appointments/${id}`, { version });
}
async createFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/follow-up-templates', overrides ?? {});
}
async deleteFollowUpTemplate(id: string, version: number): Promise<void> {
await this.del(`/health/follow-up-templates/${id}`, { version });
}
async createFollowUpTask(overrides: FollowUpTaskData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/follow-up-tasks', overrides);
}
async deleteFollowUpTask(id: string, version: number): Promise<void> {
await this.del(`/health/follow-up-tasks/${id}`, { version });
}
async createAlertRule(overrides?: Partial<AlertRuleData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/alert-rules', overrides ?? {});
}
async deleteAlertRule(id: string, version: number): Promise<void> {
await this.del(`/health/alert-rules/${id}`, { version });
}
async listAlerts(): Promise<VEntity<Record<string, unknown>>[]> {
const res = await this.get<{ data: VEntity<Record<string, unknown>>[] }>('/health/alerts');
return res.data ?? [];
}
async acknowledgeAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/acknowledge`, { version });
}
async resolveAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/resolve`, { version });
}
async dismissAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/dismiss`, { version });
}
private async headers(): Promise<Record<string, string>> {
return {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
};
}
private async parseJson<T>(res: Response, method: string, path: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
if (!json.success) throw new Error(`${method} ${path} failed: ${json.error ?? 'unknown'}`);
return json.data as T;
}
private async get<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { headers: await this.headers() });
return this.parseJson<T>(res, 'GET', path);
}
private async post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: await this.headers(),
body: JSON.stringify(body),
});
return this.parseJson<T>(res, 'POST', path);
}
private async put<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'PUT',
headers: await this.headers(),
body: JSON.stringify(body),
});
return this.parseJson<T>(res, 'PUT', path);
}
private async del(path: string, body?: unknown): Promise<void> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
headers: await this.headers(),
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return;
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`DELETE ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
if (!json.success) throw new Error(`DELETE ${path} failed: ${json.error ?? 'unknown'}`);
}
private async rawPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`POST ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
if (!json.success) throw new Error(`POST ${path} failed: ${json.error ?? 'unknown'}`);
return json as T;
}
}

View File

@@ -0,0 +1,73 @@
// apps/web/e2e/fixtures/auth.fixture.ts
import { test as base, type Page } from '@playwright/test';
import { ApiClient } from './api-client';
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
type E2eFixtures = {
api: ApiClient;
authenticatedPage: Page;
};
interface LoginResult {
access_token: string;
refresh_token: string;
user: object;
}
async function login(): Promise<LoginResult> {
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: process.env.E2E_ADMIN_USER || 'admin',
password: process.env.E2E_ADMIN_PASS || 'Admin@2026',
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${text.slice(0, 100)}`);
}
const json = await res.json();
if (json.success) return json.data;
throw new Error(`Login unsuccessful: ${json.error ?? 'unknown'}`);
} catch (err) {
if (attempt === 4) throw err;
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
}
throw new Error('Login failed after 5 attempts');
}
export const test = base.extend<E2eFixtures>({
api: async ({}, use) => {
const { access_token } = await login();
const client = new ApiClient();
client['token'] = access_token;
await use(client);
},
authenticatedPage: async ({ page }, use) => {
const { access_token, refresh_token, user } = await login();
await page.addInitScript((args) => {
localStorage.setItem('access_token', args.token);
localStorage.setItem('refresh_token', args.refresh);
localStorage.setItem('user', JSON.stringify(args.userData));
}, { token: access_token, refresh: refresh_token, userData: user });
await use(page);
},
page: async ({ page }, use) => {
const { access_token, refresh_token, user } = await login();
await page.addInitScript((args) => {
localStorage.setItem('access_token', args.token);
localStorage.setItem('refresh_token', args.refresh);
localStorage.setItem('user', JSON.stringify(args.userData));
}, { token: access_token, refresh: refresh_token, userData: user });
await use(page);
},
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,198 @@
// apps/web/e2e/fixtures/test-data.ts
export interface PatientData {
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
source?: string;
notes?: string;
}
export interface DoctorData {
name: string;
department?: string;
title?: string;
specialty?: string;
phone?: string;
license_number?: string;
status?: string;
}
export interface VitalSignsData {
record_date: string;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
heart_rate?: number;
body_temperature?: number;
spo2?: number;
blood_sugar?: number;
weight?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
source?: string;
}
export interface ScheduleData {
doctor_id: string;
schedule_date: string;
start_time: string;
end_time: string;
max_appointments?: number;
period_type?: string;
}
export interface AppointmentData {
patient_id: string;
doctor_id: string;
schedule_id: string;
appointment_date: string;
start_time: string;
end_time: string;
reason?: string;
}
export interface FollowUpTemplateData {
name: string;
description?: string;
follow_up_type: string;
applicable_scope?: string;
fields?: Array<{
label: string;
field_key: string;
field_type: string;
required?: boolean;
options?: string;
}>;
}
export interface FollowUpTaskData {
patient_id: string;
follow_up_type: string;
planned_date: string;
assigned_to?: string;
content_template?: string;
}
export interface AlertRuleData {
name: string;
device_type: string;
condition_type: string;
condition_params: Record<string, unknown>;
severity?: string;
description?: string;
apply_tags?: Record<string, unknown>;
notify_roles?: Array<string>;
cooldown_minutes?: number;
}
let counter = 0;
function uid(): string {
counter += 1;
return `${Date.now()}_${counter}_${Math.random().toString(36).slice(2, 6)}`;
}
export function makePatient(overrides?: Partial<PatientData>): PatientData {
const id = uid();
return {
name: `E2E患者_${id}`,
gender: 'male',
birth_date: '1990-01-15',
id_number: `110101199001${String(Math.random()).slice(2, 8)}`,
...overrides,
};
}
export function makeDoctor(overrides?: Partial<DoctorData>): DoctorData {
const id = uid();
return {
name: `E2E医生_${id}`,
department: '内科',
title: '主治医师',
specialty: '全科',
license_number: `DOC${id}`,
...overrides,
};
}
export function makeVitalSigns(overrides?: Partial<VitalSignsData>): VitalSignsData {
return {
record_date: new Date().toISOString().slice(0, 10),
systolic_bp_morning: 120,
diastolic_bp_morning: 80,
heart_rate: 72,
body_temperature: 36.5,
spo2: 98,
source: 'web_e2e',
...overrides,
};
}
export function makeSchedule(doctorId: string, overrides?: Partial<ScheduleData>): ScheduleData {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const date = tomorrow.toISOString().slice(0, 10);
return {
doctor_id: doctorId,
schedule_date: date,
start_time: '09:00',
end_time: '12:00',
max_appointments: 10,
...overrides,
};
}
export function makeAppointment(patientId: string, doctorId: string, scheduleId: string, overrides?: Partial<AppointmentData>): AppointmentData {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const date = tomorrow.toISOString().slice(0, 10);
return {
patient_id: patientId,
doctor_id: doctorId,
schedule_id: scheduleId,
appointment_date: date,
start_time: '09:00',
end_time: '10:00',
reason: 'E2E测试预约',
...overrides,
};
}
export function makeFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): FollowUpTemplateData {
return {
name: `E2E随访模板_${uid()}`,
description: 'E2E自动创建的随访模板',
follow_up_type: 'phone',
...overrides,
};
}
export function makeFollowUpTask(patientId: string, _templateId: string, overrides?: Partial<FollowUpTaskData>): FollowUpTaskData {
const plannedDate = new Date();
plannedDate.setDate(plannedDate.getDate() + 7);
return {
patient_id: patientId,
follow_up_type: 'phone',
planned_date: plannedDate.toISOString().slice(0, 10),
...overrides,
};
}
export function makeAlertRule(overrides?: Partial<AlertRuleData>): AlertRuleData {
return {
name: `E2E告警规则_${uid()}`,
device_type: 'heart_rate',
condition_type: 'single_threshold',
condition_params: { direction: 'above', value: 50 },
severity: 'warning',
description: 'E2E测试低阈值规则用于触发告警',
...overrides,
};
}

View File

@@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test('显示登录页面', async ({ page }) => {
await page.goto('/#/login');
await expect(page.locator('.ant-card, .ant-form')).toBeVisible();
});
test('空表单提交显示验证错误', async ({ page }) => {
await page.goto('/#/login');
await page.click('button[type="submit"]');
// Ant Design 应显示验证错误
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2); // 用户名 + 密码
});
});

View File

@@ -0,0 +1,56 @@
// apps/web/e2e/pages/appointment.page.ts
import type { Page } from '@playwright/test';
export class AppointmentPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async gotoSchedule() {
await this.page.goto('/#/health/schedules');
await this.page.waitForSelector('.ant-table, .ant-fullcalendar, [class*="calendar"]', { timeout: 10000 });
}
async gotoAppointments() {
await this.page.goto('/#/health/appointments');
await this.page.waitForSelector('.ant-table', { timeout: 10000 });
}
async clickCreateSchedule() {
await this.page.click('button:has-text("新增排班"), button:has-text("创建")');
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
}
async fillScheduleForm(data: { doctor_id?: string; date: string; start_time: string; end_time: string }) {
if (data.doctor_id) {
await this.page.click('.ant-select');
await this.page.click('.ant-select-item-option');
}
await this.page.fill('input[placeholder*="日期"]', data.date);
await this.page.fill('input[placeholder*="开始"]', data.start_time);
await this.page.fill('input[placeholder*="结束"]', data.end_time);
}
async submitScheduleForm() {
await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary');
await this.page.waitForSelector('.ant-message-success', { timeout: 10000 });
}
async clickCreateAppointment() {
await this.page.click('button:has-text("新增预约"), button:has-text("创建")');
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
}
async fillAppointmentForm(data: { patient_id: string; doctor_id: string; date: string; reason?: string }) {
if (data.reason) {
await this.page.fill('textarea, input[placeholder*="原因"]', data.reason);
}
}
async submitAppointmentForm() {
await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary');
await this.page.waitForSelector('.ant-message-success', { timeout: 10000 });
}
}

View File

@@ -0,0 +1,6 @@
// apps/web/e2e/pages/index.ts
export { LoginPage } from './login.page';
export { PatientListPage } from './patient-list.page';
export { PatientDetailPage } from './patient-detail.page';
export { HealthDataPage } from './health-data.page';
export { AppointmentPage } from './appointment.page';

View File

@@ -0,0 +1,48 @@
// apps/web/e2e/pages/login.page.ts
import type { Page } from '@playwright/test';
export class LoginPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto('/#/login');
await this.page.waitForSelector('.ant-card, .ant-form');
}
async fillUsername(username: string) {
await this.page.fill('input[id="username"], input[placeholder*="用户名"]', username);
}
async fillPassword(password: string) {
await this.page.fill('input[type="password"]', password);
}
async clickSubmit() {
await this.page.click('button[type="submit"]');
}
async login(username: string, password: string) {
await this.goto();
await this.fillUsername(username);
await this.fillPassword(password);
await this.clickSubmit();
}
async getErrorMessage(): Promise<string> {
const el = this.page.locator('.ant-form-item-explain-error, .ant-message-error, .ant-alert-error');
return el.first().textContent() ?? '';
}
async isLoggedIn(): Promise<boolean> {
try {
await this.page.waitForURL('**/#/', { timeout: 5000 });
return true;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,44 @@
// apps/web/e2e/pages/patient-detail.page.ts
import type { Page } from '@playwright/test';
export class PatientDetailPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(id: string) {
await this.page.goto(`/#/health/patients/${id}`);
await this.page.waitForSelector('.ant-descriptions, .ant-tabs', { timeout: 10000 });
}
async getPatientName(): Promise<string> {
const el = this.page.locator('div[style*="font-weight"]').first();
return el.textContent() ?? '';
}
async clickTab(tabName: string) {
await this.page.click(`.ant-tabs-tab:has-text("${tabName}")`);
await this.page.waitForTimeout(500);
}
async getVitalSignsCount(): Promise<number> {
return this.page.locator('.ant-table-tbody tr').count();
}
async clickAssignDoctor() {
await this.page.click('button:has-text("分配医生")');
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
}
async selectDoctor(doctorName: string) {
await this.page.click('.ant-select');
await this.page.click(`.ant-select-item-option:has-text("${doctorName}")`);
}
async confirmAssign() {
await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary');
await this.page.waitForSelector('.ant-message-success', { timeout: 5000 });
}
}

View File

@@ -0,0 +1,66 @@
// apps/web/e2e/pages/patient-list.page.ts
import type { Page } from '@playwright/test';
export class PatientListPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto('/#/health/patients');
await this.page.waitForSelector('.ant-table', { timeout: 15000 });
}
async clickCreate() {
await this.page.click('button:has-text("新增"), button:has-text("新建"), button:has-text("创建")');
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
}
async fillCreateForm(data: { name: string; gender?: string; birth_date?: string }) {
const drawer = this.page.locator('.ant-drawer');
await drawer.locator('input').first().waitFor({ state: 'visible' });
await this.page.locator('.ant-drawer [name="name"] input, .ant-drawer input').first().fill(data.name);
if (data.gender) {
await drawer.locator('.ant-select').first().click();
await this.page.locator(`.ant-select-item-option:has-text("${data.gender === 'male' ? '男' : '女'}")`).first().click();
}
if (data.birth_date) {
await drawer.locator('[name="birth_date"] input, input[placeholder*="出生"]').fill(data.birth_date);
}
}
async submitForm() {
await this.page.click('.ant-drawer button.ant-btn-primary, button:has-text("保存"), .ant-modal .ant-btn-primary');
await this.page.waitForSelector('.ant-message-success', { timeout: 10000 });
}
async searchPatient(name: string) {
const searchInput = this.page.locator('input[placeholder*="搜索"]').first();
await searchInput.fill(name);
await searchInput.press('Enter');
await this.page.waitForTimeout(1000);
}
async clickPatientRow(row: number) {
const rows = this.page.locator('.ant-table-tbody tr');
await rows.nth(row).click();
}
async clickPatientByName(name: string) {
await this.searchPatient(name);
const row = this.page.locator(`.ant-table-tbody tr:has-text("${name}")`).first();
await row.click();
}
async getTableRowCount(): Promise<number> {
return this.page.locator('.ant-table-tbody tr').count();
}
async hasPatientInTable(name: string): Promise<boolean> {
await this.searchPatient(name);
const count = await this.page.locator(`.ant-table-tbody tr:has-text("${name}")`).count();
return count > 0;
}
}

View File

@@ -0,0 +1,24 @@
import { test, expect } from './auth.fixture';
test.describe('插件管理', () => {
test('插件管理页面加载', async ({ page }) => {
await page.goto('/#/');
// 侧边栏显示"扩展管理插件管理"
await page.locator('text=扩展管理').first().click();
await page.waitForLoadState('networkidle');
// 页面不崩溃
await expect(page.locator('main')).toBeVisible();
});
test('刷新按钮可点击', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=扩展管理').first().click();
await page.waitForLoadState('networkidle');
const refreshBtn = page.locator('button:has-text("刷新")');
if (await refreshBtn.isVisible().catch(() => false)) {
await expect(refreshBtn).toBeEnabled();
await refreshBtn.click();
await expect(page.locator('main')).toBeVisible();
}
});
});

View File

@@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test('显示登录页面', async ({ page }) => {
await page.goto('/#/login');
await expect(page.locator('.ant-card, .ant-form')).toBeVisible();
});
test('空表单提交显示验证错误', async ({ page }) => {
await page.goto('/#/login');
await page.click('button[type="submit"]');
// Ant Design 应显示验证错误
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2); // 用户名 + 密码
});
});

View File

@@ -0,0 +1,24 @@
import { test, expect } from '../fixtures/auth.fixture';
test.describe('插件管理', () => {
test('插件管理页面加载', async ({ page }) => {
await page.goto('/#/');
// 侧边栏显示"扩展管理插件管理"
await page.locator('text=扩展管理').first().click();
await page.waitForLoadState('networkidle');
// 页面不崩溃
await expect(page.locator('main')).toBeVisible();
});
test('刷新按钮可点击', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=扩展管理').first().click();
await page.waitForLoadState('networkidle');
const refreshBtn = page.locator('button:has-text("刷新")');
if (await refreshBtn.isVisible().catch(() => false)) {
await expect(refreshBtn).toBeEnabled();
await refreshBtn.click();
await expect(page.locator('main')).toBeVisible();
}
});
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from '../fixtures/auth.fixture';
test.describe('多租户隔离', () => {
test('侧边栏按模块分组显示', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 验证侧边栏模块分组
await expect(page.locator('text=基础模块').first()).toBeVisible({ timeout: 10000 });
await expect(page.locator('text=业务模块').first()).toBeVisible();
await expect(page.locator('text=系统').first()).toBeVisible();
// 验证关键菜单项
await expect(page.locator('text=工作台').first()).toBeVisible();
await expect(page.locator('text=用户管理').first()).toBeVisible();
});
test('顶部导航栏显示用户信息', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 验证顶部导航栏显示管理员信息
await expect(page.locator('text=系统管理员').first()).toBeVisible({ timeout: 10000 });
});
test('页面间导航正常工作', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 点击侧边栏的用户管理(精确匹配侧边栏区域)
const sidebar = page.locator('complementary, [class*=sider], [class*=menu], nav').first();
await sidebar.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 点击工作台返回
await sidebar.locator('text=工作台').first().click();
await expect(page).toHaveURL(/#\/$/, { timeout: 10000 });
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from '../fixtures/auth.fixture';
test.describe('用户管理', () => {
test('用户列表页面加载并显示表格', async ({ page }) => {
await page.goto('/#/');
// 通过侧边栏导航到用户管理
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 标题
await expect(page.locator('h4')).toContainText('用户管理');
// 新建用户按钮
await expect(page.locator('button:has-text("新建用户")')).toBeVisible();
// 搜索框
await expect(page.locator('input[placeholder*="搜索"]')).toBeVisible();
// 表格列头
await expect(page.locator('text=用户').first()).toBeVisible();
await expect(page.locator('text=状态').first()).toBeVisible();
});
test('新建用户弹窗表单验证', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 点击新建
await page.click('button:has-text("新建用户")');
// 弹窗出现
await expect(page.locator('.ant-modal')).toBeVisible();
// 直接提交应显示验证错误(点击 modal 内最后一个 button 即确认按钮)
const modalButtons = page.locator('.ant-modal .ant-modal-footer button');
await modalButtons.last().click();
// Ant Design 应显示验证错误(用户名 + 密码必填)
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2);
// 关闭弹窗(点击第一个按钮即取消)
await modalButtons.first().click();
});
test('搜索框可输入', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
const searchInput = page.locator('input[placeholder*="搜索"]');
await searchInput.fill('admin');
await expect(searchInput).toHaveValue('admin');
});
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from './auth.fixture';
test.describe('多租户隔离', () => {
test('侧边栏按模块分组显示', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 验证侧边栏模块分组
await expect(page.locator('text=基础模块').first()).toBeVisible({ timeout: 10000 });
await expect(page.locator('text=业务模块').first()).toBeVisible();
await expect(page.locator('text=系统').first()).toBeVisible();
// 验证关键菜单项
await expect(page.locator('text=工作台').first()).toBeVisible();
await expect(page.locator('text=用户管理').first()).toBeVisible();
});
test('顶部导航栏显示用户信息', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 验证顶部导航栏显示管理员信息
await expect(page.locator('text=系统管理员').first()).toBeVisible({ timeout: 10000 });
});
test('页面间导航正常工作', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 点击侧边栏的用户管理(精确匹配侧边栏区域)
const sidebar = page.locator('complementary, [class*=sider], [class*=menu], nav').first();
await sidebar.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 点击工作台返回
await sidebar.locator('text=工作台').first().click();
await expect(page).toHaveURL(/#\/$/, { timeout: 10000 });
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from './auth.fixture';
test.describe('用户管理', () => {
test('用户列表页面加载并显示表格', async ({ page }) => {
await page.goto('/#/');
// 通过侧边栏导航到用户管理
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 标题
await expect(page.locator('h4')).toContainText('用户管理');
// 新建用户按钮
await expect(page.locator('button:has-text("新建用户")')).toBeVisible();
// 搜索框
await expect(page.locator('input[placeholder*="搜索"]')).toBeVisible();
// 表格列头
await expect(page.locator('text=用户').first()).toBeVisible();
await expect(page.locator('text=状态').first()).toBeVisible();
});
test('新建用户弹窗表单验证', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 点击新建
await page.click('button:has-text("新建用户")');
// 弹窗出现
await expect(page.locator('.ant-modal')).toBeVisible();
// 直接提交应显示验证错误(点击 modal 内最后一个 button 即确认按钮)
const modalButtons = page.locator('.ant-modal .ant-modal-footer button');
await modalButtons.last().click();
// Ant Design 应显示验证错误(用户名 + 密码必填)
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2);
// 关闭弹窗(点击第一个按钮即取消)
await modalButtons.first().click();
});
test('搜索框可输入', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
const searchInput = page.locator('input[placeholder*="搜索"]');
await searchInput.fill('admin');
await expect(searchInput).toHaveValue('admin');
});
});

5980
apps/web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
apps/web/public/crm.wasm Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
apps/web/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

View File

@@ -0,0 +1,349 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.13.6'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View File

@@ -0,0 +1,2 @@
User-agent: *
Allow: /

258
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,258 @@
import { useEffect, lazy, Suspense, useMemo } from 'react';
import { HashRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { ConfigProvider, theme as antdTheme, Spin, Result } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import MainLayout from './layouts/MainLayout';
import Login from './pages/Login';
import { ErrorBoundary } from './components/ErrorBoundary';
import { useAuthStore } from './stores/auth';
import { useAppStore } from './stores/app';
import type { ThemeName } from './stores/app';
import { ROUTE_PERMISSIONS, FROZEN_ROUTES, validateRouteCoverage } from './routeConfig';
const Home = lazy(() => import('./pages/Home'));
const Users = lazy(() => import('./pages/Users'));
const Roles = lazy(() => import('./pages/Roles'));
const Organizations = lazy(() => import('./pages/Organizations'));
const Workflow = lazy(() => import('./pages/Workflow'));
const Messages = lazy(() => import('./pages/Messages'));
const Settings = lazy(() => import('./pages/Settings'));
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
const PluginMarket = lazy(() => import('./pages/PluginMarket'));
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) => ({ default: m.PluginGraphPage })));
const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage })));
const PluginKanbanPage = lazy(() => import('./pages/PluginKanbanPage'));
function FrozenRoute() {
return <Result status="info" title="功能暂未开放" subTitle="该功能正在优化中,敬请期待" />;
}
function ForbiddenPage() {
const navigate = useNavigate();
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<Result
status="403"
title="权限不足"
subTitle="您没有访问此页面的权限,请联系管理员"
extra={<button onClick={() => navigate('/')} style={{ cursor: 'pointer', color: 'var(--ant-color-primary)', background: 'none', border: 'none', fontSize: 14 }}></button>}
/>
</div>
);
}
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const permissions = useAuthStore((s) => s.permissions);
const location = useLocation();
if (!isAuthenticated) return <Navigate to="/login" replace />;
const path = location.pathname;
// 冻结路由检查
if (FROZEN_ROUTES.some((frozen) => path.startsWith(frozen))) {
return <FrozenRoute />;
}
// 首页/工作台始终放行
if (path === '/' || path === '') return <>{children}</>;
const matchedPrefix = Object.keys(ROUTE_PERMISSIONS).find(
(prefix) => path === prefix || path.startsWith(prefix + '/'),
);
if (matchedPrefix) {
const required = ROUTE_PERMISSIONS[matchedPrefix];
const hasAccess = required.some((r) => permissions.includes(r));
if (!hasAccess) return <ForbiddenPage />;
} else {
return <ForbiddenPage />;
}
return <>{children}</>;
}
const baseToken = {
borderRadius: 10,
borderRadiusLG: 12,
borderRadiusSM: 6,
fontFamily: "'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif",
fontSize: 14,
fontSizeHeading4: 20,
controlHeight: 40,
controlHeightLG: 44,
controlHeightSM: 32,
boxShadow: 'none',
boxShadowSecondary: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
};
const baseComponents = {
Button: { primaryShadow: 'none', fontWeight: 500 },
Card: { paddingLG: 20 },
Menu: { itemBorderRadius: 10, itemMarginInline: 8, itemHeight: 40 },
Modal: { borderRadiusLG: 16 },
Tag: { borderRadiusSM: 6 },
};
const themeConfigs: Record<ThemeName, { token: Record<string, unknown>; components: Record<string, Record<string, unknown>> }> = {
blue: {
token: {
...baseToken,
colorPrimary: '#2563eb',
colorSuccess: '#059669',
colorWarning: '#d97706',
colorError: '#dc2626',
colorInfo: '#0284c7',
colorBgLayout: '#f8fafc',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBorder: '#e2e8f0',
colorBorderSecondary: '#f1f5f9',
},
components: {
...baseComponents,
Table: { headerBg: '#f1f5f9', headerColor: '#475569', rowHoverBg: '#f1f5f9', fontSize: 14 },
},
},
warm: {
token: {
...baseToken,
borderRadius: 12,
borderRadiusLG: 14,
borderRadiusSM: 8,
colorPrimary: '#C4623A',
colorSuccess: '#5B7A5E',
colorWarning: '#C4873A',
colorError: '#B54A4A',
colorInfo: '#8B7A5E',
colorBgLayout: '#F5F0EB',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBorder: '#E8E2DC',
colorBorderSecondary: '#F0EBE5',
},
components: {
...baseComponents,
Table: { headerBg: '#EDE8E2', headerColor: '#7A756E', rowHoverBg: '#F5F0EB', fontSize: 14 },
},
},
dark: {
token: {
...baseToken,
colorPrimary: '#60A5FA',
colorSuccess: '#34D399',
colorWarning: '#FBBF24',
colorError: '#F87171',
colorInfo: '#38BDF8',
colorBgLayout: '#0F172A',
colorBgContainer: '#1E293B',
colorBgElevated: '#334155',
colorBorder: '#334155',
colorBorderSecondary: 'rgba(255,255,255,0.06)',
boxShadow: 'none',
boxShadowSecondary: '0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2)',
},
components: {
...baseComponents,
Table: { headerBg: '#1E293B', headerColor: '#94A3B8', rowHoverBg: '#1E293B', fontSize: 14 },
},
},
emerald: {
token: {
...baseToken,
borderRadius: 10,
borderRadiusLG: 14,
borderRadiusSM: 8,
colorPrimary: '#5B7A5E',
colorSuccess: '#3D7A42',
colorWarning: '#B8863A',
colorError: '#A54A4A',
colorInfo: '#4A7A8B',
colorBgLayout: '#F4F7F4',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBorder: '#D5DED5',
colorBorderSecondary: '#E5ECE5',
},
components: {
...baseComponents,
Table: { headerBg: '#EDF2ED', headerColor: '#5A6E5A', rowHoverBg: '#F4F7F4', fontSize: 14 },
},
},
};
export default function App() {
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
const themeName = useAppStore((s) => s.theme);
useEffect(() => {
loadFromStorage();
}, [loadFromStorage]);
useEffect(() => {
document.documentElement.setAttribute('data-theme', themeName);
}, [themeName]);
// DEV mode: validate all routes have permission declarations
useEffect(() => {
validateRouteCoverage([
"/users", "/roles", "/organizations", "/workflow", "/messages", "/settings",
"/plugins/admin", "/plugins/market",
]);
}, []);
const isDark = themeName === 'dark';
const antTheme = useMemo(() => themeConfigs[themeName] ?? themeConfigs.blue, [themeName]);
return (
<>
<a href="#root" className="erp-skip-link"></a>
<ConfigProvider
locale={zhCN}
theme={{
...antTheme,
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
}}
>
<HashRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<PrivateRoute>
<MainLayout>
<ErrorBoundary>
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}><Spin size="large" /></div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/users" element={<Users />} />
<Route path="/roles" element={<Roles />} />
<Route path="/organizations" element={<Organizations />} />
<Route path="/workflow" element={<Workflow />} />
<Route path="/messages" element={<Messages />} />
<Route path="/settings" element={<Settings />} />
<Route path="/plugins/admin" element={<PluginAdmin />} />
<Route path="/plugins/market" element={<PluginMarket />} />
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
<Route path="/plugins/:pluginId/kanban/:entityName" element={<PluginKanbanPage />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
</Routes>
</Suspense>
</ErrorBoundary>
</MainLayout>
</PrivateRoute>
}
/>
</Routes>
</HashRouter>
</ConfigProvider>
</>
);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}

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

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

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

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

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

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

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
import { usePermission } from '../hooks/usePermission';
interface AuthButtonProps {
code: string;
children: ReactNode;
}
export function AuthButton({ code, children }: AuthButtonProps) {
const { hasPermission } = usePermission(code);
if (!hasPermission) return null;
return <>{children}</>;
}

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { Drawer, Form, Typography, Divider, Button, Space } from 'antd';
import { useThemeMode } from '../hooks/useThemeMode';
export interface FormSection {
title: string;
fields: React.ReactNode;
defaultCollapsed?: boolean;
}
interface DrawerFormProps {
title: string;
open: boolean;
onClose: () => void;
onSubmit: (values: Record<string, unknown>) => Promise<void>;
initialValues?: Record<string, unknown>;
loading?: boolean;
width?: number | string;
sections?: FormSection[];
children?: React.ReactNode;
columns?: 1 | 2;
form?: ReturnType<typeof Form.useForm>[0];
onValuesChange?: (changedValues: Record<string, unknown>, allValues: Record<string, unknown>) => void;
}
export function DrawerForm({
title,
open,
onClose,
onSubmit,
initialValues,
loading,
width = 640,
sections,
children,
columns = 2,
form: externalForm,
onValuesChange,
}: DrawerFormProps) {
const [internalForm] = Form.useForm();
const form = externalForm ?? internalForm;
const isDark = useThemeMode();
React.useEffect(() => {
if (open) {
form.resetFields();
if (initialValues) {
form.setFieldsValue(initialValues);
}
}
}, [open, initialValues, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
await onSubmit(values);
} catch (error: unknown) {
// validateFields 失败时 error 包含 errorFields预期行为不记录
// 其他类型的错误才记录
if (error && typeof error === 'object' && !('errorFields' in error)) {
console.error('[DrawerForm] submit error:', error);
}
}
};
const gridStyle: React.CSSProperties =
columns === 2
? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }
: {};
return (
<Drawer
title={title}
open={open}
onClose={onClose}
width={width}
styles={{
body: { background: isDark ? '#141414' : undefined },
}}
extra={
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={handleSubmit} loading={loading}>
</Button>
</Space>
}
>
<Form form={form} layout="vertical" initialValues={initialValues} onValuesChange={onValuesChange}>
{sections
? sections.map((s, i) => (
<div key={i}>
{i > 0 && <Divider style={{ margin: '16px 0' }} />}
<Typography.Text
strong
style={{ fontSize: 14, marginBottom: 12, display: 'block' }}
>
{s.title}
</Typography.Text>
<div style={gridStyle}>{s.fields}</div>
</div>
))
: children && <div style={gridStyle}>{children}</div>}
</Form>
</Drawer>
);
}

View File

@@ -0,0 +1,23 @@
import { Tooltip, Typography } from 'antd';
interface EntityNameProps {
name?: string | null;
id?: string;
fallbackLabel?: string;
}
export function EntityName({ name, id, fallbackLabel = '未知' }: EntityNameProps) {
if (name !== undefined && name !== null && name !== '') return <span>{name}</span>;
if (id) {
return (
<Tooltip title={`ID: ${id.slice(0, 8)}...`}>
<Typography.Text type="secondary" style={{ cursor: 'help' }}>
{fallbackLabel}
</Typography.Text>
</Tooltip>
);
}
return <Typography.Text type="secondary">{fallbackLabel}</Typography.Text>;
}

View File

@@ -0,0 +1,141 @@
import { Select, Spin, Input, Tooltip } from 'antd';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { useState, useEffect, useCallback } from 'react';
import { listPluginData, getPluginEntityRegistry } from '../api/pluginData';
interface EntitySelectProps {
pluginId: string;
entity: string;
labelField: string;
searchFields?: string[];
/** 跨插件引用的目标插件 manifest ID如 "erp-crm" */
refPlugin?: string;
/** 目标插件未安装时的降级显示文本 */
fallbackLabel?: string;
value?: string;
onChange?: (value: string, label: string) => void;
cascadeFrom?: string;
cascadeFilter?: string;
cascadeValue?: string;
placeholder?: string;
}
export default function EntitySelect({
pluginId,
entity,
labelField,
searchFields: _searchFields,
refPlugin,
fallbackLabel,
value,
onChange,
cascadeFrom,
cascadeFilter,
cascadeValue,
placeholder,
}: EntitySelectProps) {
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
const [loading, setLoading] = useState(false);
const [targetUnavailable, setTargetUnavailable] = useState(false);
const [resolvedPluginId, setResolvedPluginId] = useState<string | null>(null);
// 跨插件时:先解析 manifest_id → plugin UUID
useEffect(() => {
if (!refPlugin) {
setResolvedPluginId(pluginId);
return;
}
let cancelled = false;
(async () => {
try {
const registry = await getPluginEntityRegistry();
const match = registry.find((e) => e.manifest_id === refPlugin && e.entity_name === entity);
if (!cancelled) {
setResolvedPluginId(match ? match.plugin_id : null);
if (!match) setTargetUnavailable(true);
}
} catch {
if (!cancelled) {
setTargetUnavailable(true);
setResolvedPluginId(null);
}
}
})();
return () => { cancelled = true; };
}, [refPlugin, pluginId, entity]);
const effectivePluginId = resolvedPluginId || pluginId;
const fetchData = useCallback(
async (keyword?: string) => {
if (!resolvedPluginId && refPlugin) return;
setLoading(true);
try {
const filter: Record<string, string> | undefined =
cascadeFrom && cascadeFilter && cascadeValue
? { [cascadeFilter]: cascadeValue }
: undefined;
const result = await listPluginData(effectivePluginId, entity, 1, 20, {
search: keyword,
filter,
});
const items = (result.data || []).map((item) => ({
value: item.id,
label: String(item.data?.[labelField] ?? item.id),
}));
setOptions(items);
setTargetUnavailable(false);
} catch {
if (refPlugin) {
setTargetUnavailable(true);
setOptions([]);
}
} finally {
setLoading(false);
}
},
[effectivePluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue, refPlugin, resolvedPluginId],
);
useEffect(() => {
if (resolvedPluginId || !refPlugin) {
fetchData();
}
}, [fetchData, resolvedPluginId, refPlugin]);
// 目标插件未安装 → 降级显示
if (targetUnavailable) {
return (
<Input
value={value || ''}
placeholder={fallbackLabel || `外部引用 (${refPlugin})`}
disabled
suffix={
<Tooltip title="目标插件未安装,此字段暂时不可用">
<QuestionCircleOutlined style={{ color: '#999' }} />
</Tooltip>
}
/>
);
}
return (
<Select
showSearch
value={value}
placeholder={placeholder || '请选择'}
loading={loading}
options={options}
onSearch={(v) => fetchData(v)}
onChange={(v) => {
const opt = options.find((o) => o.value === v);
onChange?.(v, opt?.label || '');
}}
filterOption={false}
notFoundContent={loading ? <Spin size="small" /> : '无数据'}
allowClear
/>
);
}

View File

@@ -0,0 +1,54 @@
import { Component, type ReactNode } from 'react';
import { Button, Result } from 'antd';
interface Props {
children: ReactNode;
pageLevel?: boolean;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<Result
status="error"
title={this.props.pageLevel ? '页面加载出错' : '出了点问题'}
subTitle={this.props.pageLevel
? `错误信息:${this.state.error?.message || '未知错误'}`
: '请刷新页面重试'}
extra={[
<Button key="retry" type="primary" onClick={this.handleReset}>
</Button>,
<Button key="home" onClick={() => window.location.hash = '/'}>
</Button>,
]}
/>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,38 @@
import { Button, Flex, Space } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { useThemeMode } from '../hooks/useThemeMode';
interface FilterBarProps {
children: React.ReactNode;
onReset?: () => void;
extra?: React.ReactNode;
}
export function FilterBar({ children, onReset, extra }: FilterBarProps) {
const isDark = useThemeMode();
return (
<Flex
justify="space-between"
align="center"
style={{
padding: '12px 16px',
background: isDark ? '#1f1f1f' : '#fafafa',
borderRadius: 8,
marginBottom: 16,
}}
>
<Space wrap size="middle">
{children}
</Space>
<Space>
{onReset && (
<Button icon={<ReloadOutlined />} onClick={onReset}>
</Button>
)}
{extra}
</Space>
</Flex>
);
}

View File

@@ -0,0 +1,187 @@
import { useEffect, useRef } from 'react';
import { Badge, List, Popover, Button, Empty, Typography } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useMessageStore } from '../stores/message';
import { useThemeMode } from '../hooks/useThemeMode';
const { Text } = Typography;
export default function NotificationPanel() {
const navigate = useNavigate();
const unreadCount = useMessageStore((s) => s.unreadCount);
const recentMessages = useMessageStore((s) => s.recentMessages);
const markAsRead = useMessageStore((s) => s.markAsRead);
const isDark = useThemeMode();
const initializedRef = useRef(false);
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
const { fetchUnreadCount, fetchRecentMessages, connectSSE } = useMessageStore.getState();
fetchUnreadCount();
fetchRecentMessages();
const disconnectSSE = connectSSE();
const interval = setInterval(() => {
fetchUnreadCount();
fetchRecentMessages();
}, 60000);
return () => {
clearInterval(interval);
disconnectSSE();
initializedRef.current = false;
};
}, []);
const content = (
<div style={{ width: 360 }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
padding: '4px 0',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}></span>
{unreadCount > 0 && (
<Button
type="text"
size="small"
style={{ fontSize: 12, color: '#2563eb' }}
onClick={() => navigate('/messages')}
>
</Button>
)}
</div>
{recentMessages.length === 0 ? (
<Empty
description="暂无消息"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: '24px 0' }}
/>
) : (
<List
dataSource={recentMessages.slice(0, 5)}
renderItem={(item) => (
<List.Item
style={{
padding: '10px 12px',
margin: '2px 0',
borderRadius: 8,
cursor: 'pointer',
transition: 'background 0.15s ease',
border: 'none',
background: !item.is_read ? (isDark ? '#0f172a' : '#eff6ff') : 'transparent',
}}
onClick={() => {
if (!item.is_read) {
markAsRead(item.id);
}
navigate('/messages');
}}
onMouseEnter={(e) => {
if (item.is_read) {
e.currentTarget.style.background = isDark ? '#0f172a' : '#f1f5f9';
}
}}
onMouseLeave={(e) => {
if (item.is_read) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text
strong={!item.is_read}
ellipsis
style={{ maxWidth: 260, fontSize: 13 }}
>
{item.title}
</Text>
{!item.is_read && (
<span style={{
display: 'inline-block',
width: 6,
height: 6,
borderRadius: '50%',
background: '#2563eb',
flexShrink: 0,
}} />
)}
</div>
<Text
type="secondary"
ellipsis
style={{ maxWidth: 300, fontSize: 12, display: 'block', marginTop: 2 }}
>
{item.body}
</Text>
</div>
</List.Item>
)}
/>
)}
{recentMessages.length > 0 && (
<div style={{
textAlign: 'center',
paddingTop: 8,
marginTop: 4,
borderTop: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}>
<Button
type="text"
onClick={() => navigate('/messages')}
style={{ fontSize: 13, color: '#2563eb', fontWeight: 500 }}
>
</Button>
</div>
)}
</div>
);
return (
<Popover
content={content}
trigger="click"
placement="bottomRight"
overlayStyle={{ padding: 0 }}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.15s ease',
position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark ? '#0f172a' : '#f8fafc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<Badge count={unreadCount} size="small" offset={[4, -4]}>
<BellOutlined style={{
fontSize: 16,
color: isDark ? '#94a3b8' : '#475569',
}} />
</Badge>
</div>
</Popover>
);
}

View File

@@ -0,0 +1,79 @@
import { Card, Flex, Space, Typography, Button } from 'antd';
import { useThemeMode } from '../hooks/useThemeMode';
import { FilterBar } from './FilterBar';
interface PageContainerProps {
title: string;
subtitle?: string;
filters?: React.ReactNode;
onResetFilters?: () => void;
filterExtra?: React.ReactNode;
actions?: React.ReactNode;
batchActions?: React.ReactNode;
selectedCount?: number;
onClearSelection?: () => void;
onBack?: () => void;
children: React.ReactNode;
loading?: boolean;
}
export function PageContainer({
title,
subtitle,
filters,
onResetFilters,
filterExtra,
actions,
batchActions,
selectedCount,
onClearSelection,
onBack,
children,
loading,
}: PageContainerProps) {
const isDark = useThemeMode();
return (
<div style={{ padding: 24 }}>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
{onBack && (
<Button type="text" size="small" onClick={onBack} style={{ marginRight: 8 }}>
</Button>
)}
{title}
</Typography.Title>
{subtitle && (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{subtitle}
</Typography.Text>
)}
</div>
<Space>
{selectedCount ? batchActions : actions}
{selectedCount ? (
<Button size="small" onClick={onClearSelection}>
({selectedCount})
</Button>
) : null}
</Space>
</Flex>
{filters && (
<FilterBar onReset={onResetFilters} extra={filterExtra}>
{filters}
</FilterBar>
)}
<Card
styles={{ body: { padding: 0 } }}
style={{ background: isDark ? '#141414' : '#fff' }}
loading={loading}
>
{children}
</Card>
</div>
);
}

View File

@@ -0,0 +1,232 @@
import React, { useCallback, useMemo } from 'react';
import {
Form,
Input,
InputNumber,
Switch,
Select,
DatePicker,
Button,
message,
Divider,
Typography,
Tooltip,
} from 'antd';
import { QuestionCircleOutlined, SaveOutlined } from '@ant-design/icons';
import type {
PluginSettingField,
PluginSettingType,
} from '../api/plugins';
const { Text } = Typography;
interface PluginSettingsFormProps {
/** manifest 中声明的 settings 字段 */
fields: PluginSettingField[];
/** 当前存储的配置值 */
values: Record<string, unknown>;
/** 插件版本(乐观锁) */
recordVersion: number;
/** 保存回调 */
onSave: (config: Record<string, unknown>, version: number) => Promise<unknown>;
/** 是否只读 */
readOnly?: boolean;
}
/** 根据 manifest settings 声明自动渲染配置表单 */
const PluginSettingsForm: React.FC<PluginSettingsFormProps> = ({
fields,
values,
recordVersion,
onSave,
readOnly = false,
}) => {
const [form] = Form.useForm();
const [saving, setSaving] = React.useState(false);
const initialValues = useMemo(() => {
const merged: Record<string, unknown> = {};
for (const f of fields) {
merged[f.name] = values[f.name] ?? f.default_value ?? getDefaultForType(f.field_type);
}
return merged;
}, [fields, values]);
const handleSave = useCallback(async () => {
try {
const formValues = await form.validateFields();
setSaving(true);
await onSave(formValues, recordVersion);
message.success('配置已保存');
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) {
// antd 表单校验错误,无需额外提示
return;
}
message.error(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
}, [form, onSave, recordVersion]);
const grouped = useMemo(() => {
const groups = new Map<string, PluginSettingField[]>();
for (const f of fields) {
const group = f.group ?? '';
const list = groups.get(group) ?? [];
list.push(f);
groups.set(group, list);
}
return groups;
}, [fields]);
const renderField = (field: PluginSettingField) => {
const label = (
<span>
{field.display_name}
{field.description && (
<Tooltip title={field.description}>
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
</Tooltip>
)}
</span>
);
const rules: Array<{ required: boolean; message?: string; type?: 'string' | 'number' | 'boolean' | 'url' | 'email' }> = [];
if (field.required) {
rules.push({ required: true, message: `请输入${field.display_name}` });
}
const widget = renderWidget(field, readOnly);
return (
<Form.Item
key={field.name}
name={field.name}
label={label}
rules={rules}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
>
{widget}
</Form.Item>
);
};
const groupEntries = Array.from(grouped.entries());
return (
<Form
form={form}
layout="vertical"
initialValues={initialValues}
disabled={readOnly}
>
{groupEntries.map(([group, groupFields], gi) => (
<React.Fragment key={group || `__default_${gi}`}>
{group ? (
<Divider type="horizontal" orientationMargin={0} plain>
<Text strong>{group}</Text>
</Divider>
) : null}
{groupFields.map(renderField)}
</React.Fragment>
))}
{!readOnly && (
<Form.Item>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={handleSave}
>
</Button>
</Form.Item>
)}
</Form>
);
};
function renderWidget(field: PluginSettingField, readOnly: boolean): React.ReactNode {
switch (field.field_type) {
case 'text':
return <Input disabled={readOnly} placeholder={`请输入${field.display_name}`} />;
case 'number': {
const props: Record<string, unknown> = {
disabled: readOnly,
placeholder: `请输入${field.display_name}`,
style: { width: '100%' },
};
if (field.range) {
props.min = field.range[0];
props.max = field.range[1];
}
return <InputNumber {...props} />;
}
case 'boolean':
return <Switch disabled={readOnly} />;
case 'select':
return (
<Select
disabled={readOnly}
placeholder={`请选择${field.display_name}`}
options={(field.options ?? []).map((o) => {
if (typeof o === 'object' && o !== null && 'label' in o && 'value' in o) {
return o as { label: string; value: string };
}
return { label: String(o), value: String(o) };
})}
/>
);
case 'multiselect':
return (
<Select
mode="multiple"
disabled={readOnly}
placeholder={`请选择${field.display_name}`}
options={(field.options ?? []).map((o) => {
if (typeof o === 'object' && o !== null && 'label' in o && 'value' in o) {
return o as { label: string; value: string };
}
return { label: String(o), value: String(o) };
})}
/>
);
case 'color':
return <Input type="color" disabled={readOnly} style={{ width: 80 }} />;
case 'date':
return <DatePicker disabled={readOnly} style={{ width: '100%' }} />;
case 'datetime':
return <DatePicker showTime disabled={readOnly} style={{ width: '100%' }} />;
case 'json':
return <Input.TextArea disabled={readOnly} rows={4} placeholder="JSON 格式" />;
default:
return <Input disabled={readOnly} />;
}
}
function getDefaultForType(type: PluginSettingType): unknown {
switch (type) {
case 'text':
case 'color':
return '';
case 'number':
return 0;
case 'boolean':
return false;
case 'select':
return undefined;
case 'multiselect':
return [];
case 'date':
case 'datetime':
return undefined;
case 'json':
return '';
default:
return '';
}
}
export default PluginSettingsForm;

View File

@@ -0,0 +1,64 @@
import { Dropdown } from 'antd';
import { BgColorsOutlined } from '@ant-design/icons';
import { useAppStore, THEME_OPTIONS } from '../stores/app';
export default function ThemeSwitcher() {
const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme);
const content = (
<div style={{
padding: 8,
display: 'flex',
flexDirection: 'column',
gap: 6,
minWidth: 220,
background: 'var(--erp-bg-container)',
borderRadius: 12,
boxShadow: 'var(--erp-shadow-lg)',
}}>
{THEME_OPTIONS.map((opt) => {
const active = theme === opt.key;
return (
<div
key={opt.key}
onClick={() => setTheme(opt.key)}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 12px',
borderRadius: 8,
cursor: 'pointer',
border: `2px solid ${active ? opt.preview.primary : 'transparent'}`,
background: active ? `${opt.preview.primary}08` : 'transparent',
transition: 'all 0.15s ease',
}}
>
{/* 色块预览 */}
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
<div style={{ width: 20, height: 20, borderRadius: 4, background: opt.preview.primary }} />
<div style={{ width: 20, height: 20, borderRadius: 4, background: opt.preview.bg, border: '1px solid #e0e0e0' }} />
<div style={{ width: 20, height: 20, borderRadius: 4, background: opt.preview.surface, border: '1px solid #e0e0e0' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: active ? opt.preview.primary : '#333' }}>{opt.label}</div>
<div style={{ fontSize: 11, color: '#999', marginTop: 1 }}>{opt.desc}</div>
</div>
{active && (
<div style={{ width: 8, height: 8, borderRadius: 4, background: opt.preview.primary, flexShrink: 0 }} />
)}
</div>
);
})}
</div>
);
return (
<Dropdown popupRender={() => content} trigger={['click']} placement="bottomRight">
<div className="erp-header-btn" title="切换主题">
<BgColorsOutlined style={{ fontSize: 16 }} />
</div>
</Dropdown>
);
}

View File

@@ -0,0 +1,41 @@
import { useCallback, useState } from 'react';
import { message } from 'antd';
function extractErrorMessage(err: unknown): string {
if (err && typeof err === 'object' && 'response' in err) {
const resp = (err as { response?: { data?: { message?: string } } }).response;
return resp?.data?.message || '';
}
if (err instanceof Error) return err.message;
return '';
}
interface UseApiRequestReturn {
execute: <T>(fn: () => Promise<T>, successMsg?: string, errorMsg?: string) => Promise<T | null>;
loading: boolean;
}
export function useApiRequest(): UseApiRequestReturn {
const [loading, setLoading] = useState(false);
const execute = useCallback(async <T>(
fn: () => Promise<T>,
successMsg?: string,
errorMsg = '操作失败',
): Promise<T | null> => {
setLoading(true);
try {
const result = await fn();
if (successMsg) message.success(successMsg);
return result;
} catch (err) {
const msg = extractErrorMessage(err);
message.error(msg || errorMsg);
return null;
} finally {
setLoading(false);
}
}, []);
return { execute, loading };
}

View File

@@ -0,0 +1,24 @@
import { useState, useEffect, useRef } from 'react';
export function useCountUp(end: number, duration = 800) {
const [count, setCount] = useState(0);
const prevEnd = useRef(end);
useEffect(() => {
if (end === prevEnd.current && count > 0) return;
prevEnd.current = end;
if (end === 0) { setCount(0); return; }
const startTime = performance.now();
function tick(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setCount(Math.round(end * eased));
if (progress < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}, [end, duration]);
return count;
}

View File

@@ -0,0 +1,74 @@
import { useState, useCallback } from 'react';
import { useApiRequest } from './useApiRequest';
export interface UseCrudDrawerOptions<T> {
getId: (record: T) => string;
onCreate: (values: Record<string, unknown>) => Promise<void>;
onUpdate: (id: string, values: Record<string, unknown> & { version: number }) => Promise<void>;
onSuccess?: () => void;
}
export interface UseCrudDrawerReturn<T> {
open: boolean;
editingRecord: T | null;
initialValues: Record<string, unknown> | undefined;
openCreate: (defaults?: Record<string, unknown>) => void;
openEdit: (record: T, fieldMap?: (record: T) => Record<string, unknown>) => void;
close: () => void;
handleSubmit: (values: Record<string, unknown>) => Promise<void>;
loading: boolean;
}
export function useCrudDrawer<T extends { version: number }>(
options: UseCrudDrawerOptions<T>,
): UseCrudDrawerReturn<T> {
const [open, setOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<T | null>(null);
const [initialValues, setInitialValues] = useState<Record<string, unknown> | undefined>(undefined);
const { execute, loading } = useApiRequest();
const openCreate = useCallback((defaults?: Record<string, unknown>) => {
setEditingRecord(null);
setInitialValues(defaults);
setOpen(true);
}, []);
const openEdit = useCallback((record: T, fieldMap?: (record: T) => Record<string, unknown>) => {
setEditingRecord(record);
setInitialValues(fieldMap ? fieldMap(record) : (record as unknown as Record<string, unknown>));
setOpen(true);
}, []);
const close = useCallback(() => {
setOpen(false);
setEditingRecord(null);
setInitialValues(undefined);
}, []);
const handleSubmit = useCallback(
async (values: Record<string, unknown>) => {
if (editingRecord) {
await execute(
() => options.onUpdate(options.getId(editingRecord), { ...values, version: (editingRecord as unknown as { version: number }).version }),
'更新成功',
);
} else {
await execute(() => options.onCreate(values), '创建成功');
}
close();
options.onSuccess?.();
},
[editingRecord, options, close, execute],
);
return {
open,
editingRecord,
initialValues,
openCreate,
openEdit,
close,
handleSubmit,
loading,
};
}

View File

@@ -0,0 +1,6 @@
import { theme } from 'antd';
export function useDarkMode(): boolean {
const { token } = theme.useToken();
return token.colorBgBase !== '#ffffff' && token.colorBgBase !== '#fff';
}

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useDebouncedValue } from './useDebouncedValue'
describe('useDebouncedValue', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebouncedValue('hello'))
expect(result.current).toBe('hello')
})
it('debounces value updates', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 300),
{ initialProps: { value: 'a' } },
)
expect(result.current).toBe('a')
rerender({ value: 'b' })
expect(result.current).toBe('a')
act(() => { vi.advanceTimersByTime(299) })
expect(result.current).toBe('a')
act(() => { vi.advanceTimersByTime(1) })
expect(result.current).toBe('b')
})
it('resets timer on rapid updates', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 200),
{ initialProps: { value: 'a' } },
)
rerender({ value: 'b' })
act(() => { vi.advanceTimersByTime(100) })
rerender({ value: 'c' })
act(() => { vi.advanceTimersByTime(100) })
expect(result.current).toBe('a')
act(() => { vi.advanceTimersByTime(100) })
expect(result.current).toBe('c')
})
it('uses custom delay', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 500),
{ initialProps: { value: 'x' } },
)
rerender({ value: 'y' })
act(() => { vi.advanceTimersByTime(499) })
expect(result.current).toBe('x')
act(() => { vi.advanceTimersByTime(1) })
expect(result.current).toBe('y')
})
it('works with numeric values', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 100),
{ initialProps: { value: 0 } },
)
rerender({ value: 42 })
act(() => { vi.advanceTimersByTime(100) })
expect(result.current).toBe(42)
})
})

View File

@@ -0,0 +1,12 @@
import { useState, useEffect } from 'react';
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,31 @@
import { useEffect, useState, useMemo } from 'react';
import { listItemsByCode, type DictionaryItemInfo } from '../api/dictionaries';
export interface DictOption {
value: string;
label: string;
}
export function useDictionary(code: string, fallback?: DictOption[]) {
const [items, setItems] = useState<DictionaryItemInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
listItemsByCode(code)
.then((data) => setItems(data))
.catch(() => setItems([]))
.finally(() => setLoading(false));
}, [code]);
const options = useMemo<DictOption[]>(() => {
if (items.length > 0) {
return items
.sort((a, b) => a.sort_order - b.sort_order)
.map((item) => ({ value: item.value, label: item.label }));
}
return fallback ?? [];
}, [items, fallback]);
return { items, options, loading };
}

View File

@@ -0,0 +1,33 @@
import { useState, useCallback, useEffect, useRef } from 'react';
export interface UseListDataReturn<T> {
data: T[];
loading: boolean;
refresh: () => Promise<void>;
}
export function useListData<T>(fetchFn: () => Promise<T[]>, autoFetch = true): UseListDataReturn<T> {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
const refresh = useCallback(async () => {
setLoading(true);
try {
const result = await fetchFnRef.current();
setData(result);
} catch {
setData([]);
}
setLoading(false);
}, []);
useEffect(() => {
if (autoFetch) {
refresh();
}
}, [refresh, autoFetch]);
return { data, loading, refresh };
}

View File

@@ -0,0 +1,125 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { message } from 'antd';
interface PaginatedState<T> {
data: T[];
total: number;
page: number;
loading: boolean;
}
type FetchResult<T> = { data: T[]; total: number };
type OptionsConfig<F> = { pageSize?: number; defaultFilters: F; autoFetch?: boolean };
/**
* 通用分页数据 Hook封装 data / total / page / loading / fetch 逻辑。
*
* 支持三种签名:
* 1. 泛型筛选 (page, pageSize, filters: F) — 带结构化筛选的列表页
* 2. 三参数 (page, pageSize, search: string) — 带搜索的列表页
* 3. 两参数 (page, pageSize) — 纯分页,不含搜索
*/
// 重载签名
export function usePaginatedData<T, F>(
fetchFn: (page: number, pageSize: number, filters: F) => Promise<FetchResult<T>>,
options: OptionsConfig<F>,
): PaginatedResult<T, F>;
export function usePaginatedData<T>(
fetchFn:
| ((page: number, pageSize: number, search: string) => Promise<FetchResult<T>>)
| ((page: number, pageSize: number) => Promise<FetchResult<T>>),
pageSize?: number,
autoFetch?: boolean,
): PaginatedResult<T, string>;
/* eslint-disable @typescript-eslint/no-explicit-any -- 实现签名必须兼容所有重载 */
export function usePaginatedData<T, F = string>(
fetchFn: (...args: any[]) => Promise<FetchResult<T>>,
pageSizeOrOptions?: number | OptionsConfig<F>,
autoFetch = true,
): PaginatedResult<T, F> {
/* eslint-enable @typescript-eslint/no-explicit-any */
const isOptions = typeof pageSizeOrOptions === 'object' && pageSizeOrOptions !== null;
const options = pageSizeOrOptions as OptionsConfig<F>;
const pageSize = isOptions ? options.pageSize ?? 20 : (pageSizeOrOptions as number) ?? 20;
const shouldAutoFetch = isOptions ? options.autoFetch ?? true : autoFetch;
const defaultFilters = isOptions ? options.defaultFilters : ('' as unknown as F);
const [state, setState] = useState<PaginatedState<T>>({
data: [],
total: 0,
page: 1,
loading: false,
});
const [searchText, setSearchText] = useState('');
const [filters, setFilters] = useState<F>(defaultFilters);
const fetchFnRef = useRef(fetchFn);
const searchTextRef = useRef(searchText);
const filtersRef = useRef(filters);
const stateRef = useRef(state);
useEffect(() => {
fetchFnRef.current = fetchFn;
searchTextRef.current = searchText;
filtersRef.current = filters;
stateRef.current = state;
});
// 所有 fetch 统一走 useEffect通过 fetchTrigger 触发
const [fetchTrigger, setFetchTrigger] = useState(0);
const pendingPageRef = useRef<number | undefined>(undefined);
const isFirstRender = useRef(true);
// refresh 只负责设置目标页并递增 trigger实际 fetch 在 useEffect 中执行
const refresh = useCallback((p?: number) => {
pendingPageRef.current = p;
setFetchTrigger((t) => t + 1);
}, []);
useEffect(() => {
const targetPage = pendingPageRef.current ?? stateRef.current.page;
pendingPageRef.current = undefined;
if (isFirstRender.current) {
isFirstRender.current = false;
if (!shouldAutoFetch) return;
}
// eslint-disable-next-line react-hooks/set-state-in-effect -- 数据获取 hookloading → fetch → setState 是标准模式
setState((s) => ({ ...s, loading: true }));
let cancelled = false;
fetchFnRef.current(targetPage, pageSize, filtersRef.current ?? searchTextRef.current)
.then((result) => {
if (!cancelled) {
setState({ data: result.data, total: result.total, page: targetPage, loading: false });
}
})
.catch((err) => {
if (!cancelled) {
console.warn('[usePaginatedData] 加载数据失败:', err);
message.error('加载数据失败');
setState((s) => ({ ...s, loading: false }));
}
});
return () => { cancelled = true; };
// fetchTrigger 变化 = 手动 refreshfilters 变化 = 筛选刷新
}, [shouldAutoFetch, filters, fetchTrigger, pageSize]);
return { ...state, searchText, setSearchText, filters, setFilters, refresh };
}
interface PaginatedResult<T, F> extends PaginatedState<T> {
searchText: string;
setSearchText: (text: string) => void;
filters: F;
setFilters: (filters: F | ((prev: F) => F)) => void;
refresh: (page?: number) => void;
}

View File

@@ -0,0 +1,38 @@
import { useAuthStore } from '../stores/auth';
import { TAB_PERMISSIONS } from '../routeConfig';
export interface TabItem {
key: string;
[prop: string]: unknown;
}
/**
* 根据权限过滤详情页 Tab 列表。
*
* @param prefix - Tab 权限映射前缀(如 "patient"),对应 routeConfig.ts 中 "patient#tabKey"
* @param tabs - 完整 Tab 列表
* @returns 过滤后有权限可见的 Tab 列表
*/
export function usePermFilteredTabs<T extends TabItem>(prefix: string, tabs: T[]): T[] {
const permissions = useAuthStore((s) => s.permissions);
return tabs.filter((tab) => {
const lookupKey = `${prefix}#${tab.key}`;
const requiredPerm = TAB_PERMISSIONS[lookupKey];
// 未在 TAB_PERMISSIONS 中声明的 Tab安全默认不显示
if (requiredPerm === undefined && !(lookupKey in TAB_PERMISSIONS)) {
if (import.meta.env.DEV) {
console.warn(
`[usePermFilteredTabs] Tab "${lookupKey}" 未在 routeConfig.ts TAB_PERMISSIONS 中声明,已隐藏。请添加声明。`,
);
}
return false;
}
// 显式声明为 undefined无需权限→ 始终可见
if (requiredPerm === undefined) return true;
return permissions.includes(requiredPerm);
});
}

View File

@@ -0,0 +1,6 @@
import { useAuthStore } from '../stores/auth';
export function usePermission(code: string): { hasPermission: boolean } {
const permissions = useAuthStore((s) => s.permissions);
return { hasPermission: permissions.includes(code) };
}

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useThemeMode } from './useThemeMode'
describe('useThemeMode', () => {
it('should return false when no ConfigProvider is present (light default)', () => {
const { result } = renderHook(() => useThemeMode())
expect(result.current).toBe(false)
})
it('should return a boolean value', () => {
const { result } = renderHook(() => useThemeMode())
expect(typeof result.current).toBe('boolean')
})
})

View File

@@ -0,0 +1,11 @@
import { useAppStore } from '../stores/app';
/**
* 判断当前是否处于暗色主题模式。
*
* 通过 store 的主题名称判断,替代旧的 token 色值检测,
* 支持多主题系统blue / warm / dark / emerald
*/
export function useThemeMode(): boolean {
return useAppStore((s) => s.theme) === 'dark';
}

1368
apps/web/src/index.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,375 @@
import { useCallback, useState, useEffect, useMemo } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme, Menu } from 'antd';
import type { MenuItemType, SubMenuType } from 'antd/es/menu/hooks/useItems';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
LogoutOutlined,
SearchOutlined,
AppstoreOutlined,
UserOutlined,
RobotOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
import { useAuthStore } from '../stores/auth';
import { usePluginStore } from '../stores/plugin';
import type { PluginMenuGroup } from '../stores/plugin';
import { getMenusForUser, type MenuInfo } from '../api/menus';
import { getIcon } from '../utils/iconRegistry';
import NotificationPanel from '../components/NotificationPanel';
import ThemeSwitcher from '../components/ThemeSwitcher';
import AiSidebar from '../components/ai/AiSidebar';
const { Header, Sider, Content, Footer } = Layout;
// 路由标题 fallback — 仅保留后端菜单无法覆盖的路由
// 1. 动态参数路由(:id/:id/edit— 菜单表不会存储这些路径
// 2. 无后端菜单记录的静态页面路由
const routeTitleFallback: Record<string, string> = {
// 动态参数路由
'/health/patients/:id': '患者详情',
'/health/consultations/:id': '咨询详情',
'/health/articles/new': '新建文章',
'/health/articles/:id/edit': '编辑文章',
'/health/care-plans/:id': '护理计划详情',
'/health/shifts/:id': '班次详情',
'/health/ble-gateways/:id': '网关详情',
// 无后端菜单的静态路由
'/health/follow-up-records': '随访记录',
'/health/article-categories': '分类管理',
'/health/article-tags': '标签管理',
'/health/schedules': '排班管理',
'/health/appointments': '预约管理',
};
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
for (const m of menus) {
if (m.path === path) return m.title;
if (m.children) {
const found = getTitleFromMenus(path, m.children);
if (found) return found;
}
}
return undefined;
}
// 将后端 MenuInfo 树转为 Ant Design Menu 的 items 格式
type AntMenuItem = MenuItemType | SubMenuType;
function buildMenuItems(menus: MenuInfo[]): AntMenuItem[] {
return menus
.filter((m) => m.visible !== false && m.menu_type !== 'button')
.map((m) => {
const visibleChildren = m.children?.filter((c) => c.visible !== false && c.menu_type !== 'button') || [];
if ((m.menu_type === 'directory') && visibleChildren.length > 0) {
return {
key: m.id,
icon: getIcon(m.icon),
label: m.title,
children: buildMenuItems(visibleChildren),
};
}
return {
key: m.path || m.id,
icon: getIcon(m.icon),
label: m.title,
};
});
}
// 查找包含指定 path 的所有父级 key用于自动展开 openKeys
function findParentKeys(menus: MenuInfo[], targetPath: string): string[] {
const keys: string[] = [];
function walk(items: MenuInfo[], parents: string[]): boolean {
for (const m of items) {
if (m.path === targetPath) {
keys.push(...parents);
return true;
}
if (m.children) {
if (walk(m.children, [...parents, m.id])) return true;
}
}
return false;
}
walk(menus, []);
return keys;
}
// 插件菜单也纳入 Menu items
function buildPluginItems(groups: PluginMenuGroup[]): AntMenuItem[] {
return groups.map((g) => ({
key: `plugin-${g.pluginId}`,
icon: <AppstoreOutlined />,
label: g.pluginName,
children: g.items.map((item) => ({
key: item.key,
icon: getIcon(item.icon),
label: item.label,
})),
}));
}
export default function MainLayout({ children }: { children: React.ReactNode }) {
const { sidebarCollapsed, toggleSidebar } = useAppStore();
const themeConfig = useAppStore((s) => s.themeConfig);
const loadThemeConfig = useAppStore((s) => s.loadThemeConfig);
const { user, logout } = useAuthStore();
const pluginMenuItems = usePluginStore((s) => s.pluginMenuItems);
const pluginMenuGroups = usePluginStore((s) => s.pluginMenuGroups);
const fetchPlugins = usePluginStore((s) => s.fetchPlugins);
theme.useToken();
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname || '/';
// 动态菜单状态
const [dynamicMenus, setDynamicMenus] = useState<MenuInfo[]>([]);
const [menuLoading, setMenuLoading] = useState(true);
const [aiSidebarOpen, setAiSidebarOpen] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const menus = await getMenusForUser();
if (!cancelled) {
// 根据用户权限过滤菜单:菜单项声明 permission 时,用户必须有对应权限
const perms = useAuthStore.getState().permissions;
const isAdmin = useAuthStore.getState().user?.roles?.some((r) => typeof r === 'object' && r.code === 'admin') ?? false;
if (isAdmin) {
setDynamicMenus(menus);
} else {
const filterByPerm = (items: MenuInfo[]): MenuInfo[] =>
items
.map((m) => ({
...m,
children: m.children ? filterByPerm(m.children) : undefined,
}))
.filter((m) => {
if (m.menu_type === 'directory') return true;
if (!m.permission) return false;
return perms.includes(m.permission);
})
.filter((m) => m.menu_type === 'directory' || (m.children && m.children.length > 0) || (m.permission && perms.includes(m.permission)));
setDynamicMenus(filterByPerm(menus));
}
}
} catch {
// fallback: 使用空数组,保留插件菜单
}
if (!cancelled) setMenuLoading(false);
})();
return () => { cancelled = true; };
}, []);
// 合并动态菜单 + 插件菜单为 Ant Design Menu items
const allMenuItems = useMemo(() => {
const items = buildMenuItems(dynamicMenus);
if (pluginMenuGroups.length > 0) {
items.push(...buildPluginItems(pluginMenuGroups));
}
return items;
}, [dynamicMenus, pluginMenuGroups]);
// openKeys: 自动展开包含当前路由的父级
const autoExpandKeys = useMemo(() => {
const keys = findParentKeys(dynamicMenus, currentPath);
for (const g of pluginMenuGroups) {
if (g.items.some((it) => it.key === currentPath)) {
keys.push(`plugin-${g.pluginId}`);
}
}
return keys;
}, [currentPath, dynamicMenus, pluginMenuGroups]);
const [openKeys, setOpenKeys] = useState<string[]>([]);
const [lastExpandedPath, setLastExpandedPath] = useState(currentPath);
if (currentPath !== lastExpandedPath) {
setLastExpandedPath(currentPath);
if (autoExpandKeys.length > 0) {
setOpenKeys((prev) => [...new Set([...prev, ...autoExpandKeys])]);
}
}
// 加载插件菜单
useEffect(() => {
fetchPlugins(1, 'running');
}, [fetchPlugins]);
// 加载主题配置
useEffect(() => {
loadThemeConfig();
}, [loadThemeConfig]);
const handleLogout = useCallback(async () => {
await logout();
navigate('/login');
}, [logout, navigate]);
// 标题查找:先从动态菜单查找,再 fallback支持动态路径参数匹配
const headerTitle = useMemo(() => {
const fromMenus = getTitleFromMenus(currentPath, dynamicMenus);
if (fromMenus) return fromMenus;
// 尝试模式匹配 routeTitleFallback 的 key如 /health/patients/:id
for (const [pattern, title] of Object.entries(routeTitleFallback)) {
const regex = new RegExp('^' + pattern.replace(/:[^/]+/g, '[^/]+') + '$');
if (regex.test(currentPath)) return title;
}
return pluginMenuItems.find((p) => p.key === currentPath)?.label || '页面';
}, [currentPath, dynamicMenus, pluginMenuItems]);
const userMenuItems = [
{
key: 'profile',
icon: <UserOutlined />,
label: user?.display_name || user?.username || '用户',
disabled: true,
},
{ type: 'divider' as const },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
onClick: handleLogout,
},
];
const sidebarWidth = sidebarCollapsed ? 72 : 240;
return (
<Layout style={{ minHeight: '100vh' }}>
{/* 侧边栏 */}
<Sider
trigger={null}
collapsible
collapsed={sidebarCollapsed}
width={240}
collapsedWidth={72}
className="erp-sider-dark"
>
{/* Logo 区域 */}
<div className="erp-sidebar-logo" onClick={() => navigate('/')}>
<div className="erp-sidebar-logo-icon">H</div>
{!sidebarCollapsed && (
<span className="erp-sidebar-logo-text">{themeConfig?.brand_name || 'HMS 健康'}</span>
)}
</div>
{/* 菜单 */}
{menuLoading ? (
<div style={{ padding: 16, textAlign: 'center' }}>
<Spin size="small" />
</div>
) : (
<Menu
mode="inline"
inlineCollapsed={sidebarCollapsed}
items={allMenuItems}
selectedKeys={[currentPath]}
openKeys={openKeys}
onOpenChange={setOpenKeys}
onClick={({ key }) => navigate(key)}
className="erp-sidebar-menu"
/>
)}
</Sider>
{/* 右侧主区域 */}
<Layout
className="erp-main-layout"
style={{ marginLeft: sidebarWidth }}
>
{/* 顶部导航栏 */}
<Header className="erp-header">
{/* 左侧:折叠按钮 + 标题 */}
<Space size="middle" style={{ alignItems: 'center' }}>
<div className="erp-header-btn" onClick={toggleSidebar}>
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<span className="erp-header-title">
{headerTitle}
</span>
</Space>
{/* 右侧:搜索 + 主题切换 + 通知 + 用户 */}
<Space size={4} style={{ alignItems: 'center' }}>
<Tooltip title="搜索">
<div className="erp-header-btn">
<SearchOutlined style={{ fontSize: 16 }} />
</div>
</Tooltip>
<ThemeSwitcher />
<NotificationPanel />
<div className="erp-header-divider" />
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" trigger={['click']}>
<div className="erp-header-user">
<Avatar
size={30}
className="erp-user-avatar"
>
{(user?.display_name?.[0] || user?.username?.[0] || 'U').toUpperCase()}
</Avatar>
{!sidebarCollapsed && (
<span className="erp-user-name">
{user?.display_name || user?.username || 'User'}
</span>
)}
</div>
</Dropdown>
</Space>
</Header>
{/* 内容区域 */}
<Content style={{ padding: 20, minHeight: 'calc(100vh - 56px - 48px)' }}>
<div key={currentPath}>{children}</div>
</Content>
{/* 底部 */}
<Footer className="erp-footer">
{themeConfig?.brand_copyright || 'HMS 健康管理平台'}
</Footer>
</Layout>
{/* AI 助手浮动按钮 + 侧边栏 */}
<Tooltip title="AI 健康助手" placement="left">
<div
onClick={() => setAiSidebarOpen(true)}
style={{
position: 'fixed',
right: 24,
bottom: 32,
width: 48,
height: 48,
borderRadius: '50%',
background: 'linear-gradient(135deg, #1677ff 0%, #722ed1 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(22, 119, 255, 0.4)',
zIndex: 1000,
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.1)';
e.currentTarget.style.boxShadow = '0 6px 16px rgba(22, 119, 255, 0.6)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(22, 119, 255, 0.4)';
}}
>
<RobotOutlined style={{ color: '#fff', fontSize: 22 }} />
</div>
</Tooltip>
<AiSidebar open={aiSidebarOpen} onClose={() => setAiSidebarOpen(false)} />
</Layout>
);
}

10
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

179
apps/web/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,179 @@
import { useEffect, useState, useCallback } from 'react';
import { Row, Col, Spin, Empty } from 'antd';
import {
UserOutlined,
FileTextOutlined,
RightOutlined,
PartitionOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
BellOutlined,
SafetyCertificateOutlined,
ApartmentOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useThemeMode } from '../hooks/useThemeMode';
import { useMessageStore } from '../stores/message';
import { listAuditLogs, type AuditLogItem } from '../api/auditLogs';
import { listPendingTasks, type TaskInfo } from '../api/workflowTasks';
// --- Shared utilities ---
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
return `${days} 天前`;
}
const ACTION_LABELS: Record<string, string> = {
create: '创建', created: '创建', update: '更新', updated: '更新', delete: '删除', deleted: '删除',
login: '登录', 'user.create': '创建', 'user.update': '更新', 'user.delete': '删除',
};
const RESOURCE_LABELS: Record<string, string> = {
user: '用户', role: '角色', process_instance: '流程实例', organization: '组织',
message: '消息', plugin: '插件',
};
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
user: <UserOutlined />, role: <SafetyCertificateOutlined />,
organization: <ApartmentOutlined />,
process_instance: <FileTextOutlined />, message: <BellOutlined />,
};
function formatActionLabel(action: string): string {
return ACTION_LABELS[action] || ACTION_LABELS[action.split('.').pop() || ''] || action;
}
function formatResourceLabel(resource: string): string {
return RESOURCE_LABELS[resource] || RESOURCE_LABELS[resource.split('.').pop() || ''] || resource;
}
// --- Component ---
export default function Home() {
const navigate = useNavigate();
const isDark = useThemeMode();
const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount);
const [pendingTasks, setPendingTasks] = useState<TaskInfo[]>([]);
const [recentActivities, setRecentActivities] = useState<AuditLogItem[]>([]);
const [activitiesLoading, setActivitiesLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetchUnreadCount();
listPendingTasks(1, 5)
.then((result) => { if (!cancelled) setPendingTasks(result.data); })
.catch((err) => console.warn('[Home] 获取待办任务失败:', err));
listAuditLogs({ page: 1, page_size: 5 })
.then((result) => {
if (!cancelled) setRecentActivities(result.data.filter((a) => a.action !== 'login_failed'));
})
.catch((err) => console.warn('[Home] 获取审计日志失败:', err))
.finally(() => { if (!cancelled) setActivitiesLoading(false); });
return () => { cancelled = true; };
}, []);
const handleNavigate = useCallback((path: string) => {
navigate(path);
}, [navigate]);
return (
<div>
{/* 欢迎语 */}
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
<h2 style={{
fontSize: 24,
fontWeight: 700,
color: isDark ? '#f8fafc' : 'var(--erp-text-primary)',
margin: '0 0 4px',
letterSpacing: '-0.5px',
}}>
</h2>
<p style={{ fontSize: 14, color: 'var(--erp-text-secondary)', margin: 0 }}>
</p>
</div>
{/* 待办任务 + 最近动态 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={14}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-1">
<div className="erp-section-header">
<CheckCircleOutlined className="erp-section-icon" />
<span className="erp-section-title"></span>
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--erp-text-secondary)' }}>
{pendingTasks.length}
</span>
</div>
<div className="erp-task-list">
{pendingTasks.length === 0 ? (
<Empty description="暂无待办任务" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
pendingTasks.map((task) => (
<div
key={task.id}
className="erp-task-item"
style={{ '--task-color': 'var(--erp-primary)' } as React.CSSProperties}
onClick={() => handleNavigate('/workflow')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate('/workflow'); }}
>
<div className="erp-task-item-icon"><PartitionOutlined /></div>
<div className="erp-task-item-content">
<div className="erp-task-item-title">{task.node_name || task.definition_name || '流程任务'}</div>
<div className="erp-task-item-meta">
<span>{task.definition_name || '工作流'}</span>
<span>{task.status === 'pending' ? '待处理' : task.status}</span>
</div>
</div>
<span className="erp-task-priority erp-task-priority-medium"></span>
<RightOutlined style={{ color: 'var(--erp-text-tertiary)', fontSize: 12 }} />
</div>
))
)}
</div>
</div>
</Col>
<Col xs={24} lg={10}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2" style={{ height: '100%' }}>
<div className="erp-section-header">
<ClockCircleOutlined className="erp-section-icon" />
<span className="erp-section-title"></span>
</div>
<div className="erp-activity-list">
{activitiesLoading ? (
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
) : recentActivities.length === 0 ? (
<Empty description="暂无动态" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
recentActivities.map((log) => (
<div key={log.id} className="erp-activity-item">
<div className="erp-activity-dot">
{RESOURCE_ICONS[log.resource_type] || <FileTextOutlined />}
</div>
<div className="erp-activity-content">
<div className="erp-activity-text">
{formatActionLabel(log.action)}{formatResourceLabel(log.resource_type)}
</div>
<div className="erp-activity-time">{formatTimeAgo(log.created_at)}</div>
</div>
</div>
))
)}
</div>
</div>
</Col>
</Row>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, message, Divider } from 'antd';
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import { useAuthStore } from '../stores/auth';
import { handleApiError } from '../api/client';
import ThemeSwitcher from '../components/ThemeSwitcher';
import { getPublicBrand, type BrandConfig } from '../api/themes';
export default function Login() {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const loading = useAuthStore((s) => s.loading);
const [messageApi, contextHolder] = message.useMessage();
const [brand, setBrand] = useState<BrandConfig | null>(null);
useEffect(() => {
getPublicBrand().then(setBrand);
}, []);
const onFinish = async (values: { username: string; password: string }) => {
try {
await login(values.username, values.password);
messageApi.success('登录成功');
navigate('/');
} catch (err: unknown) {
handleApiError(err, '登录失败,请检查用户名和密码');
}
};
return (
<div className="login-root">
{contextHolder}
{/* 左侧品牌展示区 */}
<div className="login-brand-panel">
<div className="deco-circle" style={{ top: '-20%', right: '-10%', width: 500, height: 500 }} />
<div className="deco-circle" style={{ bottom: '-15%', left: '-8%', width: 400, height: 400, background: 'rgba(255, 255, 255, 0.03)' }} />
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center', maxWidth: 480 }}>
<div className="brand-icon">
<SafetyCertificateOutlined />
</div>
<h1 className="brand-title">{brand?.brand_name || 'HMS 健康管理平台'}</h1>
<p className="brand-desc">{brand?.brand_slogan || '新一代健康管理平台'}</p>
<p className="brand-sub-desc">{brand?.brand_features || '患者管理 · 健康监测 · 随访管理 · AI 智能分析'}</p>
<div style={{ marginTop: 48, display: 'flex', gap: 32, justifyContent: 'center' }}>
{[
{ label: '多租户架构', value: 'SaaS' },
{ label: '模块化设计', value: '可插拔' },
{ label: '事件驱动', value: '可扩展' },
].map((item) => (
<div key={item.label} style={{ textAlign: 'center' }}>
<div className="feature-item-value">{item.value}</div>
<div className="feature-item-label">{item.label}</div>
</div>
))}
</div>
</div>
</div>
{/* 右侧登录表单区 */}
<main className="login-form-panel">
<div className="login-theme-switcher">
<ThemeSwitcher />
</div>
<div style={{ maxWidth: 360, width: '100%', margin: '0 auto' }}>
<h2 className="form-title"></h2>
<p className="form-subtitle"></p>
<Divider style={{ margin: '24px 0' }} />
<Form name="login" onFinish={onFinish} autoComplete="off" size="large" layout="vertical">
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined style={{ color: 'var(--login-input-icon-color)' }} />}
placeholder="用户名"
style={{ height: 44, borderRadius: 'var(--erp-radius-md)' }}
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: 'var(--login-input-icon-color)' }} />}
placeholder="密码"
style={{ height: 44, borderRadius: 'var(--erp-radius-md)' }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{ height: 44, borderRadius: 'var(--erp-radius-md)', fontSize: 15, fontWeight: 600 }}
>
</Button>
</Form.Item>
</Form>
<div className="form-footer">
{brand?.brand_copyright || 'HMS 健康管理平台 · ©汕头市智界科技有限公司'}
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Tabs } from 'antd';
import { BellOutlined, MailOutlined, FileTextOutlined, SettingOutlined } from '@ant-design/icons';
import NotificationList from './messages/NotificationList';
import MessageTemplates from './messages/MessageTemplates';
import NotificationPreferences from './messages/NotificationPreferences';
import type { MessageQuery } from '../api/messages';
const UNREAD_FILTER: MessageQuery = { is_read: false };
export default function Messages() {
const [activeKey, setActiveKey] = useState('all');
return (
<div>
<div className="erp-page-header" style={{ borderBottom: 'none', marginBottom: 0, paddingBottom: 8 }}>
<div>
<h4></h4>
<div className="erp-page-subtitle"></div>
</div>
</div>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
style={{ marginTop: 8 }}
items={[
{
key: 'all',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<MailOutlined style={{ fontSize: 14 }} />
</span>
),
children: <NotificationList />,
},
{
key: 'unread',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<BellOutlined style={{ fontSize: 14 }} />
</span>
),
children: <NotificationList queryFilter={UNREAD_FILTER} />,
},
{
key: 'templates',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FileTextOutlined style={{ fontSize: 14 }} />
</span>
),
children: <MessageTemplates />,
},
{
key: 'preferences',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<SettingOutlined style={{ fontSize: 14 }} />
</span>
),
children: <NotificationPreferences />,
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,371 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import {
Tree,
Button,
Space,
Form,
Input,
InputNumber,
Table,
Popconfirm,
Empty,
Tag,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
ApartmentOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import { useThemeMode } from '../hooks/useThemeMode';
import { DrawerForm } from '../components/DrawerForm';
import { useCrudDrawer } from '../hooks/useCrudDrawer';
import { useApiRequest } from '../hooks/useApiRequest';
import {
listOrgTree,
createOrg,
updateOrg,
deleteOrg,
listDeptTree,
createDept,
deleteDept,
listPositions,
createPosition,
deletePosition,
type OrganizationInfo,
type DepartmentInfo,
type PositionInfo,
} from '../api/orgs';
export default function Organizations() {
const isDark = useThemeMode();
const { execute } = useApiRequest();
const cardStyle = {
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
};
// --- Org tree state ---
const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]);
const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null);
// --- Department tree state ---
const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]);
const [selectedDept, setSelectedDept] = useState<DepartmentInfo | null>(null);
// --- Position list state ---
const [positions, setPositions] = useState<PositionInfo[]>([]);
// --- Ref for drawer onSuccess callback (avoids before-declaration issue) ---
const refreshOrgTreeRef = useRef<() => void>(() => {});
// --- Fetch org tree ---
const fetchOrgTree = useCallback(async () => {
try {
const tree = await listOrgTree();
setOrgTree(tree);
if (selectedOrg) {
const stillExists = findOrgInTree(tree, selectedOrg.id);
if (!stillExists) { setSelectedOrg(null); setDeptTree([]); setPositions([]); }
}
} catch { /* silent */ }
}, [selectedOrg]);
refreshOrgTreeRef.current = () => { fetchOrgTree(); };
useEffect(() => { fetchOrgTree(); }, [fetchOrgTree]);
// --- Dept Drawer ---
const [deptDrawerOpen, setDeptDrawerOpen] = useState(false);
// --- Position Drawer ---
const [positionDrawerOpen, setPositionDrawerOpen] = useState(false);
// --- Org Drawer (uses ref to avoid before-declaration) ---
const orgDrawer = useCrudDrawer<OrganizationInfo>({
getId: (r) => r.id,
onCreate: async (values) => {
await createOrg({ ...(values as { name: string; code?: string; sort_order?: number }), parent_id: selectedOrg?.id });
},
onUpdate: async (id, values) => {
await updateOrg(id, values as { name: string; code?: string; sort_order?: number; version: number });
},
onSuccess: () => { refreshOrgTreeRef.current(); },
});
// --- Fetch dept tree ---
const fetchDeptTree = useCallback(async () => {
if (!selectedOrg) return;
try {
const tree = await listDeptTree(selectedOrg.id);
setDeptTree(tree);
if (selectedDept) {
const stillExists = findDeptInTree(tree, selectedDept.id);
if (!stillExists) { setSelectedDept(null); setPositions([]); }
}
} catch { /* silent */ }
}, [selectedOrg, selectedDept]);
useEffect(() => { fetchDeptTree(); }, [fetchDeptTree]);
// --- Fetch positions ---
const fetchPositions = useCallback(async () => {
if (!selectedDept) return;
try {
setPositions(await listPositions(selectedDept.id));
} catch { /* silent */ }
}, [selectedDept]);
useEffect(() => { fetchPositions(); }, [fetchPositions]);
// --- Org handlers ---
const handleDeleteOrg = async (id: string) => {
await execute(() => deleteOrg(id), '组织已删除');
setSelectedOrg(null); setDeptTree([]); setPositions([]);
fetchOrgTree();
};
// --- Dept handlers ---
const handleCreateDept = async (values: Record<string, unknown>) => {
if (!selectedOrg) return;
await execute(() => createDept(selectedOrg.id, {
name: values.name as string, code: values.code as string | undefined,
parent_id: selectedDept?.id, sort_order: values.sort_order as number | undefined,
}), '部门创建成功');
setDeptDrawerOpen(false);
fetchDeptTree();
};
const handleDeleteDept = async (id: string) => {
await execute(() => deleteDept(id), '部门已删除');
setSelectedDept(null); setPositions([]);
fetchDeptTree();
};
// --- Position handlers ---
const handleCreatePosition = async (values: Record<string, unknown>) => {
if (!selectedDept) return;
await execute(() => createPosition(selectedDept.id, {
name: values.name as string, code: values.code as string | undefined,
level: values.level as number | undefined, sort_order: values.sort_order as number | undefined,
}), '岗位创建成功');
setPositionDrawerOpen(false);
fetchPositions();
};
const handleDeletePosition = async (id: string) => {
await execute(() => deletePosition(id), '岗位已删除');
fetchPositions();
};
// --- Tree node converters ---
const convertOrgTree = (items: OrganizationInfo[]): DataNode[] =>
items.map((item) => ({
key: item.id,
title: <span>{item.name} {item.code && <Tag style={{ marginLeft: 4, background: isDark ? '#0f172a' : '#eff6ff', border: 'none', color: '#2563eb', fontSize: 11 }}>{item.code}</Tag>}</span>,
children: convertOrgTree(item.children),
}));
const convertDeptTree = (items: DepartmentInfo[]): DataNode[] =>
items.map((item) => ({
key: item.id,
title: <span>{item.name} {item.code && <Tag style={{ marginLeft: 4, background: isDark ? '#0f172a' : '#ECFDF5', border: 'none', color: '#059669', fontSize: 11 }}>{item.code}</Tag>}</span>,
children: convertDeptTree(item.children),
}));
const onSelectOrg = (selectedKeys: React.Key[]) => {
if (selectedKeys.length === 0) { setSelectedOrg(null); setDeptTree([]); setSelectedDept(null); setPositions([]); return; }
setSelectedOrg(findOrgInTree(orgTree, selectedKeys[0] as string));
setSelectedDept(null); setPositions([]);
};
const onSelectDept = (selectedKeys: React.Key[]) => {
if (selectedKeys.length === 0) { setSelectedDept(null); setPositions([]); return; }
setSelectedDept(findDeptInTree(deptTree, selectedKeys[0] as string));
};
const positionColumns = [
{ title: '岗位名称', dataIndex: 'name', key: 'name' },
{ title: '编码', dataIndex: 'code', key: 'code', render: (v?: string) => v || '-' },
{ title: '级别', dataIndex: 'level', key: 'level' },
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order' },
{
title: '操作', key: 'actions',
render: (_: unknown, record: PositionInfo) => (
<Popconfirm title="确定删除此岗位?" onConfirm={() => handleDeletePosition(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
),
},
];
return (
<div>
<div className="erp-page-header">
<div>
<h4><ApartmentOutlined style={{ marginRight: 8, color: '#2563eb' }} /></h4>
<div className="erp-page-subtitle"></div>
</div>
</div>
<div style={{ display: 'flex', gap: 16, minHeight: 500 }}>
{/* 左栏:组织树 */}
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px', borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}></span>
<Space size={4}>
<Button size="small" type="text" icon={<PlusOutlined />} onClick={() => orgDrawer.openCreate()} />
{selectedOrg && (
<>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => orgDrawer.openEdit(selectedOrg, (r) => ({
name: r.name, code: r.code, sort_order: r.sort_order,
}))} />
<Popconfirm title="确定删除此组织?" onConfirm={() => handleDeleteOrg(selectedOrg.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</>
)}
</Space>
</div>
<div style={{ padding: 12 }}>
{orgTree.length > 0 ? (
<Tree showLine defaultExpandAll treeData={convertOrgTree(orgTree)} onSelect={onSelectOrg} selectedKeys={selectedOrg ? [selectedOrg.id] : []} />
) : <Empty description="暂无组织" />}
</div>
</div>
{/* 中栏:部门树 */}
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px', borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{selectedOrg ? `${selectedOrg.name} · 部门` : '部门'}</span>
{selectedOrg && (
<Space size={4}>
<Button size="small" type="text" icon={<PlusOutlined />} onClick={() => setDeptDrawerOpen(true)} />
{selectedDept && (
<Popconfirm title="确定删除此部门?" onConfirm={() => handleDeleteDept(selectedDept.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
)}
</div>
<div style={{ padding: 12 }}>
{selectedOrg ? (
deptTree.length > 0 ? (
<Tree showLine defaultExpandAll treeData={convertDeptTree(deptTree)} onSelect={onSelectDept} selectedKeys={selectedDept ? [selectedDept.id] : []} />
) : <Empty description="暂无部门" />
) : <Empty description="请先选择组织" />}
</div>
</div>
{/* 右栏:岗位表 */}
<div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px', borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{selectedDept ? `${selectedDept.name} · 岗位` : '岗位'}</span>
{selectedDept && (
<Button size="small" type="text" icon={<PlusOutlined />} onClick={() => setPositionDrawerOpen(true)}></Button>
)}
</div>
<div style={{ padding: '0 4px' }}>
{selectedDept ? (
<Table columns={positionColumns} dataSource={positions} rowKey="id" size="small" pagination={false} />
) : <div style={{ padding: 24 }}><Empty description="请先选择部门" /></div>}
</div>
</div>
</div>
{/* Org Drawer */}
<DrawerForm
title={orgDrawer.editingRecord ? '编辑组织' : selectedOrg ? `${selectedOrg.name} 下新建子组织` : '新建根组织'}
open={orgDrawer.open}
onClose={orgDrawer.close}
onSubmit={orgDrawer.handleSubmit}
initialValues={orgDrawer.initialValues}
loading={orgDrawer.loading}
width={480}
columns={1}
>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入组织名称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码"><Input /></Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</DrawerForm>
{/* Dept Drawer */}
<DrawerForm
title={selectedDept ? `${selectedDept.name} 下新建子部门` : `${selectedOrg?.name} 下新建部门`}
open={deptDrawerOpen}
onClose={() => setDeptDrawerOpen(false)}
onSubmit={handleCreateDept}
initialValues={{ sort_order: 0 }}
loading={false}
width={480}
columns={1}
>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入部门名称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码"><Input /></Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</DrawerForm>
{/* Position Drawer */}
<DrawerForm
title={`${selectedDept?.name} 下新建岗位`}
open={positionDrawerOpen}
onClose={() => setPositionDrawerOpen(false)}
onSubmit={handleCreatePosition}
initialValues={{ level: 1, sort_order: 0 }}
loading={false}
width={480}
columns={1}
>
<Form.Item name="name" label="岗位名称" rules={[{ required: true, message: '请输入岗位名称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码"><Input /></Form.Item>
<Form.Item name="level" label="级别" initialValue={1}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</DrawerForm>
</div>
);
}
function findOrgInTree(tree: OrganizationInfo[], id: string): OrganizationInfo | null {
for (const item of tree) {
if (item.id === id) return item;
const found = findOrgInTree(item.children, id);
if (found) return found;
}
return null;
}
function findDeptInTree(tree: DepartmentInfo[], id: string): DepartmentInfo | null {
for (const item of tree) {
if (item.id === id) return item;
const found = findDeptInTree(item.children, id);
if (found) return found;
}
return null;
}

View File

@@ -0,0 +1,409 @@
import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Tag,
message,
Upload,
Modal,
Input,
Drawer,
Descriptions,
Popconfirm,
Form,
Tabs,
theme,
} from 'antd';
import {
UploadOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CloudDownloadOutlined,
DeleteOutlined,
ReloadOutlined,
HeartOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { PluginInfo, PluginStatus, PluginSchemaResponse } from '../api/plugins';
import {
listPlugins,
uploadPlugin,
installPlugin,
enablePlugin,
disablePlugin,
uninstallPlugin,
purgePlugin,
getPluginHealth,
getPluginSchema,
updatePluginConfig,
} from '../api/plugins';
import PluginSettingsForm from '../components/PluginSettingsForm';
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
uploaded: { color: '#475569', label: '已上传' },
installed: { color: '#2563EB', label: '已安装' },
enabled: { color: '#059669', label: '已启用' },
running: { color: '#059669', label: '运行中' },
disabled: { color: '#dc2626', label: '已禁用' },
uninstalled: { color: '#9333EA', label: '已卸载' },
};
export default function PluginAdmin() {
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [manifestText, setManifestText] = useState('');
const [wasmFile, setWasmFile] = useState<File | null>(null);
const [detailPlugin, setDetailPlugin] = useState<PluginInfo | null>(null);
const [schemaData, setSchemaData] = useState<PluginSchemaResponse | null>(null);
const [healthDetail, setHealthDetail] = useState<Record<string, unknown> | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const { token } = theme.useToken();
const fetchPlugins = useCallback(async (p = page) => {
setLoading(true);
try {
const result = await listPlugins(p);
setPlugins(result.data);
setTotal(result.total);
} catch {
message.error('加载插件列表失败');
}
setLoading(false);
}, [page]);
useEffect(() => {
fetchPlugins();
}, [fetchPlugins]);
// 打开详情时加载 schema含 settings
useEffect(() => {
if (!detailPlugin) {
setSchemaData(null);
return;
}
getPluginSchema(detailPlugin.id)
.then(setSchemaData)
.catch(() => setSchemaData(null));
}, [detailPlugin]);
const handleUpload = async () => {
if (!wasmFile || !manifestText.trim()) {
message.warning('请选择 WASM 文件并填写 Manifest');
return;
}
try {
await uploadPlugin(wasmFile, manifestText);
message.success('插件上传成功');
setUploadModalOpen(false);
setWasmFile(null);
setManifestText('');
fetchPlugins();
} catch {
message.error('插件上传失败');
}
};
const handleAction = async (id: string, action: () => Promise<PluginInfo>, label: string) => {
setActionLoading(id);
try {
await action();
message.success(`${label}成功`);
fetchPlugins();
if (detailPlugin?.id === id) {
setDetailPlugin(null);
}
} catch {
message.error(`${label}失败`);
}
setActionLoading(null);
};
const handleHealthCheck = async (id: string) => {
try {
const result = await getPluginHealth(id);
setHealthDetail(result.details);
} catch {
message.error('健康检查失败');
}
};
const getActions = (record: PluginInfo) => {
const id = record.id;
const btns: React.ReactNode[] = [];
switch (record.status) {
case 'uploaded':
btns.push(
<Button
key="install"
size="small"
icon={<CloudDownloadOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => installPlugin(id), '安装')}
>
</Button>,
);
break;
case 'installed':
btns.push(
<Button
key="enable"
size="small"
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => enablePlugin(id), '启用')}
>
</Button>,
);
break;
case 'enabled':
case 'running':
btns.push(
<Button
key="disable"
size="small"
danger
icon={<PauseCircleOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => disablePlugin(id), '停用')}
>
</Button>,
);
break;
case 'disabled':
btns.push(
<Button
key="enable"
size="small"
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => enablePlugin(id), '启用')}
>
</Button>,
<Button
key="uninstall"
size="small"
icon={<DeleteOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => uninstallPlugin(id), '卸载')}
>
</Button>,
);
break;
}
return btns;
};
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 180 },
{ title: '版本', dataIndex: 'version', key: 'version', width: 80 },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: PluginStatus) => {
const cfg = STATUS_CONFIG[status] || { color: '#475569', label: status };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{ title: '作者', dataIndex: 'author', key: 'author', width: 120 },
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '操作',
key: 'action',
width: 320,
render: (_: unknown, record: PluginInfo) => (
<Space size="small">
{getActions(record)}
<Button size="small" onClick={() => setDetailPlugin(record)}>
</Button>
<Popconfirm
title="确定要清除该插件记录吗?"
onConfirm={() => handleAction(record.id, async () => { await purgePlugin(record.id); return record; }, '清除')}
>
<Button size="small" danger disabled={!['uninstalled', 'disabled', 'uploaded', 'installed'].includes(record.status)}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Space>
<Button icon={<UploadOutlined />} type="primary" onClick={() => setUploadModalOpen(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchPlugins()}>
</Button>
</Space>
</div>
<Table
columns={columns}
dataSource={plugins}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => setPage(p),
showTotal: (t) => `${t} 个插件`,
}}
/>
<Modal
title="上传插件"
open={uploadModalOpen}
onOk={handleUpload}
onCancel={() => setUploadModalOpen(false)}
okText="上传"
width={600}
>
<Form layout="vertical">
<Form.Item label="WASM 文件" required>
<Upload
beforeUpload={(file) => {
setWasmFile(file);
return false;
}}
maxCount={1}
accept=".wasm"
fileList={[]}
onRemove={() => setWasmFile(null)}
>
<Button icon={<UploadOutlined />}> WASM </Button>
</Upload>
</Form.Item>
<Form.Item label="Manifest (TOML)" required>
<Input.TextArea
rows={12}
value={manifestText}
onChange={(e) => setManifestText(e.target.value)}
placeholder="[metadata]
id = &quot;my-plugin&quot;
name = &quot;我的插件&quot;
version = &quot;0.1.0&quot;"
/>
</Form.Item>
</Form>
</Modal>
<Drawer
title={detailPlugin ? `插件详情: ${detailPlugin.name}` : '插件详情'}
open={!!detailPlugin}
onClose={() => {
setDetailPlugin(null);
setHealthDetail(null);
setSchemaData(null);
}}
width={500}
>
{detailPlugin && (
<Tabs
defaultActiveKey="info"
items={[
{
key: 'info',
label: '基本信息',
children: (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item>
<Descriptions.Item label="名称">{detailPlugin.name}</Descriptions.Item>
<Descriptions.Item label="版本">{detailPlugin.version}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_CONFIG[detailPlugin.status]?.color}>
{STATUS_CONFIG[detailPlugin.status]?.label || detailPlugin.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="作者">{detailPlugin.author || '-'}</Descriptions.Item>
<Descriptions.Item label="描述">{detailPlugin.description || '-'}</Descriptions.Item>
<Descriptions.Item label="安装时间">{detailPlugin.installed_at || '-'}</Descriptions.Item>
<Descriptions.Item label="启用时间">{detailPlugin.enabled_at || '-'}</Descriptions.Item>
<Descriptions.Item label="实体数量">{detailPlugin.entities.length}</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16 }}>
<Button
icon={<HeartOutlined />}
onClick={() => handleHealthCheck(detailPlugin.id)}
style={{ marginBottom: 8 }}
>
</Button>
{healthDetail && (
<pre
style={{
background: token.colorBgContainer,
padding: 12,
borderRadius: 6,
fontSize: 12,
overflow: 'auto',
}}
>
{JSON.stringify(healthDetail, null, 2)}
</pre>
)}
</div>
</>
),
},
...(schemaData?.settings
? [
{
key: 'settings',
label: (
<span>
<SettingOutlined />
</span>
),
children: (
<PluginSettingsForm
fields={schemaData.settings.fields}
values={detailPlugin.config as Record<string, unknown>}
recordVersion={detailPlugin.record_version}
onSave={async (config, version) => {
const updated = await updatePluginConfig(
detailPlugin.id,
config,
version,
);
setDetailPlugin({ ...detailPlugin, ...updated });
}}
readOnly={detailPlugin.status !== 'enabled' && detailPlugin.status !== 'running'}
/>
),
},
]
: []),
]}
/>
)}
</Drawer>
</div>
);
}

View File

@@ -0,0 +1 @@
export { default } from './PluginCRUDPage/PluginCRUDPageInner';

View File

@@ -0,0 +1,102 @@
import { Drawer, Descriptions, Tag } from 'antd';
import type { PluginFieldSchema, PluginEntitySchema, PluginSectionSchema } from '../../api/plugins';
import PluginCRUDPageInner from './PluginCRUDPageInner';
interface DetailDrawerProps {
open: boolean;
record: Record<string, unknown> | null;
displayName: string;
fields: PluginFieldSchema[];
sections: PluginSectionSchema[];
allEntities: PluginEntitySchema[];
pluginId: string;
entityName: string;
onClose: () => void;
}
export default function DetailDrawer({
open,
record,
displayName,
fields,
sections,
allEntities,
pluginId,
entityName: _entityName,
onClose,
}: DetailDrawerProps) {
if (!record) return null;
return (
<Drawer
title={displayName + ' 详情'}
open={open}
onClose={onClose}
width={640}
>
{sections.length > 0 ? (
sections.map((section, idx) => {
if (section.type === 'fields') {
return (
<div key={idx} style={{ marginBottom: 24 }}>
<h4>{section.label}</h4>
<Descriptions column={2} bordered size="small">
{section.fields.map((fieldName) => {
const fieldDef = fields.find((f) => f.name === fieldName);
const val = record[fieldName];
return (
<Descriptions.Item
key={fieldName}
label={fieldDef?.display_name || fieldName}
>
{typeof val === 'boolean' ? (
val ? <Tag color="green"></Tag> : <Tag></Tag>
) : (
String(val ?? '-')
)}
</Descriptions.Item>
);
})}
</Descriptions>
</div>
);
}
if (section.type === 'crud') {
const secEntity = allEntities.find((e) => e.name === section.entity);
return (
<div key={idx} style={{ marginBottom: 24 }}>
<h4>{section.label}</h4>
{secEntity && (
<PluginCRUDPageInner
pluginIdOverride={pluginId}
entityOverride={section.entity}
filterField={section.filter_field}
filterValue={String(record._id ?? '')}
enableViews={section.enable_views}
compact
/>
)}
</div>
);
}
return null;
})
) : (
<Descriptions column={2} bordered size="small">
{fields.map((field) => {
const val = record[field.name];
return (
<Descriptions.Item key={field.name} label={field.display_name || field.name}>
{typeof val === 'boolean' ? (
val ? <Tag color="green"></Tag> : <Tag></Tag>
) : (
String(val ?? '-')
)}
</Descriptions.Item>
);
})}
</Descriptions>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react';
import { Modal, Upload, Alert, Button, message } from 'antd';
import { importPluginData, type ImportResult } from '../../api/pluginData';
interface ImportModalProps {
open: boolean;
pluginId: string;
entityName: string;
onClose: () => void;
onSuccess: () => void;
}
export default function ImportModal({ open, pluginId, entityName, onClose, onSuccess }: ImportModalProps) {
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const handleClose = () => {
onClose();
setImportResult(null);
};
return (
<Modal
title="导入数据"
open={open}
onCancel={handleClose}
footer={importResult ? (
<Button onClick={handleClose}></Button>
) : null}
destroyOnHidden
>
{importResult ? (
<div>
<Alert
type={importResult.error_count > 0 ? 'warning' : 'success'}
message={`导入完成:成功 ${importResult.success_count} 条,失败 ${importResult.error_count}`}
style={{ marginBottom: 16 }}
/>
{importResult.errors.length > 0 && (
<div>
<h4></h4>
{importResult.errors.map((err, i) => (
<Alert
key={i}
type="error"
message={`${err.row + 1}`}
description={err.errors.join('; ')}
style={{ marginBottom: 8 }}
/>
))}
</div>
)}
</div>
) : (
<Upload.Dragger
accept=".json"
maxCount={1}
beforeUpload={(file) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const text = e.target?.result as string;
const rows = JSON.parse(text);
if (!Array.isArray(rows)) {
message.error('文件格式错误:需要 JSON 数组');
return;
}
setImporting(true);
const result = await importPluginData(pluginId, entityName, rows);
setImportResult(result);
if (result.success_count > 0) onSuccess();
} catch {
message.error('文件解析失败,请确认格式为 JSON 数组');
}
setImporting(false);
};
reader.readAsText(file);
return false;
}}
showUploadList={false}
disabled={importing}
>
<p style={{ fontSize: 16, padding: '24px 0' }}>
{importing ? '导入中...' : '点击或拖拽 JSON 文件到此处'}
</p>
<p style={{ color: '#999' }}> JSON 1000 </p>
</Upload.Dragger>
)}
</Modal>
);
}

View File

@@ -0,0 +1,488 @@
import { useState, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
DatePicker,
Switch,
Select,
Tag,
message,
Popconfirm,
Segmented,
Timeline,
Dropdown,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined,
EyeOutlined,
DownloadOutlined,
UploadOutlined,
} from '@ant-design/icons';
import {
createPluginData,
updatePluginData,
deletePluginData,
batchPluginData,
exportPluginData,
exportPluginDataAsBlob,
} from '../../api/pluginData';
import EntitySelect from '../../components/EntitySelect';
import type { PluginFieldSchema } from '../../api/plugins';
import { evaluateVisibleWhen } from '../../utils/exprEvaluator';
import { usePluginData } from './usePluginData';
import DetailDrawer from './DetailDrawer';
import ImportModal from './ImportModal';
const { Search } = Input;
const { TextArea } = Input;
interface PluginCRUDPageProps {
pluginIdOverride?: string;
entityOverride?: string;
filterField?: string;
filterValue?: string;
enableViews?: string[];
compact?: boolean;
}
export default function PluginCRUDPageInner({
pluginIdOverride,
entityOverride,
filterField,
filterValue,
enableViews: enableViewsProp,
compact,
}: PluginCRUDPageProps = {}) {
const routeParams = useParams<{ pluginId: string; entityName: string }>();
const pluginId = pluginIdOverride || routeParams.pluginId || '';
const entityName = entityOverride || routeParams.entityName || '';
const {
records, total, page, loading, fields, displayName,
sortBy, sortOrder,
resolvedLabels, labelMeta,
entityDef, allEntities, detailSections,
hasDetailPage, filterableFields,
setPage, setSortBy, setSortOrder,
fetchData,
} = usePluginData(pluginId, entityName, filterField, filterValue);
const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm();
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
const [viewMode, setViewMode] = useState<string>('table');
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
const [importModalOpen, setImportModalOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const enableViews = enableViewsProp ||
(() => {
return ['table'];
})();
const handleSubmit = async (values: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
const { _id, _version, ...data } = values as Record<string, unknown> & {
_id?: string;
_version?: number;
};
try {
if (editRecord) {
await updatePluginData(
pluginId, entityName,
editRecord._id as string, data,
editRecord._version as number,
);
message.success('更新成功');
} else {
await createPluginData(pluginId, entityName, data);
message.success('创建成功');
}
setModalOpen(false);
setEditRecord(null);
fetchData();
} catch {
message.error('操作失败');
}
};
const handleDelete = async (record: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
try {
await deletePluginData(pluginId, entityName, record._id as string);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
const handleBatchDelete = async () => {
if (!pluginId || !entityName || selectedRowKeys.length === 0) return;
try {
await batchPluginData(pluginId, entityName, {
action: 'delete',
ids: selectedRowKeys,
});
message.success(`已删除 ${selectedRowKeys.length} 条记录`);
setSelectedRowKeys([]);
fetchData();
} catch {
message.error('批量删除失败');
}
};
const columns = useMemo(() => [
...fields.slice(0, 5).map((f) => ({
title: f.display_name || f.name,
dataIndex: f.name,
key: f.name,
ellipsis: true,
sorter: f.sortable ? true : undefined,
render: (val: unknown) => {
if (typeof val === 'boolean') return val ? <Tag color="green"></Tag> : <Tag></Tag>;
if (f.ref_entity) {
const uuid = String(val ?? '');
if (!uuid || uuid === '-') return '-';
const label = resolvedLabels[f.name]?.[uuid];
const installed = labelMeta[f.name]?.plugin_installed !== false;
if (!installed) return <Tag color="default">{f.ref_fallback_label || '外部引用'}</Tag>;
if (label === null) return <Tag color="warning"></Tag>;
if (label) return <Tag color="blue">{label}</Tag>;
}
return String(val ?? '-');
},
})),
{
title: '操作',
key: 'action',
width: hasDetailPage ? 200 : 150,
render: (_: unknown, record: Record<string, unknown>) => (
<Space size="small">
{hasDetailPage && (
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => { setDetailRecord(record); setDetailOpen(true); }}
>
</Button>
)}
<Button
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditRecord(record);
form.setFieldsValue(record);
setFormValues(record);
setModalOpen(true);
}}
>
</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
], [fields, resolvedLabels, labelMeta, hasDetailPage, handleDelete]);
const renderFormField = (field: PluginFieldSchema) => {
const widget = field.ui_widget || field.field_type;
switch (widget) {
case 'number':
case 'integer':
case 'float':
case 'decimal':
return <InputNumber style={{ width: '100%' }} />;
case 'boolean':
return <Switch />;
case 'date':
case 'datetime':
return <DatePicker showTime={widget === 'datetime'} style={{ width: '100%' }} />;
case 'select':
return (
<Select>
{(field.options || []).map((opt) => (
<Select.Option key={String(opt.value)} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
);
case 'textarea':
return <TextArea rows={3} />;
case 'entity_select':
return (
<EntitySelect
pluginId={pluginId}
entity={field.ref_entity!}
labelField={field.ref_label_field || 'name'}
searchFields={field.ref_search_fields}
refPlugin={field.ref_plugin}
fallbackLabel={field.ref_fallback_label}
value={formValues[field.name] as string | undefined}
onChange={(v) => form.setFieldValue(field.name, v)}
cascadeFrom={field.cascade_from}
cascadeFilter={field.cascade_filter}
cascadeValue={
field.cascade_from
? (formValues[field.cascade_from] as string | undefined)
: undefined
}
placeholder={field.display_name}
/>
);
default:
return <Input />;
}
};
return (
<div style={compact ? { padding: 0 } : { padding: 24 }}>
{!compact && (
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>{displayName}</h2>
<Space>
{enableViews.length > 1 && (
<Segmented
options={enableViews.map((v) => ({
label: v === 'table' ? '表格' : v === 'timeline' ? '时间线' : v,
value: v,
}))}
value={viewMode}
onChange={(val) => setViewMode(val as string)}
/>
)}
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
setEditRecord(null);
form.resetFields();
setFormValues({});
setModalOpen(true);
}}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}></Button>
{entityDef?.exportable && (
<Dropdown
menu={{
items: [
{ key: 'json', label: 'JSON' },
{ key: 'csv', label: 'CSV' },
{ key: 'xlsx', label: 'Excel (.xlsx)' },
],
onClick: async ({ key }) => {
setExporting(true);
try {
const ts = Date.now();
if (key === 'json') {
const rows = await exportPluginData(pluginId, entityName, {
sort_by: sortBy, sort_order: sortOrder,
});
const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${entityName}_export_${ts}.json`;
a.click();
URL.revokeObjectURL(url);
message.success(`导出 ${rows.length} 条记录`);
} else {
const blob = await exportPluginDataAsBlob(
pluginId, entityName, key as 'csv' | 'xlsx',
{ sort_by: sortBy, sort_order: sortOrder },
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${entityName}_export_${ts}.${key === 'csv' ? 'csv' : 'xlsx'}`;
a.click();
URL.revokeObjectURL(url);
message.success('导出成功');
}
} catch {
message.error('导出失败');
}
setExporting(false);
},
}}
>
<Button icon={<DownloadOutlined />} loading={exporting}></Button>
</Dropdown>
)}
{entityDef?.importable && (
<Button icon={<UploadOutlined />} onClick={() => setImportModalOpen(true)}>
</Button>
)}
</Space>
</div>
)}
{!compact && (
<Space style={{ marginBottom: 16 }} wrap>
{fields.some((f) => f.searchable) && (
<Search
placeholder="搜索..."
allowClear
style={{ width: 240 }}
onSearch={(value) => {
setPage(1);
fetchData(1, { search: value });
}}
/>
)}
{filterableFields.map((field) => (
<Select
key={field.name}
placeholder={field.display_name || field.name}
allowClear
style={{ width: 150 }}
options={field.options || []}
onChange={(value) => {
const newFilters: Record<string, string> = {};
if (value) newFilters[field.name] = value;
setPage(1);
fetchData(1);
}}
/>
))}
</Space>
)}
{selectedRowKeys.length > 0 && !compact && (
<div style={{
marginBottom: 16, padding: '8px 16px',
background: 'var(--colorBgContainer, #fff)', borderRadius: 8,
display: 'flex', alignItems: 'center', gap: 12,
}}>
<span> <strong>{selectedRowKeys.length}</strong> </span>
<Popconfirm title={`确定删除选中的 ${selectedRowKeys.length} 条记录?`} onConfirm={handleBatchDelete}>
<Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
<Button onClick={() => setSelectedRowKeys([])}></Button>
</div>
)}
{viewMode === 'table' || enableViews.length <= 1 ? (
<Table
columns={columns}
dataSource={records}
rowKey="_id"
loading={loading}
size={compact ? 'small' : undefined}
rowSelection={compact ? undefined : {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
onChange={(_pagination, _filters, sorter) => {
if (!Array.isArray(sorter) && sorter.field) {
const newSortBy = String(sorter.field);
const newSortOrder = sorter.order === 'ascend' ? 'asc' as const : 'desc' as const;
setSortBy(newSortBy);
setSortOrder(newSortOrder);
setPage(1);
fetchData(1, { sort_by: newSortBy, sort_order: newSortOrder });
} else if (!sorter || (Array.isArray(sorter) && sorter.length === 0)) {
setSortBy(undefined);
setSortOrder('desc');
setPage(1);
fetchData(1, { sort_by: undefined, sort_order: undefined });
}
}}
pagination={compact
? { pageSize: 5, showTotal: (t) => `${t}` }
: { current: page, total, pageSize: 20, onChange: (p) => setPage(p), showTotal: (t) => `${t}` }
}
/>
) : viewMode === 'timeline' ? (
<Timeline
items={records.map((record) => {
const dateField = fields.find((f) => f.field_type === 'DateTime' || f.field_type === 'date');
const titleField = fields.find((f) => f.searchable)?.name || fields[1]?.name;
const contentField = fields.find((f) => f.ui_widget === 'textarea')?.name;
return {
children: (
<div>
{titleField && <p><strong>{String(record[titleField] ?? '-')}</strong></p>}
{contentField && <p>{String(record[contentField] ?? '-')}</p>}
{dateField && <p style={{ color: '#999', fontSize: 12 }}>{String(record[dateField.name] ?? '-')}</p>}
</div>
),
};
})}
/>
) : null}
<Modal
title={editRecord ? '编辑' : '新增'}
open={modalOpen}
onCancel={() => { setModalOpen(false); setEditRecord(null); setFormValues({}); }}
onOk={() => form.submit()}
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={(_, allValues) => setFormValues(allValues)}>
{fields.map((field) => {
const visible = evaluateVisibleWhen(field.visible_when, formValues);
if (!visible) return null;
return (
<Form.Item
key={field.name}
name={field.name}
label={field.display_name || field.name}
rules={[
...(field.required ? [{ required: true, message: `请输入${field.display_name || field.name}` }] : []),
...(field.validation?.pattern ? [{ pattern: new RegExp(field.validation.pattern), message: field.validation.message || '格式不正确' }] : []),
]}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
>
{renderFormField(field)}
</Form.Item>
);
})}
</Form>
</Modal>
<DetailDrawer
open={detailOpen}
record={detailRecord}
displayName={displayName}
fields={fields}
sections={detailSections}
allEntities={allEntities}
pluginId={pluginId}
entityName={entityName}
onClose={() => { setDetailOpen(false); setDetailRecord(null); }}
/>
<ImportModal
open={importModalOpen}
pluginId={pluginId}
entityName={entityName}
onClose={() => setImportModalOpen(false)}
onSuccess={() => fetchData()}
/>
</div>
);
}

View File

@@ -0,0 +1,207 @@
import { useEffect, useState, useCallback } from 'react';
import { message } from 'antd';
import {
listPluginData,
resolveRefLabels,
type PluginDataListOptions,
} from '../../api/pluginData';
import {
getPluginSchema,
type PluginFieldSchema,
type PluginEntitySchema,
type PluginPageSchema,
type PluginSectionSchema,
} from '../../api/plugins';
export interface PluginDataState {
records: Record<string, unknown>[];
total: number;
page: number;
loading: boolean;
fields: PluginFieldSchema[];
displayName: string;
filters: Record<string, string>;
searchText: string;
sortBy: string | undefined;
sortOrder: 'asc' | 'desc';
resolvedLabels: Record<string, Record<string, string | null>>;
labelMeta: Record<string, { plugin_installed: boolean }>;
entityDef: PluginEntitySchema | null;
allEntities: PluginEntitySchema[];
allPages: PluginPageSchema[];
detailSections: PluginSectionSchema[];
hasDetailPage: boolean;
filterableFields: PluginFieldSchema[];
}
export interface PluginDataActions {
setRecords: React.Dispatch<React.SetStateAction<Record<string, unknown>[]>>;
setPage: React.Dispatch<React.SetStateAction<number>>;
setFilters: React.Dispatch<React.SetStateAction<Record<string, string>>>;
setSearchText: React.Dispatch<React.SetStateAction<string>>;
setSortBy: React.Dispatch<React.SetStateAction<string | undefined>>;
setSortOrder: React.Dispatch<React.SetStateAction<'asc' | 'desc'>>;
fetchData: (p?: number, overrides?: {
search?: string;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}) => Promise<void>;
handleFilterChange: (fieldName: string, value: string | undefined) => void;
}
export type PluginDataHook = PluginDataState & PluginDataActions;
export function usePluginData(
pluginId: string,
entityName: string,
filterField?: string,
filterValue?: string,
): PluginDataHook {
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
const [displayName, setDisplayName] = useState(entityName || '');
const [filters, setFilters] = useState<Record<string, string>>({});
const [searchText, setSearchText] = useState('');
const [sortBy, setSortBy] = useState<string | undefined>();
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [resolvedLabels, setResolvedLabels] = useState<Record<string, Record<string, string | null>>>({});
const [labelMeta, setLabelMeta] = useState<Record<string, { plugin_installed: boolean }>>({});
const [entityDef, setEntityDef] = useState<PluginEntitySchema | null>(null);
const [allEntities, setAllEntities] = useState<PluginEntitySchema[]>([]);
const [allPages, setAllPages] = useState<PluginPageSchema[]>([]);
const [detailSections, setDetailSections] = useState<PluginSectionSchema[]>([]);
const filterableFields = fields.filter((f) => f.filterable);
const hasDetailPage = allPages.some(
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
);
// 加载 schema
useEffect(() => {
if (!pluginId) return;
const abortController = new AbortController();
async function loadSchema() {
try {
const schema = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const entities: PluginEntitySchema[] = (schema as { entities?: PluginEntitySchema[] }).entities || [];
setAllEntities(entities);
const entity = entities.find((e) => e.name === entityName);
if (entity) {
setFields(entity.fields);
setDisplayName(entity.display_name || entityName || '');
setEntityDef(entity);
}
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
if (ui?.pages) {
setAllPages(ui.pages);
const detailPage = ui.pages.find(
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
);
if (detailPage && 'sections' in detailPage) {
setDetailSections(detailPage.sections);
}
}
} catch {
message.warning('Schema 加载失败,部分功能不可用');
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId, entityName]);
const fetchData = useCallback(
async (
p = page,
overrides?: { search?: string; sort_by?: string; sort_order?: 'asc' | 'desc' },
) => {
if (!pluginId || !entityName) return;
setLoading(true);
try {
const options: PluginDataListOptions = {};
const mergedFilters = { ...filters };
if (filterField && filterValue) {
mergedFilters[filterField] = filterValue;
}
if (Object.keys(mergedFilters).length > 0) {
options.filter = mergedFilters;
}
const effectiveSearch = overrides?.search ?? searchText;
if (effectiveSearch) options.search = effectiveSearch;
const effectiveSortBy = overrides?.sort_by ?? sortBy;
const effectiveSortOrder = overrides?.sort_order ?? sortOrder;
if (effectiveSortBy) {
options.sort_by = effectiveSortBy;
options.sort_order = effectiveSortOrder;
}
const result = await listPluginData(pluginId, entityName, p, 20, options);
setRecords(
result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })),
);
setTotal(result.total);
} catch {
message.error('加载数据失败');
}
setLoading(false);
},
[pluginId, entityName, page, filters, searchText, sortBy, sortOrder, filterField, filterValue],
);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchData();
}, [fetchData]);
// 数据加载后解析跨插件引用标签
useEffect(() => {
if (!pluginId || !entityName || !records.length || !fields.length) return;
const refFields = fields.filter((f) => f.ref_entity);
if (!refFields.length) return;
const fieldUuids: Record<string, string[]> = {};
for (const f of refFields) {
const uuids = [...new Set(
records.map((r) => r[f.name]).filter(Boolean).map(String),
)];
if (uuids.length) fieldUuids[f.name] = uuids;
}
if (!Object.keys(fieldUuids).length) return;
resolveRefLabels(pluginId, entityName, fieldUuids)
.then((result) => {
setResolvedLabels(result.labels);
setLabelMeta(result.meta as Record<string, { plugin_installed: boolean }>);
})
.catch((err) => console.warn('[usePluginData] 获取标签元数据失败:', err));
}, [records, fields, pluginId, entityName]);
const handleFilterChange = (fieldName: string, value: string | undefined) => {
const newFilters = { ...filters };
if (value) {
newFilters[fieldName] = value;
} else {
delete newFilters[fieldName];
}
setFilters(newFilters);
setPage(1);
fetchData(1);
};
return {
records, total, page, loading, fields, displayName,
filters, searchText, sortBy, sortOrder,
resolvedLabels, labelMeta,
entityDef, allEntities, allPages, detailSections,
hasDetailPage, filterableFields,
setRecords, setPage, setFilters, setSearchText, setSortBy, setSortOrder,
fetchData, handleFilterChange,
};
}

View File

@@ -0,0 +1,455 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Row, Col, Empty, Select } from 'antd';
import { DashboardOutlined } from '@ant-design/icons';
import { countPluginData, aggregatePluginData, listPluginData } from '../api/pluginData';
import {
getPluginSchema,
type PluginEntitySchema,
type PluginSchemaResponse,
type PluginPageSchema,
type DashboardWidget,
} from '../api/plugins';
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboard/dashboardTypes';
import { getEntityPalette, getEntityIcon, getDelayClass } from './dashboard/dashboardConstants';
import {
StatCard,
SkeletonStatCard,
BreakdownCard,
SkeletonBreakdownCard,
WidgetRenderer,
} from './dashboard/DashboardWidgets';
import { useThemeMode } from '../hooks/useThemeMode';
// ── 主组件 ──
export function PluginDashboardPage() {
const { pluginId } = useParams<{ pluginId: string }>();
const [loading, setLoading] = useState(false);
const [schemaLoading, setSchemaLoading] = useState(false);
const [entities, setEntities] = useState<PluginEntitySchema[]>([]);
const [selectedEntity, setSelectedEntity] = useState<string>('');
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
const [error, setError] = useState<string | null>(null);
// Widget-based dashboard state
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
const [widgetData, setWidgetData] = useState<WidgetData[]>([]);
const [widgetsLoading, setWidgetsLoading] = useState(false);
const isDark = useThemeMode();
// 加载 schema
useEffect(() => {
if (!pluginId) return;
const abortController = new AbortController();
async function loadSchema() {
setSchemaLoading(true);
setError(null);
try {
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const entityList = schema.entities || [];
setEntities(entityList);
if (entityList.length > 0) {
setSelectedEntity(entityList[0].name);
}
// 提取 dashboard widgets
const pages = schema.ui?.pages || [];
const dashboardPage = pages.find(
(p): p is PluginPageSchema & { type: 'dashboard'; widgets?: DashboardWidget[] } =>
p.type === 'dashboard',
);
if (dashboardPage?.widgets && dashboardPage.widgets.length > 0) {
setWidgets(dashboardPage.widgets);
}
} catch {
setError('Schema 加载失败,部分功能不可用');
} finally {
if (!abortController.signal.aborted) setSchemaLoading(false);
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId]);
const currentEntity = useMemo(
() => entities.find((e) => e.name === selectedEntity),
[entities, selectedEntity],
);
const filterableFields = useMemo(
() => currentEntity?.fields.filter((f) => f.filterable) || [],
[currentEntity],
);
// 加载所有实体的计数
useEffect(() => {
if (!pluginId || entities.length === 0) return;
const abortController = new AbortController();
async function loadAllCounts() {
const results: EntityStat[] = [];
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (abortController.signal.aborted) return;
const palette = getEntityPalette(entity.name, i);
const icon = getEntityIcon(entity.name);
try {
const count = await countPluginData(pluginId!, entity.name);
if (abortController.signal.aborted) return;
results.push({
name: entity.name,
displayName: entity.display_name || entity.name,
count,
icon,
gradient: palette.gradient,
iconBg: palette.iconBg,
});
} catch {
results.push({
name: entity.name,
displayName: entity.display_name || entity.name,
count: 0,
icon,
gradient: palette.gradient,
iconBg: palette.iconBg,
});
}
}
if (!abortController.signal.aborted) {
setEntityStats(results);
}
}
loadAllCounts();
return () => abortController.abort();
}, [pluginId, entities]);
// Widget 数据并行加载
useEffect(() => {
if (!pluginId || widgets.length === 0) return;
const abortController = new AbortController();
async function loadWidgetData() {
setWidgetsLoading(true);
try {
const results = await Promise.all(
widgets.map(async (widget) => {
try {
// 旧类型
if (widget.type === 'stat_card') {
const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count };
}
// stat_cards — 多个统计卡片
if (widget.type === 'stat_cards' && widget.cards) {
const cardResults = await Promise.all(
widget.cards.map(async (card) => {
try {
const count = await countPluginData(pluginId!, card.entity, {
filter: card.filter ? JSON.parse(card.filter) : undefined,
});
return { card, value: count };
} catch {
return { card, value: 0 };
}
}),
);
return { widget, data: [], statCards: cardResults };
}
// action_list — 待办列表
if (widget.type === 'action_list' && widget.queries) {
const actionResults = await Promise.all(
widget.queries.map(async (query) => {
try {
const filterObj = query.filter ? JSON.parse(query.filter) : undefined;
const sortParts = query.sort?.split(' ') ?? [];
const result = await listPluginData(pluginId!, query.entity, 1, widget.max_items ?? 10, {
filter: filterObj,
sort_by: sortParts[0] || undefined,
sort_order: (sortParts[1] as 'asc' | 'desc') || undefined,
});
return { query, records: result.data };
} catch {
return { query, records: [] };
}
}),
);
return { widget, data: [], actionItems: actionResults };
}
// funnel — 阶段漏斗
if (widget.type === 'funnel' && widget.lane_field) {
const data = await aggregatePluginData(
pluginId!,
widget.entity,
widget.lane_field,
);
return { widget, data };
}
// card_list — 卡片列表
if (widget.type === 'card_list') {
const filterObj = widget.filter ? JSON.parse(widget.filter) : undefined;
const result = await listPluginData(pluginId!, widget.entity, 1, widget.max_items ?? 10, {
filter: filterObj,
});
return { widget, data: [], records: result.data };
}
// 旧类型图表
if (widget.dimension_field) {
const data = await aggregatePluginData(
pluginId!,
widget.entity,
widget.dimension_field,
);
return { widget, data };
}
// fallback — 仅返回计数
const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count };
} catch {
return { widget, data: [], count: 0 };
}
}),
);
if (!abortController.signal.aborted) {
setWidgetData(results);
}
} finally {
if (!abortController.signal.aborted) setWidgetsLoading(false);
}
}
loadWidgetData();
return () => abortController.abort();
}, [pluginId, widgets]);
// 当前实体的聚合数据
const loadData = useCallback(async () => {
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
const abortController = new AbortController();
setLoading(true);
setError(null);
try {
const fieldResults: FieldBreakdown[] = [];
for (const field of filterableFields) {
if (abortController.signal.aborted) return;
try {
const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name);
if (abortController.signal.aborted) return;
fieldResults.push({
fieldName: field.name,
displayName: field.display_name || field.name,
items,
});
} catch {
// 单个字段聚合失败不影响其他字段
}
}
if (!abortController.signal.aborted) setBreakdowns(fieldResults);
} catch {
setError('统计数据加载失败');
} finally {
if (!abortController.signal.aborted) setLoading(false);
}
return () => abortController.abort();
}, [pluginId, selectedEntity, filterableFields, entityStats]);
useEffect(() => {
const cleanup = loadData();
return () => { cleanup?.then((fn) => fn?.()).catch((err) => console.warn('[PluginDashboard] 清理失败:', err)); };
}, [loadData]);
// 当前选中实体的总数
const currentTotal = useMemo(
() => entityStats.find((s) => s.name === selectedEntity)?.count ?? 0,
[entityStats, selectedEntity],
);
// 当前实体的色板
const currentPalette = useMemo(
() => getEntityPalette(selectedEntity, entities.findIndex((e) => e.name === selectedEntity)),
[selectedEntity, entities],
);
// ── 渲染 ──
if (schemaLoading) {
return (
<div style={{ padding: 24 }}>
<Row gutter={[16, 16]}>
{Array.from({ length: 5 }).map((_, i) => (
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
))}
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonBreakdownCard key={i} index={i} />
))}
</Row>
</div>
);
}
return (
<div style={{ padding: 24 }}>
{/* 页面标题 */}
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<h2
style={{
fontSize: 24,
fontWeight: 700,
color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
margin: '0 0 4px',
letterSpacing: '-0.5px',
}}
>
</h2>
<p
style={{
fontSize: 14,
color: isDark ? '#94a3b8' : '#475569',
margin: 0,
}}
>
{pluginId ? `${pluginId.toUpperCase()} 数据统计` : '数据统计'}
</p>
</div>
<Select
value={selectedEntity || undefined}
style={{ width: 160 }}
options={entities.map((e) => ({
label: e.display_name || e.name,
value: e.name,
}))}
onChange={setSelectedEntity}
aria-label="选择实体类型"
/>
</div>
</div>
{/* 顶部统计卡片 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{loading && entityStats.length === 0
? Array.from({ length: 5 }).map((_, i) => (
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
))
: entityStats.map((stat, i) => (
<StatCard
key={stat.name}
stat={stat}
loading={loading}
delay={getDelayClass(i)}
/>
))}
</Row>
{/* Widget 图表区域 */}
{widgets.length > 0 && (
<>
<div style={{ marginBottom: 16 }}>
<div className="erp-section-header">
<DashboardOutlined
className="erp-section-icon"
style={{ color: '#2563eb' }}
/>
<span className="erp-section-title"></span>
</div>
</div>
{widgetsLoading && widgetData.length === 0 ? (
<Row gutter={[16, 16]}>
{widgets.map((_, i) => (
<SkeletonBreakdownCard key={i} index={i} />
))}
</Row>
) : (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{widgetData.map((wd) => {
const colSpan = wd.widget.type === 'stat_card' ? 6
: wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12
: wd.widget.type === 'stat_cards' ? 24
: wd.widget.type === 'action_list' ? 12
: wd.widget.type === 'funnel' ? 12
: wd.widget.type === 'card_list' ? 12
: 12;
return (
<Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}>
<WidgetRenderer widgetData={wd} isDark={isDark} />
</Col>
);
})}
</Row>
)}
</>
)}
{/* 分组统计区域 */}
<div style={{ marginBottom: 16 }}>
<div className="erp-section-header">
<DashboardOutlined
className="erp-section-icon"
style={{ color: currentPalette.tagColor === 'purple' ? '#2563eb' : '#3B82F6' }}
/>
<span className="erp-section-title">
{currentEntity?.display_name || selectedEntity}
</span>
<span
style={{
marginLeft: 'auto',
fontSize: 12,
color: 'var(--erp-text-tertiary)',
}}
>
{currentTotal.toLocaleString()}
</span>
</div>
</div>
{loading && breakdowns.length === 0 ? (
<Row gutter={[16, 16]}>
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonBreakdownCard key={i} index={i} />
))}
</Row>
) : breakdowns.length > 0 ? (
<Row gutter={[16, 16]}>
{breakdowns.map((bd, i) => (
<BreakdownCard
key={bd.fieldName}
breakdown={bd}
totalCount={currentTotal}
index={i}
/>
))}
</Row>
) : (
<div className="erp-content-card" style={{ textAlign: 'center', padding: '48px 24px' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
filterableFields.length === 0
? '当前实体无可筛选项,暂无分布数据'
: '暂无数据'
}
/>
</div>
)}
{/* 错误提示 */}
{error && (
<div
style={{
marginTop: 16,
padding: '12px 16px',
borderRadius: 8,
background: isDark ? 'rgba(220, 38, 38, 0.1)' : '#FEF2F2',
color: isDark ? '#FCA5A5' : '#991B1B',
fontSize: 13,
}}
role="alert"
>
{error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,284 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import {
Card,
Select,
Space,
Empty,
Spin,
Statistic,
Row,
Col,
Tag,
Tooltip,
theme,
Typography,
Divider,
Badge,
Flex,
} from 'antd';
import {
ApartmentOutlined,
TeamOutlined,
NodeIndexOutlined,
AimOutlined,
InfoCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { getNodeDegree } from './graph/graphRenderer';
import { getRelColor, getEdgeTypeLabel } from './graph/graphRenderer';
import { useGraphData } from './PluginGraphPage/useGraphData';
import { useGraphCanvas } from './PluginGraphPage/useGraphCanvas';
const { Text } = Typography;
export function PluginGraphPage() {
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
const { token } = theme.useToken();
const { customers, relationships, loading, fields, relTypes } = useGraphData(pluginId, entityName);
const [selectedCenter, setSelectedCenter] = useState<string | null>(null);
const [relFilter, setRelFilter] = useState<string | undefined>();
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 });
const filteredRels = relFilter
? relationships.filter((r) => r.label === relFilter)
: relationships;
const visibleEdges = selectedCenter
? filteredRels.filter((r) => r.source === selectedCenter || r.target === selectedCenter)
: filteredRels;
const visibleNodeIds = new Set<string>();
if (selectedCenter) {
visibleNodeIds.add(selectedCenter);
for (const e of visibleEdges) { visibleNodeIds.add(e.source); visibleNodeIds.add(e.target); }
}
const visibleNodes = selectedCenter
? customers.filter((n) => visibleNodeIds.has(n.id))
: customers;
const centerNode = customers.find((c) => c.id === selectedCenter);
const centerDegree = selectedCenter ? getNodeDegree(selectedCenter, visibleEdges) : 0;
const {
canvasRef, containerRef, hoverState,
handleCanvasMouseMove, handleCanvasMouseLeave, handleCanvasClick,
} = useGraphCanvas({
token,
canvasSize,
selectedCenter,
visibleNodes,
visibleEdges,
});
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect;
if (width > 0) setCanvasSize({ width, height: Math.max(500, Math.min(700, width * 0.65)) });
}
});
observer.observe(container);
return () => observer.disconnect();
}, []);
const onCanvasClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const result = handleCanvasClick(e);
if (result?.clicked) {
setSelectedCenter((prev) => (prev === result.clicked ? null : result.clicked));
}
},
[handleCanvasClick],
);
const legendItems = relTypes.map((type) => ({
label: getEdgeTypeLabel(type),
rawLabel: type,
color: getRelColor(type).base,
count: relationships.filter((r) => r.label === type).length,
}));
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin size="large" description="加载图谱数据中..." />
</div>
);
}
return (
<div style={{ padding: 24 }}>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Card size="small" style={{ borderLeft: `3px solid ${token.colorPrimary}` }}>
<Statistic
title={<Text type="secondary" style={{ fontSize: 12 }}><TeamOutlined style={{ marginRight: 4 }} /></Text>}
value={customers.length}
styles={{ content: { color: token.colorPrimary, fontWeight: 600 } }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small" style={{ borderLeft: `3px solid ${token.colorSuccess}` }}>
<Statistic
title={<Text type="secondary" style={{ fontSize: 12 }}><NodeIndexOutlined style={{ marginRight: 4 }} /></Text>}
value={relationships.length}
styles={{ content: { color: token.colorSuccess, fontWeight: 600 } }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small" style={{ borderLeft: `3px solid ${token.colorWarning}` }}>
<Statistic
title={<Text type="secondary" style={{ fontSize: 12 }}><AimOutlined style={{ marginRight: 4 }} /></Text>}
value={centerNode?.label || '未选择'}
styles={{ content: { fontSize: 20, color: centerNode ? token.colorWarning : token.colorTextDisabled, fontWeight: 600 } }}
/>
{selectedCenter && <Text type="secondary" style={{ fontSize: 11 }}>{centerDegree} </Text>}
</Card>
</Col>
</Row>
<Card
title={
<Space>
<ApartmentOutlined />
<span></span>
{relFilter && <Tag color="blue" closable onClose={() => setRelFilter(undefined)}>{getEdgeTypeLabel(relFilter)}</Tag>}
</Space>
}
size="small"
extra={
<Space wrap>
<Select
placeholder="筛选关系类型"
allowClear
style={{ width: 150 }}
value={relFilter}
options={relTypes.map((t) => ({
label: (
<Space>
<span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', backgroundColor: getRelColor(t).base }} />
{getEdgeTypeLabel(t)}
<Text type="secondary" style={{ fontSize: 11 }}>({relationships.filter((r) => r.label === t).length})</Text>
</Space>
),
value: t,
}))}
onChange={(v) => setRelFilter(v)}
/>
<Select
placeholder="选择中心客户"
allowClear
showSearch
style={{ width: 200 }}
optionFilterProp="label"
value={selectedCenter || undefined}
options={customers.map((c) => ({ label: c.label, value: c.id }))}
onChange={(v) => setSelectedCenter(v || null)}
/>
</Space>
}
>
{customers.length === 0 ? (
<Empty description="暂无客户数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<div ref={containerRef} style={{ position: 'relative' }}>
<canvas
ref={canvasRef}
onMouseMove={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
onClick={onCanvasClick}
style={{ width: '100%', height: canvasSize.height, borderRadius: 8, border: `1px solid ${token.colorBorderSecondary}`, display: 'block' }}
/>
{legendItems.length > 0 && (
<div style={{
position: 'absolute', bottom: 12, left: 12,
background: token.colorBgElevated, border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8, padding: '8px 12px', boxShadow: token.boxShadowSecondary, maxWidth: 220,
}}>
<Text strong style={{ fontSize: 11, color: token.colorTextSecondary, display: 'block', marginBottom: 4 }}></Text>
<Flex wrap="wrap" gap={6}>
{legendItems.map((item) => (
<Tag
key={item.rawLabel}
color={item.color}
style={{
margin: 0, fontSize: 11,
cursor: relFilter === item.rawLabel ? 'default' : 'pointer',
opacity: relFilter && relFilter !== item.rawLabel ? 0.4 : 1,
}}
onClick={() => setRelFilter((prev) => prev === item.rawLabel ? undefined : item.rawLabel)}
>
{item.label} ({item.count})
</Tag>
))}
</Flex>
</div>
)}
{hoverState.nodeId && (
<div style={{
position: 'absolute', top: 12, right: 12,
background: token.colorBgElevated, border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8, padding: '8px 12px', boxShadow: token.boxShadowSecondary, maxWidth: 280,
transition: 'opacity 0.15s ease',
}}>
<Space direction="vertical" size={4}>
<Text strong>{visibleNodes.find((n) => n.id === hoverState.nodeId)?.label}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
<InfoCircleOutlined style={{ marginRight: 4 }} /> /
</Text>
</Space>
</div>
)}
</div>
)}
</Card>
{selectedCenter && centerNode && (
<Card
size="small"
style={{ marginTop: 16 }}
title={
<Space>
<Badge color={token.colorPrimary} />
<Text strong>{centerNode.label}</Text>
<Text type="secondary"> </Text>
</Space>
}
extra={
<Tooltip title="取消选中">
<Text type="secondary" style={{ cursor: 'pointer', fontSize: 12 }} onClick={() => setSelectedCenter(null)}>
<ReloadOutlined style={{ marginRight: 4 }} />
</Text>
</Tooltip>
}
>
<Row gutter={[16, 12]}>
{Object.entries(centerNode.data).map(([key, value]) => {
if (value == null || value === '') return null;
const fieldSchema = fields.find((f) => f.name === key);
return (
<Col xs={12} sm={8} md={6} key={key}>
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>{fieldSchema?.display_name || key}</Text>
<Text style={{ fontSize: 13 }}>{String(value)}</Text>
</Col>
);
})}
</Row>
<Divider style={{ margin: '12px 0 8px' }} />
<Text type="secondary" style={{ fontSize: 12 }}>
: {centerDegree} {visibleNodes.length} {visibleEdges.length}
</Text>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,249 @@
import { useRef, useState, useCallback, useEffect } from 'react';
import type { GlobalToken } from 'antd/es/theme/interface';
import type { GraphNode, GraphEdge, NodePosition, HoverState } from '../graph/graphTypes';
import { computeCircularLayout } from '../graph/graphLayout';
import {
getEdgeColor,
NODE_HOVER_SCALE,
getRelColor,
getNodeDegree,
degreeToRadius,
drawCurvedEdge,
drawNode,
drawEdgeLabel,
drawNodeLabel,
} from '../graph/graphRenderer';
interface UseGraphCanvasParams {
token: GlobalToken;
canvasSize: { width: number; height: number };
selectedCenter: string | null;
visibleNodes: GraphNode[];
visibleEdges: GraphEdge[];
}
export function useGraphCanvas({
token,
canvasSize,
selectedCenter,
visibleNodes,
visibleEdges,
}: UseGraphCanvasParams) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const nodePositionsRef = useRef<Map<string, NodePosition>>(new Map());
const visibleNodesRef = useRef<GraphNode[]>(visibleNodes);
const visibleEdgesRef = useRef<GraphEdge[]>(visibleEdges);
const [hoverState, setHoverState] = useState<HoverState>({ nodeId: null, x: 0, y: 0 });
useEffect(() => {
visibleNodesRef.current = visibleNodes;
visibleEdgesRef.current = visibleEdges;
}, [visibleNodes, visibleEdges]);
const drawGraph = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const nodes = visibleNodesRef.current;
const edges = visibleEdgesRef.current;
const width = canvasSize.width;
const height = canvasSize.height;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
const textColor = token.colorText;
const bgColor = token.colorBgContainer;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
if (nodes.length === 0) return;
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) * 0.36;
const positions = computeCircularLayout(nodes, centerX, centerY, radius);
nodePositionsRef.current = positions;
const degreeMap = new Map<string, number>();
for (const node of nodes) {
degreeMap.set(node.id, getNodeDegree(node.id, edges));
}
for (const edge of edges) {
const from = positions.get(edge.source);
const to = positions.get(edge.target);
if (!from || !to) continue;
const colors = getRelColor(edge.label);
const isHighlighted = hoverState.nodeId === edge.source || hoverState.nodeId === edge.target;
const alpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.15) : 0.7;
const lw = isHighlighted ? 2.5 : 1.5;
const labelPos = drawCurvedEdge(ctx, from.x, from.y, to.x, to.y, colors.base, lw, isHighlighted, alpha);
if (edge.label && labelPos) {
const labelAlpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.1) : 0.9;
drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, labelAlpha);
}
}
for (const node of nodes) {
const pos = positions.get(node.id);
if (!pos) continue;
const isCenter = node.id === selectedCenter;
const isHovered = node.id === hoverState.nodeId;
const degree = degreeMap.get(node.id) || 0;
const r = degreeToRadius(degree, isCenter);
let nodeColorBase = '#2563eb';
let nodeColorLight = '#60a5fa';
let nodeColorGlow = 'rgba(79,70,229,0.3)';
if (isCenter) {
const firstEdge = edges.find((e) => e.source === node.id || e.target === node.id);
if (firstEdge) {
const rc = getRelColor(firstEdge.label);
nodeColorBase = rc.base;
nodeColorLight = rc.light;
nodeColorGlow = rc.glow;
}
} else {
const idx = nodes.indexOf(node);
const pick = getEdgeColor(`_node_${idx}`);
nodeColorBase = pick.base;
nodeColorLight = pick.light;
nodeColorGlow = pick.glow;
}
const nodeAlpha = hoverState.nodeId
? (isHovered || edges.some(
(e) => (e.source === hoverState.nodeId && e.target === node.id) ||
(e.target === hoverState.nodeId && e.source === node.id),
) ? 1 : 0.2)
: 1;
drawNode(ctx, pos.x, pos.y, r, nodeColorBase, nodeColorLight, nodeColorGlow, isCenter, isHovered, nodeAlpha);
drawNodeLabel(ctx, pos.x, pos.y, r, node.label, textColor, isCenter, isHovered);
}
if (hoverState.nodeId) {
const hoveredNode = nodes.find((n) => n.id === hoverState.nodeId);
if (hoveredNode) {
const degree = degreeMap.get(hoveredNode.id) || 0;
const tooltipText = `${hoveredNode.label} (${degree} 条关系)`;
ctx.save();
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
const metrics = ctx.measureText(tooltipText);
const tw = metrics.width + 16;
const th = 28;
const tx = hoverState.x - tw / 2;
const ty = hoverState.y - 40;
ctx.fillStyle = token.colorBgElevated;
ctx.shadowColor = 'rgba(0,0,0,0.15)';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.roundRect(tx, ty, tw, th, 6);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = token.colorText;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(tooltipText, hoverState.x, ty + th / 2);
ctx.restore();
}
}
}, [canvasSize, selectedCenter, hoverState, token]);
useEffect(() => { drawGraph(); }, [drawGraph]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => { drawGraph(); });
observer.observe(container);
return () => observer.disconnect();
}, [drawGraph]);
const handleCanvasMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const positions = nodePositionsRef.current;
const nodes = visibleNodesRef.current;
const edges = visibleEdgesRef.current;
let foundId: string | null = null;
for (const node of nodes) {
const pos = positions.get(node.id);
if (!pos) continue;
const degree = getNodeDegree(node.id, edges);
const r = degreeToRadius(degree, node.id === selectedCenter) * NODE_HOVER_SCALE;
const dx = x - pos.x;
const dy = y - pos.y;
if (dx * dx + dy * dy < r * r) { foundId = node.id; break; }
}
canvas.style.cursor = foundId ? 'pointer' : 'default';
setHoverState((prev) =>
prev.nodeId === foundId ? prev : { nodeId: foundId, x, y },
);
},
[selectedCenter],
);
const handleCanvasMouseLeave = useCallback(() => {
setHoverState({ nodeId: null, x: 0, y: 0 });
}, []);
const handleCanvasClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const positions = nodePositionsRef.current;
const nodes = visibleNodesRef.current;
const edges = visibleEdgesRef.current;
for (const node of nodes) {
const pos = positions.get(node.id);
if (!pos) continue;
const degree = getNodeDegree(node.id, edges);
const r = degreeToRadius(degree, node.id === selectedCenter);
const dx = x - pos.x;
const dy = y - pos.y;
if (dx * dx + dy * dy < r * r) {
return { clicked: node.id };
}
}
return { clicked: null };
},
[selectedCenter],
);
return {
canvasRef,
containerRef,
hoverState,
drawGraph,
handleCanvasMouseMove,
handleCanvasMouseLeave,
handleCanvasClick,
};
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { message } from 'antd';
import { listPluginData } from '../../api/pluginData';
import {
getPluginSchema,
type PluginFieldSchema,
type PluginSchemaResponse,
} from '../../api/plugins';
import type { GraphNode, GraphEdge, GraphConfig } from '../graph/graphTypes';
export function useGraphData(pluginId?: string, entityName?: string) {
const [customers, setCustomers] = useState<GraphNode[]>([]);
const [relationships, setRelationships] = useState<GraphEdge[]>([]);
const [loading, setLoading] = useState(false);
const [graphConfig, setGraphConfig] = useState<GraphConfig | null>(null);
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
const [relTypes, setRelTypes] = useState<string[]>([]);
useEffect(() => {
if (!pluginId || !entityName) return;
const abortController = new AbortController();
async function loadSchema() {
try {
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const pages = schema.ui?.pages || [];
const graphPage = pages.find(
(p): p is typeof p & GraphConfig & { type: 'graph' } =>
p.type === 'graph' && p.entity === entityName,
);
if (graphPage) {
setGraphConfig({
entity: graphPage.entity,
relationshipEntity: graphPage.relationship_entity,
sourceField: graphPage.source_field,
targetField: graphPage.target_field,
edgeLabelField: graphPage.edge_label_field,
nodeLabelField: graphPage.node_label_field,
});
}
const entity = schema.entities?.find((e) => e.name === entityName);
if (entity) setFields(entity.fields);
} catch {
message.warning('Schema 加载失败,部分功能不可用');
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId, entityName]);
useEffect(() => {
if (!pluginId || !graphConfig) return;
const abortController = new AbortController();
const gc = graphConfig;
const labelField = fields.find((f) => f.name === gc.nodeLabelField)?.name || fields[1]?.name || 'name';
async function loadData() {
setLoading(true);
try {
let allCustomers: GraphNode[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
if (abortController.signal.aborted) return;
const result = await listPluginData(pluginId!, gc.entity, page, 100);
allCustomers = [
...allCustomers,
...result.data.map((r) => ({
id: r.id,
label: String(r.data[labelField] || '未命名'),
data: r.data,
})),
];
hasMore = result.data.length === 100 && allCustomers.length < result.total;
page++;
}
if (abortController.signal.aborted) return;
setCustomers(allCustomers);
let allRels: GraphEdge[] = [];
page = 1;
hasMore = true;
const types = new Set<string>();
while (hasMore) {
if (abortController.signal.aborted) return;
const result = await listPluginData(pluginId!, gc.relationshipEntity, page, 100);
for (const r of result.data) {
const relType = String(r.data[gc.edgeLabelField] || '');
types.add(relType);
allRels.push({
source: String(r.data[gc.sourceField] || ''),
target: String(r.data[gc.targetField] || ''),
label: relType,
});
}
hasMore = result.data.length === 100 && allRels.length < result.total;
page++;
}
if (abortController.signal.aborted) return;
setRelationships(allRels);
setRelTypes(Array.from(types));
} catch {
message.warning('数据加载失败');
}
if (!abortController.signal.aborted) setLoading(false);
}
loadData();
return () => abortController.abort();
}, [pluginId, graphConfig, fields]);
return { customers, relationships, loading, fields, graphConfig, relTypes };
}

View File

@@ -0,0 +1,348 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Spin, Typography, Tag, message } from 'antd';
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
closestCorners,
} from '@dnd-kit/core';
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import { listPluginData, patchPluginData } from '../api/pluginData';
import { getPluginSchema, type PluginPageSchema } from '../api/plugins';
// ── 内部看板渲染组件 ──
interface KanbanInnerProps {
pluginId: string;
entity: string;
laneField: string;
laneOrder: string[];
cardTitleField: string;
cardSubtitleField?: string;
cardFields?: string[];
enableDrag?: boolean;
}
function KanbanInner({
pluginId,
entity,
laneField,
laneOrder,
cardTitleField,
cardSubtitleField,
cardFields,
enableDrag,
}: KanbanInnerProps) {
const [lanes, setLanes] = useState<Record<string, any[]>>({});
const [loading, setLoading] = useState(true);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const fetchData = async () => {
setLoading(true);
try {
const allData: Record<string, any[]> = {};
const results = await Promise.all(
laneOrder.map(async (lane) => {
const res = await listPluginData(pluginId, entity, 1, 100, {
filter: { [laneField]: lane },
});
return { lane, data: res.data || [] };
}),
);
for (const { lane, data } of results) {
allData[lane] = data;
}
setLanes(allData);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [pluginId, entity]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = async (event: DragEndEvent) => {
setActiveId(null);
if (!enableDrag) return;
const { active, over } = event;
if (!over) return;
const recordId = active.id as string;
const newLane = String(over.data.current?.lane || over.id);
if (!newLane) return;
let currentLane = '';
let currentRecord: Record<string, any> | null = null;
for (const [lane, items] of Object.entries(lanes)) {
const found = items.find((item) => item.id === recordId);
if (found) {
currentLane = lane;
currentRecord = found;
break;
}
}
if (currentLane === newLane) return;
// 乐观更新
setLanes((prev) => {
const next: Record<string, any[]> = {};
for (const [lane, items] of Object.entries(prev)) {
if (lane === currentLane) {
next[lane] = items.filter((item) => item.id !== recordId);
} else if (lane === newLane) {
const moved = prev[currentLane]?.find((item) => item.id === recordId);
next[lane] = moved ? [...items, moved] : [...items];
} else {
next[lane] = items;
}
}
return next;
});
try {
await patchPluginData(pluginId, entity, recordId, {
data: { [laneField]: newLane },
version: currentRecord?.version ?? 0,
});
message.success('移动成功');
} catch {
message.error('移动失败');
fetchData();
}
};
const handleDragCancel = () => {
setActiveId(null);
};
const activeCard = activeId
? Object.values(lanes)
.flat()
.find((item) => item.id === activeId)
: null;
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin />
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div style={{ display: 'flex', gap: 16, overflowX: 'auto', padding: 16 }}>
{laneOrder.map((lane) => {
const items = lanes[lane] || [];
return (
<div
key={lane}
id={`lane-${lane}`}
style={{
minWidth: 280,
flex: 1,
background: 'var(--colorBgLayout, #f5f5f5)',
borderRadius: 8,
padding: 12,
}}
>
<div
style={{
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Typography.Text strong>{lane}</Typography.Text>
<Tag>{items.length}</Tag>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item) => (
<Card
key={item.id}
id={item.id}
size="small"
style={{
cursor: enableDrag ? 'grab' : 'default',
opacity: activeId === item.id ? 0.4 : 1,
}}
>
<Typography.Text strong>
{item.data?.[cardTitleField] ?? '-'}
</Typography.Text>
{cardSubtitleField && item.data?.[cardSubtitleField] && (
<div>
<Typography.Text type="secondary">
{item.data[cardSubtitleField]}
</Typography.Text>
</div>
)}
{cardFields && (
<div style={{ marginTop: 4 }}>
{cardFields.map(
(f) =>
item.data?.[f] ? (
<Tag key={f}>{String(item.data[f])}</Tag>
) : null,
)}
</div>
)}
</Card>
))}
</div>
</div>
);
})}
</div>
<DragOverlay>
{activeCard ? (
<Card
size="small"
style={{
cursor: 'grabbing',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
width: 260,
}}
>
<Typography.Text strong>
{activeCard.data?.[cardTitleField] ?? '-'}
</Typography.Text>
{cardSubtitleField && activeCard.data?.[cardSubtitleField] && (
<div>
<Typography.Text type="secondary">
{activeCard.data[cardSubtitleField]}
</Typography.Text>
</div>
)}
</Card>
) : null}
</DragOverlay>
</DndContext>
);
}
// ── 路由入口:自加载 schema ──
/**
* 路由入口组件
* 路由: /plugins/:pluginId/kanban/:entityName
* 自动加载 schema 并提取 kanban 页面配置
*/
export default function PluginKanbanPageRoute() {
const { pluginId, entityName } = useParams<{
pluginId: string;
entityName: string;
}>();
const [loading, setLoading] = useState(true);
const [pageConfig, setPageConfig] = useState<{
entity: string;
lane_field: string;
lane_order?: string[];
card_title_field: string;
card_subtitle_field?: string;
card_fields?: string[];
enable_drag?: boolean;
} | null>(null);
useEffect(() => {
if (!pluginId || !entityName) return;
async function loadSchema() {
try {
const schema = await getPluginSchema(pluginId!);
const pages: PluginPageSchema[] = schema.ui?.pages || [];
const kanbanPage = pages.find(
(p): p is PluginPageSchema & { type: 'kanban' } =>
p.type === 'kanban' && p.entity === entityName,
);
if (kanbanPage) {
setPageConfig(kanbanPage);
}
} catch {
message.warning('Schema 加载失败');
} finally {
setLoading(false);
}
}
loadSchema();
}, [pluginId, entityName]);
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin />
</div>
);
}
if (!pageConfig) {
return <div style={{ padding: 24 }}></div>;
}
return (
<KanbanInner
pluginId={pluginId!}
entity={pageConfig.entity}
laneField={pageConfig.lane_field}
laneOrder={pageConfig.lane_order || []}
cardTitleField={pageConfig.card_title_field}
cardSubtitleField={pageConfig.card_subtitle_field}
cardFields={pageConfig.card_fields}
enableDrag={pageConfig.enable_drag}
/>
);
}
// ── Tabs/Detail 内嵌使用 ──
export interface PluginKanbanPageFromConfigProps {
pluginId: string;
page: {
entity: string;
lane_field: string;
lane_order?: string[];
card_title_field: string;
card_subtitle_field?: string;
card_fields?: string[];
enable_drag?: boolean;
};
}
export function PluginKanbanPageFromConfig({
pluginId,
page,
}: PluginKanbanPageFromConfigProps) {
return (
<KanbanInner
pluginId={pluginId}
entity={page.entity}
laneField={page.lane_field}
laneOrder={page.lane_order || []}
cardTitleField={page.card_title_field}
cardSubtitleField={page.card_subtitle_field}
cardFields={page.card_fields}
enableDrag={page.enable_drag}
/>
);
}

View File

@@ -0,0 +1,344 @@
import { useEffect, useState, useCallback } from 'react';
import {
Card,
Row,
Col,
Input,
Tag,
Button,
Space,
Typography,
Modal,
Rate,
message,
Empty,
Tooltip,
Form,
Input as TextArea,
Alert,
Spin,
} from 'antd';
import {
SearchOutlined,
DownloadOutlined,
AppstoreOutlined,
StarOutlined,
} from '@ant-design/icons';
import {
listMarketEntries,
installFromMarket,
listMarketReviews,
submitMarketReview,
listPlugins,
type MarketEntry,
type MarketReview,
} from '../api/plugins';
const { Title, Text, Paragraph } = Typography;
const CATEGORY_COLORS: Record<string, string> = {
'财务': '#059669',
'CRM': '#2563EB',
'进销存': '#9333EA',
'生产': '#dc2626',
'人力资源': '#d97706',
'基础': '#475569',
};
export default function PluginMarket() {
const [plugins, setPlugins] = useState<MarketEntry[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [detailVisible, setDetailVisible] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<MarketEntry | null>(null);
const [installing, setInstalling] = useState<string | null>(null);
// 当前已安装的插件列表(用于标识已安装状态)
const [installedIds, setInstalledIds] = useState<Set<string>>(new Set());
// 评论区
const [reviews, setReviews] = useState<MarketReview[]>([]);
const [reviewForm] = Form.useForm();
const [submittingReview, setSubmittingReview] = useState(false);
const fetchInstalled = useCallback(async () => {
try {
const result = await listPlugins(1);
const ids = new Set(result.data.map((p) => p.id));
setInstalledIds(ids);
} catch {
// 静默失败
}
}, []);
const loadMarketPlugins = useCallback(async () => {
setLoading(true);
try {
const result = await listMarketEntries({ search: searchText || undefined, category: selectedCategory || undefined });
setPlugins(result.data);
} catch {
message.error('加载插件市场失败');
}
setLoading(false);
}, [searchText, selectedCategory]);
useEffect(() => {
fetchInstalled();
}, [fetchInstalled]);
useEffect(() => {
loadMarketPlugins();
}, [loadMarketPlugins]);
const categories = Array.from(new Set(plugins.map((p) => p.category).filter((c): c is string => Boolean(c))));
const showDetail = async (plugin: MarketEntry) => {
setSelectedPlugin(plugin);
setDetailVisible(true);
try {
const result = await listMarketReviews(plugin.id);
setReviews(result);
} catch {
setReviews([]);
}
};
const handleInstall = async (plugin: MarketEntry) => {
setInstalling(plugin.id);
try {
await installFromMarket(plugin.id);
message.success(`${plugin.name} 安装成功`);
fetchInstalled();
loadMarketPlugins();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '安装失败';
message.error(msg);
}
setInstalling(null);
};
const handleSubmitReview = async () => {
if (!selectedPlugin) return;
try {
const values = await reviewForm.validateFields();
setSubmittingReview(true);
await submitMarketReview(selectedPlugin.id, values);
message.success('评分提交成功');
reviewForm.resetFields();
// 刷新评论和列表
const result = await listMarketReviews(selectedPlugin.id);
setReviews(result);
loadMarketPlugins();
} catch {
// 表单验证失败静默
}
setSubmittingReview(false);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}>
<AppstoreOutlined />
</Title>
<Text type="secondary"> ERP </Text>
</div>
{/* 搜索和分类 */}
<div style={{ marginBottom: 24 }}>
<Space size="middle" wrap>
<Input
prefix={<SearchOutlined />}
placeholder="搜索插件..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
allowClear
/>
<Button
type={selectedCategory === null ? 'primary' : 'default'}
onClick={() => setSelectedCategory(null)}
>
</Button>
{categories.map((cat) => (
<Button
key={cat}
type={selectedCategory === cat ? 'primary' : 'default'}
onClick={() => setSelectedCategory(selectedCategory === cat ? null : cat)}
>
{cat}
</Button>
))}
</Space>
</div>
{/* 插件卡片网格 */}
<Spin spinning={loading}>
{plugins.length === 0 && !loading ? (
<Empty description="暂无可用插件" />
) : (
<Row gutter={[16, 16]}>
{plugins.map((plugin) => (
<Col xs={24} sm={12} md={8} lg={6} key={plugin.id}>
<Card
hoverable
onClick={() => showDetail(plugin)}
style={{ height: '100%' }}
>
<div style={{ marginBottom: 8 }}>
<Text strong style={{ fontSize: 16 }}>{plugin.name}</Text>
<Tag
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#475569'}
style={{ marginLeft: 8 }}
>
{plugin.category}
</Tag>
</div>
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{ minHeight: 44, marginBottom: 8 }}
>
{plugin.description ?? '暂无描述'}
</Paragraph>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space size="small">
<Text type="secondary" style={{ fontSize: 12 }}>v{plugin.version}</Text>
{plugin.author && <Text type="secondary" style={{ fontSize: 12 }}>{plugin.author}</Text>}
</Space>
<Tooltip title="评分">
<Space size={2}>
<StarOutlined style={{ color: '#faad14', fontSize: 12 }} />
<Text style={{ fontSize: 12 }}>
{plugin.rating_count > 0
? plugin.rating_avg.toFixed(1)
: '-'}
</Text>
</Space>
</Tooltip>
</div>
{installedIds.has(plugin.plugin_id) && (
<Tag color="green" style={{ marginTop: 8 }}></Tag>
)}
</Card>
</Col>
))}
</Row>
)}
</Spin>
{/* 详情弹窗 */}
<Modal
title={selectedPlugin?.name}
open={detailVisible}
onCancel={() => {
setDetailVisible(false);
reviewForm.resetFields();
}}
footer={null}
width={640}
>
{selectedPlugin && (
<div>
<div style={{ marginBottom: 16 }}>
<Space>
<Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#475569'}>
{selectedPlugin.category}
</Tag>
<Text type="secondary">v{selectedPlugin.version}</Text>
<Text type="secondary">by {selectedPlugin.author ?? '未知'}</Text>
<Text type="secondary">
<DownloadOutlined /> {selectedPlugin.download_count}
</Text>
</Space>
</div>
<Paragraph>{selectedPlugin.description ?? '暂无描述'}</Paragraph>
{selectedPlugin.changelog && (
<div style={{ marginBottom: 16 }}>
<Text strong></Text>
<Paragraph type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
{selectedPlugin.changelog}
</Paragraph>
</div>
)}
{selectedPlugin.tags && selectedPlugin.tags.length > 0 && (
<div style={{ marginBottom: 16 }}>
{selectedPlugin.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
)}
<div style={{ marginBottom: 16 }}>
<Rate disabled value={Math.round(selectedPlugin.rating_avg)} />
<Text type="secondary" style={{ marginLeft: 8 }}>
{selectedPlugin.rating_count}
</Text>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
loading={installing === selectedPlugin.id}
disabled={installedIds.has(selectedPlugin.plugin_id)}
onClick={() => handleInstall(selectedPlugin)}
block
style={{ marginBottom: 24 }}
>
{installedIds.has(selectedPlugin.plugin_id) ? '已安装' : '一键安装'}
</Button>
{/* 评论区 */}
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 16 }}>
<Title level={5}> ({reviews.length})</Title>
{reviews.length > 0 && (
<div style={{ marginBottom: 16, maxHeight: 200, overflowY: 'auto' }}>
{reviews.map((review) => (
<div key={review.id} style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f5f5f5' }}>
<Space>
<Rate disabled value={review.rating} style={{ fontSize: 14 }} />
<Text type="secondary" style={{ fontSize: 12 }}>
{review.created_at ? new Date(review.created_at).toLocaleDateString() : ''}
</Text>
</Space>
{review.review_text && (
<Paragraph style={{ marginTop: 4, marginBottom: 0 }} type="secondary">
{review.review_text}
</Paragraph>
)}
</div>
))}
</div>
)}
{reviews.length === 0 && (
<Alert type="info" message="暂无评价" style={{ marginBottom: 16 }} />
)}
{installedIds.has(selectedPlugin.plugin_id) && (
<Form form={reviewForm} layout="vertical">
<Form.Item name="rating" label="评分" rules={[{ required: true, message: '请选择评分' }]}>
<Rate />
</Form.Item>
<Form.Item name="review_text" label="评价内容">
<TextArea.TextArea rows={2} placeholder="写下你的使用体验..." />
</Form.Item>
<Form.Item>
<Button type="primary" loading={submittingReview} onClick={handleSubmitReview}>
</Button>
</Form.Item>
</Form>
)}
</div>
</div>
)}
</Modal>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More