Initialize backendized SurClaw report system

- Add React/Vite frontend for login, dashboard, reports, templates, users, settings, AI, speech, and media workflows.

- Add NestJS/Prisma/PostgreSQL backend with auth, dashboard stats, reports, templates, users, departments, settings, files, AI, speech, audit logs, and HTML sanitization.

- Add Prisma schema, migrations, seed data, persistent app sessions, Docker/Nginx deployment files, and upload volume configuration.

- Add Vitest, Playwright, backend integration tests, and project documentation for requirements, design, permissions, API contracts, testing, deployment, security, and progress.

- Configure production local fallback switch and remove unused Gemini direct dependency/env wiring.
This commit is contained in:
2026-05-02 01:37:20 +08:00
commit 014aca8619
162 changed files with 27116 additions and 0 deletions

24
src/api/ai.ts Normal file
View File

@@ -0,0 +1,24 @@
import { apiRequest } from './client';
export interface AiModelsResponse {
models: string[];
provider: {
endpoint: string;
modelName: string;
};
raw?: unknown;
}
export const listAiModels = async () => {
const response = await apiRequest<AiModelsResponse>('/api/ai/models');
if (!response || !Array.isArray(response.models)) {
throw new Error('Invalid AI models response');
}
return response;
};
export const createAiChatCompletion = async (payload: Record<string, unknown>) =>
apiRequest<any>('/api/ai/chat', {
method: 'POST',
body: payload,
});

38
src/api/client.test.ts Normal file
View File

@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import { apiRequest, ApiError } from './client';
describe('apiRequest', () => {
it('unwraps API data envelopes and sends credentials', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve({ data: { ok: true } }),
text: () => Promise.resolve(''),
} as Response);
await expect(apiRequest<{ ok: boolean }>('/api/health')).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith('/api/health', expect.objectContaining({
credentials: 'include',
}));
});
it('throws typed errors from backend error envelopes', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve({ error: { code: 'UNAUTHORIZED', message: '未登录' } }),
text: () => Promise.resolve(''),
} as Response);
await expect(apiRequest('/api/auth/me')).rejects.toMatchObject({
name: 'ApiError',
status: 401,
code: 'UNAUTHORIZED',
message: '未登录',
});
});
});

80
src/api/client.ts Normal file
View File

