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