@@ -0,0 +1,80 @@
export interface ApiErrorBody {
error?: {
code?: string;
message?: string;
};
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string,
) {
super(message);
this.name = 'ApiError';
}
}
type ApiRequestOptions = Omit<RequestInit, 'body'> & {
body?: BodyInit | Record<string, unknown>;
};
const getApiBaseUrl = () => (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '');
const isJsonBody = (body: unknown) =>
body !== undefined &&
typeof body === 'object' &&
!(body instanceof FormData) &&
!(body instanceof Blob) &&
!(body instanceof URLSearchParams);
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const url = `${getApiBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
const headers = new Headers(options.headers);
let body: BodyInit | null | undefined = options.body as BodyInit | null | undefined;
if (isJsonBody(body)) {
headers.set('Content-Type', 'application/json');
body = JSON.stringify(body);
}
const response = await fetch(url, {
...options,
body,
headers,
credentials: 'include',
});
const payload = await parsePayload(response);
if (!response.ok) {
const errorPayload = payload as ApiErrorBody | null;
throw new ApiError(
errorPayload?.error?.message || `请求失败:${response.status}`,
response.status,
errorPayload?.error?.code,
);
}
if (payload && typeof payload === 'object' && 'data' in payload) {
return (payload as { data: T }).data;
}
return payload as T;
}
async function parsePayload(response: Response) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return response.json();
}
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}

29
src/api/dashboard.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest';
import { getDashboardStats } from './dashboard';
describe('dashboard api', () => {
it('loads dashboard stats from backend', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-type': 'application/json' }),
json: async () => ({
data: {
stats: {
totalCount: 1,
monthCount: 1,
templateCount: 2,
userCount: 3,
todayCount: 1,
trend: [0, 1],
trendLabels: ['5/1', '5/2'],
trendFullDates: ['2026-05-01', '2026-05-02'],
maxTrend: 1,
},
},
}),
} as Response);
await expect(getDashboardStats('7days')).resolves.toMatchObject({ totalCount: 1, userCount: 3 });
expect(fetch).toHaveBeenCalledWith('/api/dashboard/stats?range=7days', expect.objectContaining({ credentials: 'include' }));
});
});

21
src/api/dashboard.ts Normal file
View File

@@ -0,0 +1,21 @@
import { apiRequest } from './client';
export interface DashboardStats {
totalCount: number;
monthCount: number;
templateCount: number;
userCount: number;
todayCount: number;
trend: number[];
trendLabels: string[];
trendFullDates: string[];
maxTrend: number;
}
export const getDashboardStats = async (range: '7days' | '1month') => {
const response = await apiRequest<{ stats: DashboardStats }>(`/api/dashboard/stats?range=${range}`);
if (!response?.stats) {
throw new Error('Invalid dashboard stats response');
}
return response.stats;
};

37
src/api/files.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { listFiles, uploadFileResource } from './files';
describe('files API', () => {
it('lists file resources by kind', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve({ data: { items: [] } }),
text: () => Promise.resolve(''),
} as Response);
await expect(listFiles('TEMPLATE_ASSET')).resolves.toEqual([]);
expect(fetchMock).toHaveBeenCalledWith('/api/files?kind=TEMPLATE_ASSET', expect.objectContaining({
credentials: 'include',
}));
});
it('uploads a generic file resource', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve({ data: { file: { id: 'file_1', filename: 'a.png', mimeType: 'image/png', size: 10, url: '/api/files/file_1/content', createdAt: '2026-05-02T00:00:00.000Z' } } }),
text: () => Promise.resolve(''),
} as Response);
await expect(uploadFileResource('data:image/png;base64,AA==', { filename: 'a.png' })).resolves.toMatchObject({ id: 'file_1' });
expect(fetchMock).toHaveBeenCalledWith('/api/files', expect.objectContaining({
method: 'POST',
credentials: 'include',
}));
});
});

64
src/api/files.ts Normal file
View File

@@ -0,0 +1,64 @@
import { apiRequest } from './client';
export interface FileResourceDto {
id: string;
filename: string;
mimeType: string;
size: number;
url: string;
createdAt: string;
}
export const uploadUserSignature = async (userId: string, dataUrl: string) => {
const response = await apiRequest<{ file: FileResourceDto }>(
`/api/users/${encodeURIComponent(userId)}/signature`,
{
method: 'POST',
body: {
dataUrl,
filename: 'signature.jpg',
},
},
);
if (!response?.file) {
throw new Error('Invalid signature upload response');
}
return response.file;
};
export const deleteUserSignature = (userId: string) =>
apiRequest<null>(`/api/users/${encodeURIComponent(userId)}/signature`, {
method: 'DELETE',
});
export type FileKind = 'TEMPLATE_ASSET' | 'VIDEO' | 'FRAME' | 'REPORT_EXPORT';
export const listFiles = async (kind?: FileKind) => {
const query = kind ? `?kind=${encodeURIComponent(kind)}` : '';
const response = await apiRequest<{ items: FileResourceDto[] }>(`/api/files${query}`);
return response.items || [];
};
export const uploadFileResource = async (
dataUrl: string,
options: { kind?: FileKind; filename?: string; reportId?: string } = {},
) => {
const response = await apiRequest<{ file: FileResourceDto }>('/api/files', {
method: 'POST',
body: {
dataUrl,
kind: options.kind || 'TEMPLATE_ASSET',
filename: options.filename,
reportId: options.reportId,
},
});
if (!response?.file) {
throw new Error('Invalid file upload response');
}
return response.file;
};
export const deleteFileResource = (id: string) =>
apiRequest<null>(`/api/files/${encodeURIComponent(id)}`, {
method: 'DELETE',
});

33
src/api/library.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest';
import { getFieldLibrary, updateFieldLibrary } from './library';
describe('field library API', () => {
it('loads the backend field library envelope', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve({ data: { library: { formFields: [], customTimeFormats: [], multiSelectOptions: {}, anesthesiaOptions: [] } } }),
text: () => Promise.resolve(''),
} as Response);
await expect(getFieldLibrary()).resolves.toMatchObject({ formFields: [] });
});
it('patches field library changes', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve({ data: { library: { formFields: [], customTimeFormats: ['HH:mm'], multiSelectOptions: {}, anesthesiaOptions: [] } } }),
text: () => Promise.resolve(''),
} as Response);
await updateFieldLibrary({ customTimeFormats: ['HH:mm'] });
expect(fetchMock).toHaveBeenCalledWith('/api/library/fields', expect.objectContaining({
method: 'PATCH',
credentials: 'include',
}));
});
});

28
src/api/library.ts Normal file
View File

@@ -0,0 +1,28 @@
import { FormField } from '../types';
import { apiRequest } from './client';
export interface FieldLibrary {
formFields: FormField[];
customTimeFormats: string[];
multiSelectOptions: Record<string, string[]>;
anesthesiaOptions: string[];
}
export const getFieldLibrary = async () => {
const response = await apiRequest<{ library: FieldLibrary }>('/api/library/fields');
if (!response?.library) {
throw new Error('Invalid field library response');
}
return response.library;
};
export const updateFieldLibrary = async (library: Partial<FieldLibrary>) => {
const response = await apiRequest<{ library: FieldLibrary }>('/api/library/fields', {
method: 'PATCH',
body: library as Record<string, unknown>,
});
if (!response?.library) {
throw new Error('Invalid field library response');
}
return response.library;
};

42
src/api/reports.ts Normal file
View File

@@ -0,0 +1,42 @@
import { Report } from '../types';
import { apiRequest } from './client';
export interface ReportListResponse {
items: Report[];
total: number;
page: number;
pageSize: number;
}
export const listReports = async () => {
const response = await apiRequest<ReportListResponse>('/api/reports');
if (!response || !Array.isArray(response.items)) {
throw new Error('Invalid reports response');
}
return response;
};
export const getReport = async (id: string) => {
const response = await apiRequest<{ report: Report }>(`/api/reports/${encodeURIComponent(id)}`);
if (!response?.report) {
throw new Error('Invalid report response');
}
return response.report;
};
export const saveReportToApi = async (report: Report, existingId?: string) => {
const response = await apiRequest<{ report: Report }>(
existingId ? `/api/reports/${encodeURIComponent(existingId)}` : '/api/reports',
{
method: existingId ? 'PATCH' : 'POST',
body: report as unknown as Record<string, unknown>,
},
);
if (!response?.report) {
throw new Error('Invalid report response');
}
return response.report;
};
export const deleteReportFromApi = (id: string) =>
apiRequest<null>(`/api/reports/${encodeURIComponent(id)}`, { method: 'DELETE' });

31
src/api/settings.ts Normal file
View File

@@ -0,0 +1,31 @@
import { SystemSettings } from '../types';
import { apiRequest } from './client';
export const getSystemSettings = async () => {
const response = await apiRequest<{ settings: SystemSettings }>('/api/settings/system');
if (!response?.settings) {
throw new Error('Invalid settings response');
}
return response.settings;
};
export const updateSystemSettings = async (settings: SystemSettings) => {
const response = await apiRequest<{ settings: SystemSettings }>('/api/settings/system', {
method: 'PATCH',
body: settings as unknown as Record<string, unknown>,
});
if (!response?.settings) {
throw new Error('Invalid settings response');
}
return response.settings;
};
export const resetSystemSettings = async () => {
const response = await apiRequest<{ settings: SystemSettings }>('/api/settings/system/reset', {
method: 'POST',
});
if (!response?.settings) {
throw new Error('Invalid settings response');
}
return response.settings;
};

22
src/api/speech.test.ts Normal file
View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { buildSpeechIatWebSocketUrl } from './speech';
describe('buildSpeechIatWebSocketUrl', () => {
it('uses the current host when no explicit API base URL is configured', () => {
expect(buildSpeechIatWebSocketUrl({ protocol: 'http:', host: 'localhost:3001' })).toBe(
'ws://localhost:3001/api/speech/iat',
);
expect(buildSpeechIatWebSocketUrl({ protocol: 'https:', host: 'example.com' })).toBe(
'wss://example.com/api/speech/iat',
);
});
it('converts an explicit API base URL to the matching websocket protocol', () => {
expect(buildSpeechIatWebSocketUrl({ protocol: 'http:', host: 'localhost:3001' }, 'http://localhost:3002')).toBe(
'ws://localhost:3002/api/speech/iat',
);
expect(buildSpeechIatWebSocketUrl({ protocol: 'http:', host: 'localhost:3001' }, 'https://api.example.com/')).toBe(
'wss://api.example.com/api/speech/iat',
);
});
});

14
src/api/speech.ts Normal file
View File

@@ -0,0 +1,14 @@
export const buildSpeechIatWebSocketUrl = (location: Pick<Location, 'protocol' | 'host'>, explicitBase = '') => {
explicitBase = explicitBase.replace(/\/$/, '');
if (explicitBase) {
const url = new URL('/api/speech/iat', explicitBase);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return url.toString();
}
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${location.host}/api/speech/iat`;
};
export const getSpeechIatWebSocketUrl = () =>
buildSpeechIatWebSocketUrl(window.location, import.meta.env.VITE_API_BASE_URL || '');

50
src/api/templates.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Template } from '../types';
import { apiRequest } from './client';
export interface TemplateListResponse {
items: Template[];
total: number;
}
export type TemplateAccess = 'use' | 'manage';
export const listTemplates = async (access: TemplateAccess = 'use') => {
const response = await apiRequest<TemplateListResponse>(`/api/templates?access=${access}`);
if (!response || !Array.isArray(response.items)) {
throw new Error('Invalid templates response');
}
return response;
};
export const getTemplate = async (id: string) => {
const response = await apiRequest<{ template: Template }>(`/api/templates/${encodeURIComponent(id)}`);
if (!response?.template) {
throw new Error('Invalid template response');
}
return response.template;
};
export const createTemplate = async (template: Template) => {
const response = await apiRequest<{ template: Template }>('/api/templates', {
method: 'POST',
body: template as unknown as Record<string, unknown>,
});
if (!response?.template) {
throw new Error('Invalid template response');
}
return response.template;
};
export const updateTemplate = async (id: string, template: Partial<Template>) => {
const response = await apiRequest<{ template: Template }>(`/api/templates/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: template as unknown as Record<string, unknown>,
});
if (!response?.template) {
throw new Error('Invalid template response');
}
return response.template;
};
export const deleteTemplateFromApi = (id: string) =>
apiRequest<null>(`/api/templates/${encodeURIComponent(id)}`, { method: 'DELETE' });

80
src/api/users.ts Normal file
View File

@@ -0,0 +1,80 @@
import { User } from '../types';
import { apiRequest } from './client';
export interface Department {
id: string;
name: string;
code: string;
visibleTemplates: string[];
manageableTemplates: string[];
createdAt: string;
updatedAt: string;
}
export interface UserListResponse {
items: User[];
total: number;
}
export interface DepartmentListResponse {
items: Department[];
total: number;
}
export const listUsers = async () => {
const response = await apiRequest<UserListResponse>('/api/users');
if (!response || !Array.isArray(response.items)) {
throw new Error('Invalid users response');
}
return response;
};
export const createUser = async (user: User) => {
const response = await apiRequest<{ user: User }>('/api/users', {
method: 'POST',
body: user as unknown as Record<string, unknown>,
});
if (!response?.user) {
throw new Error('Invalid user response');
}
return response.user;
};
export const updateUser = async (id: string, user: Partial<User>) => {
const response = await apiRequest<{ user: User }>(`/api/users/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: user as unknown as Record<string, unknown>,
});
if (!response?.user) {
throw new Error('Invalid user response');
}
return response.user;
};
export const deleteUserFromApi = (id: string) =>
apiRequest<null>(`/api/users/${encodeURIComponent(id)}`, { method: 'DELETE' });
export const listDepartments = async () => {
const response = await apiRequest<DepartmentListResponse>('/api/departments');
if (!response || !Array.isArray(response.items)) {
throw new Error('Invalid departments response');
}
return response;
};
export const updateDepartmentTemplatePermissions = async (
id: string,
body: Pick<Department, 'visibleTemplates' | 'manageableTemplates'>,
) => {
const response = await apiRequest<{ department: Department }>(
`/api/departments/${encodeURIComponent(id)}/template-permissions`,
{
method: 'PATCH',
body,
},
);
if (!response?.department) {
throw new Error('Invalid department response');
}
return response.department;
};