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:
45
src/App.tsx
Normal file
45
src/App.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import ReportEditor from './pages/ReportEditor';
|
||||
import ReportManage from './pages/ReportManage';
|
||||
import ReportView from './pages/ReportView';
|
||||
import TemplateManage from './pages/TemplateManage';
|
||||
import UserManage from './pages/UserManage';
|
||||
import SystemSettings from './pages/SystemSettings';
|
||||
import { AuthProvider, useAuth } from './auth/AuthContext';
|
||||
|
||||
function RequireAuth({ children }: { children: ReactElement }) {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading && !user) {
|
||||
return <div className="min-h-screen bg-bg flex items-center justify-center text-sm font-semibold text-text-muted">正在恢复登录态...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />
|
||||
<Route path="/report-editor" element={<RequireAuth><ReportEditor /></RequireAuth>} />
|
||||
<Route path="/report-manage" element={<RequireAuth><ReportManage /></RequireAuth>} />
|
||||
<Route path="/report-view/:id" element={<RequireAuth><ReportView /></RequireAuth>} />
|
||||
<Route path="/template-manage" element={<RequireAuth><TemplateManage /></RequireAuth>} />
|
||||
<Route path="/user-manage" element={<RequireAuth><UserManage /></RequireAuth>} />
|
||||
<Route path="/system-settings" element={<RequireAuth><SystemSettings /></RequireAuth>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
24
src/api/ai.ts
Normal file
24
src/api/ai.ts
Normal 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
38
src/api/client.test.ts
Normal 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
80
src/api/client.ts
Normal 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
29
src/api/dashboard.test.ts
Normal 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
21
src/api/dashboard.ts
Normal 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
37
src/api/files.test.ts
Normal 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
64
src/api/files.ts
Normal 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
33
src/api/library.test.ts
Normal 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
28
src/api/library.ts
Normal 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
42
src/api/reports.ts
Normal 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
31
src/api/settings.ts
Normal 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
22
src/api/speech.test.ts
Normal 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
14
src/api/speech.ts
Normal 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
50
src/api/templates.ts
Normal 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
80
src/api/users.ts
Normal 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;
|
||||
};
|
||||
108
src/auth/AuthContext.tsx
Normal file
108
src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { apiRequest } from '../api/client';
|
||||
import { isLocalFallbackEnabled } from '../config/runtime';
|
||||
import { Template, User } from '../types';
|
||||
import { storage } from '../utils/storage';
|
||||
import { AuthUserResponse, toFrontendUser } from './backendUser';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<User>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<User | null>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(() =>
|
||||
isLocalFallbackEnabled() ? storage.get<User | null>('currentUser', null) : null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const syncUser = useCallback((authUser: AuthUserResponse['user']) => {
|
||||
const users = storage.get<User[]>('users', []);
|
||||
const templates = storage.get<Template[]>('templates', []);
|
||||
const frontendUser = toFrontendUser(authUser, users, templates);
|
||||
const updatedUsers = [
|
||||
...users.filter((item) => item.username !== frontendUser.username),
|
||||
frontendUser,
|
||||
];
|
||||
|
||||
storage.set('users', updatedUsers);
|
||||
storage.set('currentUser', frontendUser);
|
||||
setUser(frontendUser);
|
||||
return frontendUser;
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiRequest<AuthUserResponse>('/api/auth/me');
|
||||
return syncUser(response.user);
|
||||
} catch {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
storage.remove('currentUser');
|
||||
setUser(null);
|
||||
return null;
|
||||
}
|
||||
const cachedUser = storage.get<User | null>('currentUser', null);
|
||||
setUser(cachedUser);
|
||||
return cachedUser;
|
||||
}
|
||||
}, [syncUser]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
refresh().finally(() => {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const response = await apiRequest<AuthUserResponse>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
});
|
||||
return syncUser(response.user);
|
||||
}, [syncUser]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await apiRequest<null>('/api/auth/logout', { method: 'POST' });
|
||||
} catch {
|
||||
// Local cleanup still matters if the session is already gone or the API is temporarily unavailable.
|
||||
} finally {
|
||||
storage.remove('currentUser');
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => ({
|
||||
user,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
}), [isLoading, login, logout, refresh, user]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useOptionalAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
56
src/auth/backendUser.test.ts
Normal file
56
src/auth/backendUser.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Template, User } from '../types';
|
||||
import { BackendUser, toFrontendUser } from './backendUser';
|
||||
|
||||
const backendDoctor: BackendUser = {
|
||||
id: 'u1',
|
||||
username: '0001',
|
||||
role: 'doctor',
|
||||
name: '张医生',
|
||||
tenantId: 't1',
|
||||
departmentId: 'd1',
|
||||
departmentName: '外科',
|
||||
status: 'active',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('toFrontendUser', () => {
|
||||
it('maps backend doctor users to the frontend user role and keeps usable templates', () => {
|
||||
const templates: Template[] = [
|
||||
{ id: 'dept_surgery', name: '外科模板', content: '', createdAt: '', author: 'admin', scope: 'department', department: '外科' },
|
||||
{ id: 'dept_internal', name: '内科模板', content: '', createdAt: '', author: 'admin', scope: 'department', department: '内科' },
|
||||
{ id: 'mine', name: '我的模板', content: '', createdAt: '', author: '0001', scope: 'personal', ownerUser: '0001' },
|
||||
{ id: 'other', name: '他人模板', content: '', createdAt: '', author: '0002', scope: 'personal', ownerUser: '0002' },
|
||||
];
|
||||
|
||||
expect(toFrontendUser(backendDoctor, [], templates)).toMatchObject({
|
||||
username: '0001',
|
||||
role: 'user',
|
||||
department: '外科',
|
||||
visibleTemplates: ['dept_surgery', 'mine'],
|
||||
manageableTemplates: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves local signature and explicit template assignments while syncing backend identity', () => {
|
||||
const users: User[] = [
|
||||
{
|
||||
username: '0001',
|
||||
role: 'user',
|
||||
name: '旧姓名',
|
||||
signature: 'data:image/jpeg;base64,test',
|
||||
visibleTemplates: ['custom'],
|
||||
manageableTemplates: ['managed'],
|
||||
},
|
||||
];
|
||||
|
||||
expect(toFrontendUser(backendDoctor, users, [])).toMatchObject({
|
||||
username: '0001',
|
||||
name: '张医生',
|
||||
signature: 'data:image/jpeg;base64,test',
|
||||
visibleTemplates: ['custom'],
|
||||
manageableTemplates: ['managed'],
|
||||
});
|
||||
});
|
||||
});
|
||||
64
src/auth/backendUser.ts
Normal file
64
src/auth/backendUser.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Template, User } from '../types';
|
||||
|
||||
export type BackendRole = 'super' | 'admin' | 'doctor';
|
||||
export type BackendStatus = 'active' | 'inactive';
|
||||
|
||||
export interface BackendUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: BackendRole;
|
||||
name: string;
|
||||
tenantId: string;
|
||||
departmentId: string;
|
||||
departmentName: string;
|
||||
status: BackendStatus;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
signatureFileId?: string;
|
||||
signature?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AuthUserResponse {
|
||||
user: BackendUser;
|
||||
}
|
||||
|
||||
export const toFrontendUser = (
|
||||
backendUser: BackendUser,
|
||||
existingUsers: User[],
|
||||
templates: Template[],
|
||||
): User => {
|
||||
const existing = existingUsers.find((user) => user.username === backendUser.username);
|
||||
const role: User['role'] = backendUser.role === 'doctor' ? 'user' : backendUser.role;
|
||||
const allTemplateIds = templates.map((template) => template.id);
|
||||
const departmentTemplateIds = templates
|
||||
.filter((template) => template.scope !== 'personal' && template.department === backendUser.departmentName)
|
||||
.map((template) => template.id);
|
||||
const personalTemplateIds = templates
|
||||
.filter((template) => template.scope === 'personal' && template.ownerUser === backendUser.username)
|
||||
.map((template) => template.id);
|
||||
const defaultVisibleTemplates =
|
||||
role === 'super' || role === 'admin'
|
||||
? allTemplateIds
|
||||
: [...departmentTemplateIds, ...personalTemplateIds];
|
||||
|
||||
return {
|
||||
...existing,
|
||||
id: backendUser.id,
|
||||
username: backendUser.username,
|
||||
role,
|
||||
name: backendUser.name,
|
||||
phone: backendUser.phone ?? existing?.phone,
|
||||
email: backendUser.email ?? existing?.email,
|
||||
departmentId: backendUser.departmentId,
|
||||
department: backendUser.departmentName,
|
||||
status: backendUser.status,
|
||||
createdAt: backendUser.createdAt,
|
||||
updatedAt: backendUser.updatedAt,
|
||||
signatureFileId: backendUser.signatureFileId,
|
||||
signature: existing?.signature ?? backendUser.signature,
|
||||
visibleTemplates: existing?.visibleTemplates ?? defaultVisibleTemplates,
|
||||
manageableTemplates: existing?.manageableTemplates ?? (role === 'user' ? [] : allTemplateIds),
|
||||
};
|
||||
};
|
||||
37
src/components/Sidebar.test.tsx
Normal file
37
src/components/Sidebar.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AuthProvider } from '../auth/AuthContext';
|
||||
import { User } from '../types';
|
||||
import { storage } from '../utils/storage';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
const renderSidebar = (user: User) => {
|
||||
storage.set('currentUser', user);
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<Sidebar />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Sidebar permissions', () => {
|
||||
it('shows administrative modules to super users', () => {
|
||||
renderSidebar({ username: 'admin', role: 'super', name: '超级管理员' });
|
||||
|
||||
expect(screen.getByText('模板管理')).toBeInTheDocument();
|
||||
expect(screen.getByText('用户管理')).toBeInTheDocument();
|
||||
expect(screen.getByText('系统设置')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides template and user management from doctors', () => {
|
||||
renderSidebar({ username: '0001', role: 'user', name: '张医生' });
|
||||
|
||||
expect(screen.getByText('工作台')).toBeInTheDocument();
|
||||
expect(screen.queryByText('模板管理')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('用户管理')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('系统设置')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
88
src/components/Sidebar.tsx
Normal file
88
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileEdit,
|
||||
FileText,
|
||||
Layout,
|
||||
Users,
|
||||
Settings,
|
||||
LogOut
|
||||
} from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import { storage } from '../utils/storage';
|
||||
import { useOptionalAuth } from '../auth/AuthContext';
|
||||
|
||||
export default function Sidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const auth = useOptionalAuth();
|
||||
const authUser = auth?.user ?? null;
|
||||
const currentUser = authUser ?? storage.get<User>('currentUser', {} as User);
|
||||
|
||||
const logout = async () => {
|
||||
if (auth) {
|
||||
await auth.logout();
|
||||
} else {
|
||||
storage.remove('currentUser');
|
||||
}
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ path: '/dashboard', icon: <LayoutDashboard size={18} />, title: '工作台', roles: ['super', 'admin', 'user'] },
|
||||
{ path: '/report-editor', icon: <FileEdit size={18} />, title: '图文报告生成', roles: ['super', 'admin', 'user'] },
|
||||
{ path: '/report-manage', icon: <FileText size={18} />, title: '报告管理', roles: ['super', 'admin', 'user'] },
|
||||
{ path: '/template-manage', icon: <Layout size={18} />, title: '模板管理', roles: ['super', 'admin'] },
|
||||
{ path: '/user-manage', icon: <Users size={18} />, title: '用户管理', roles: ['super', 'admin'] },
|
||||
{ path: '/system-settings', icon: <Settings size={18} />, title: '系统设置', roles: ['super', 'admin', 'user'] },
|
||||
];
|
||||
|
||||
const filteredNavItems = navItems.filter(item => item.roles.includes(currentUser.role));
|
||||
const isCollapsed = location.pathname === '/report-editor' || location.pathname === '/template-manage';
|
||||
|
||||
return (
|
||||
<aside className={`${isCollapsed ? 'w-20 px-3' : 'w-60 px-6'} bg-sidebar-bg border-r border-border flex flex-col py-8 shrink-0 h-screen sticky top-0 transition-all`}>
|
||||
<div className={`flex items-center gap-3 mb-12 h-12 ${isCollapsed ? 'justify-center' : ''}`}>
|
||||
<img src="/logo_square.png" alt="Logo" className="w-10 h-10 object-contain shrink-0" />
|
||||
<div className={`font-bold text-lg text-text-main leading-tight ${isCollapsed ? 'hidden' : ''}`}>
|
||||
<div>图文手术病历</div>
|
||||
<div>报告生成终端</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-1 flex-1">
|
||||
{filteredNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
title={item.title}
|
||||
className={`flex items-center rounded-lg text-sm font-medium transition-all ${
|
||||
isCollapsed ? 'justify-center px-2 py-2.5' : 'gap-3 px-3 py-2.5'
|
||||
} ${
|
||||
location.pathname === item.path
|
||||
? 'bg-[#EFF6FF] text-accent'
|
||||
: 'text-text-muted hover:bg-bg hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className={isCollapsed ? 'hidden' : ''}>{item.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={`mt-auto pt-5 border-t border-border ${isCollapsed ? 'text-center' : ''}`}>
|
||||
<div className={`text-[12px] text-text-muted mb-1 ${isCollapsed ? 'hidden' : ''}`}>当前用户</div>
|
||||
<div className={`font-semibold text-sm text-text-main mb-4 ${isCollapsed ? 'hidden' : ''}`}>{currentUser.name || '未登录'}</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
title="退出登录"
|
||||
className={`flex items-center rounded-lg text-sm font-medium text-text-muted hover:bg-red-50 hover:text-red-600 transition-all ${isCollapsed ? 'justify-center w-full px-2 py-2.5' : 'gap-3 px-3 py-2.5 w-full'}`}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span className={isCollapsed ? 'hidden' : ''}>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
2
src/config/runtime.ts
Normal file
2
src/config/runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const isLocalFallbackEnabled = () =>
|
||||
import.meta.env.DEV || import.meta.env.VITE_ENABLE_LOCAL_FALLBACK === 'true';
|
||||
225
src/index.css
Normal file
225
src/index.css
Normal file
@@ -0,0 +1,225 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--color-bg: #F8FAFC;
|
||||
--color-sidebar-bg: #FFFFFF;
|
||||
--color-accent: #2563EB;
|
||||
--color-text-main: #1E293B;
|
||||
--color-text-muted: #64748B;
|
||||
--color-border: #E2E8F0;
|
||||
--color-card-bg: #FFFFFF;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-bg text-text-main antialiased;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-accent {
|
||||
@apply bg-accent text-white px-5 py-2.5 rounded-lg font-semibold text-sm transition-all hover:opacity-90 active:scale-95;
|
||||
}
|
||||
|
||||
.card-minimal {
|
||||
@apply bg-card-bg border border-border rounded-xl shadow-[0_1px_3px_rgba(0,0,0,0.05)] p-6;
|
||||
}
|
||||
|
||||
.input-minimal {
|
||||
@apply w-full px-4 py-2.5 border border-border rounded-lg text-sm transition-colors focus:outline-hidden focus:border-accent;
|
||||
}
|
||||
|
||||
/* Editor Styles */
|
||||
.editor-content-wrapper {
|
||||
@apply flex-1 overflow-auto flex justify-center min-w-fit bg-[#e2e8f0] p-6;
|
||||
}
|
||||
.editor-content {
|
||||
@apply w-[210mm] min-h-[297mm] h-auto bg-white p-[40px_48px] shadow-[0_2px_8px_rgba(0,0,0,0.15)] outline-hidden leading-relaxed text-text-main text-sm flex-shrink-0 overflow-visible relative;
|
||||
}
|
||||
.editor-content:focus { outline: none; }
|
||||
.editor-content p { margin: 0; padding: 4px 0; }
|
||||
.editor-content h1 { font-size: 22px; margin: 16px 0 12px; font-weight: 600; text-align: center; }
|
||||
.editor-content strong, .editor-content b { font-weight: 600; }
|
||||
.editor-content u { text-decoration: underline; }
|
||||
.editor-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.editor-content td {
|
||||
padding: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.editor-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 8px auto;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
@apply border-2 border-dashed border-[#cbd5e1] rounded-lg p-4 mb-2 bg-[#f8fafc] cursor-pointer min-h-[70px] flex flex-col items-center justify-center transition-all relative;
|
||||
}
|
||||
.image-placeholder:hover {
|
||||
@apply border-accent bg-[#f0f7ff];
|
||||
}
|
||||
.image-placeholder.has-image {
|
||||
@apply border-none bg-transparent p-0 min-h-0 cursor-default;
|
||||
}
|
||||
.image-placeholder .delete-btn {
|
||||
@apply absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full items-center justify-center text-[10px] cursor-pointer z-10;
|
||||
display: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.image-placeholder:hover .delete-btn {
|
||||
display: flex;
|
||||
}
|
||||
.image-placeholder .placeholder-text {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.image-placeholder.has-image .placeholder-text {
|
||||
display: none !important;
|
||||
}
|
||||
.template-info-section {
|
||||
@apply relative mb-4;
|
||||
}
|
||||
|
||||
.manual-frame-badge {
|
||||
@apply absolute top-1 left-1 px-1.5 py-0.5 bg-yellow-400 text-yellow-900 text-[9px] font-bold rounded shadow-sm pointer-events-none;
|
||||
}
|
||||
|
||||
/* Smart Field Bindable Controls */
|
||||
.smart-field-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 2px;
|
||||
vertical-align: text-bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.smart-field-wrapper .field-label {
|
||||
color: #64748b;
|
||||
user-select: none;
|
||||
}
|
||||
.smart-field-wrapper .field-value {
|
||||
min-width: 32px;
|
||||
padding: 0 4px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
line-height: 1.2;
|
||||
font-size: inherit;
|
||||
vertical-align: text-bottom;
|
||||
box-sizing: border-box;
|
||||
min-height: 1.2em;
|
||||
outline: none;
|
||||
}
|
||||
.smart-field-wrapper .field-value:empty::before {
|
||||
content: '\200b';
|
||||
}
|
||||
.smart-field-wrapper .field-value:focus {
|
||||
background-color: #e2e8f0;
|
||||
border-color: #94a3b8;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
.smart-field-wrapper .delete-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: none;
|
||||
z-index: 10;
|
||||
}
|
||||
.smart-field-wrapper .delete-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
.template-editor-mode .smart-field-wrapper:hover .delete-btn,
|
||||
.template-editor-mode .smart-field-wrapper:focus-within .delete-btn {
|
||||
display: block;
|
||||
}
|
||||
.report-signature-img {
|
||||
max-width: 120px;
|
||||
max-height: 40px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
@page { size: A4; margin: 0; }
|
||||
body * { visibility: hidden !important; }
|
||||
.print-content, .print-content * { visibility: visible !important; }
|
||||
.print-wrapper {
|
||||
position: static !important;
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
overflow: visible !important;
|
||||
background: white !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.print-content {
|
||||
position: static !important;
|
||||
width: 210mm !important;
|
||||
min-height: auto !important;
|
||||
height: auto !important;
|
||||
box-shadow: none !important;
|
||||
padding: 10mm !important;
|
||||
margin: 0 !important;
|
||||
overflow: visible !important;
|
||||
background: white !important;
|
||||
}
|
||||
.print-content .image-placeholder:not(.has-image) {
|
||||
display: none !important;
|
||||
}
|
||||
.print-content .smart-field-wrapper .field-value {
|
||||
border: none !important;
|
||||
border-bottom: 1px solid #000 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 2px !important;
|
||||
}
|
||||
.print-content .smart-field-wrapper .delete-btn {
|
||||
display: none !important;
|
||||
}
|
||||
.print-content .ai-region {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.print-content .ai-region > [contenteditable="false"] {
|
||||
display: none !important;
|
||||
}
|
||||
.report-signature-img {
|
||||
max-width: 120px !important;
|
||||
max-height: 40px !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
object-fit: contain !important;
|
||||
vertical-align: middle !important;
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
287
src/pages/Dashboard.tsx
Normal file
287
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import { FileText, Layout, Plus, Settings, TrendingUp, ArrowRight } from 'lucide-react';
|
||||
import { User, Report, Template } from '../types';
|
||||
import { storage } from '../utils/storage';
|
||||
import { getAccessibleReports, getUsableTemplates } from '../utils/permissions';
|
||||
import { getDashboardStats, type DashboardStats } from '../api/dashboard';
|
||||
import { isLocalFallbackEnabled } from '../config/runtime';
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalCount: 0,
|
||||
monthCount: 0,
|
||||
templateCount: 0,
|
||||
userCount: 0,
|
||||
todayCount: 0,
|
||||
trend: [0,0,0,0,0,0,0],
|
||||
trendLabels: ['','','','','','',''],
|
||||
trendFullDates: ['','','','','','',''],
|
||||
maxTrend: 1
|
||||
});
|
||||
const [tooltip, setTooltip] = useState<{ visible: boolean; x: number; y: number; date: string; count: number }>({ visible: false, x: 0, y: 0, date: '', count: 0 });
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [timeRange, setTimeRange] = useState<'7days' | '1month'>('7days');
|
||||
|
||||
useEffect(() => {
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
if (!user) {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
setCurrentUser(user);
|
||||
|
||||
void getDashboardStats(timeRange)
|
||||
.then(setStats)
|
||||
.catch(() => {
|
||||
if (!isLocalFallbackEnabled()) return;
|
||||
setStats(buildLocalStats(user, timeRange));
|
||||
});
|
||||
}, [navigate, timeRange]);
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-bg">
|
||||
<Sidebar />
|
||||
|
||||
<main className="flex-1 p-10 overflow-y-auto">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-main">工作台概览</h1>
|
||||
<p className="text-text-muted text-sm mt-1">实时报告动态与系统状态追踪。</p>
|
||||
</div>
|
||||
<Link to="/report-editor" className="btn-accent inline-flex items-center gap-2">
|
||||
<Plus size={18} />
|
||||
新建报告
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="card-minimal">
|
||||
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold">全部报告总数</div>
|
||||
<div className="text-3xl font-bold text-text-main">{stats.totalCount}</div>
|
||||
</div>
|
||||
|
||||
<div className="card-minimal">
|
||||
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold">本月报告总数</div>
|
||||
<div className="text-3xl font-bold text-text-main">{stats.monthCount}</div>
|
||||
</div>
|
||||
|
||||
<div className="card-minimal">
|
||||
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold">今日新增报告</div>
|
||||
<div className="text-3xl font-bold text-text-main">{stats.todayCount}</div>
|
||||
</div>
|
||||
|
||||
<div className="card-minimal">
|
||||
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold">系统总用户</div>
|
||||
<div className="text-3xl font-bold text-text-main">{stats.userCount}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1.5fr_1fr] gap-6">
|
||||
<div className="card-minimal flex flex-col">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<span className="font-bold text-sm uppercase tracking-wider text-text-main flex items-center gap-2">
|
||||
<TrendingUp size={16} className="text-accent" />
|
||||
报告增长趋势
|
||||
</span>
|
||||
<div className="flex bg-slate-100 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setTimeRange('7days')}
|
||||
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${timeRange === '7days' ? 'bg-white text-accent shadow-sm' : 'text-text-muted hover:text-text-main'}`}
|
||||
>最近 7 天</button>
|
||||
<button
|
||||
onClick={() => setTimeRange('1month')}
|
||||
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${timeRange === '1month' ? 'bg-white text-accent shadow-sm' : 'text-text-muted hover:text-text-main'}`}
|
||||
>最近 30 天</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-50 rounded-xl p-6 min-h-[240px] relative">
|
||||
{/* SVG Area Chart */}
|
||||
<svg
|
||||
viewBox="0 0 300 135"
|
||||
className="w-full h-full overflow-visible"
|
||||
onMouseMove={(e) => {
|
||||
const svg = e.currentTarget;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const mouseX = ((e.clientX - rect.left) / rect.width) * 300;
|
||||
const paddingX = 10;
|
||||
const chartW = 300 - paddingX * 2;
|
||||
const n = stats.trend.length;
|
||||
if (n <= 1) return;
|
||||
let idx = Math.round(((mouseX - paddingX) / chartW) * (n - 1));
|
||||
idx = Math.max(0, Math.min(n - 1, idx));
|
||||
const ptX = paddingX + (idx / (n - 1)) * chartW;
|
||||
const ptY = 8 + (120 - 16) - (stats.maxTrend > 0 ? (stats.trend[idx] / stats.maxTrend) * (120 - 16) : 0);
|
||||
setTooltip({
|
||||
visible: true,
|
||||
x: (ptX / 300) * rect.width,
|
||||
y: (ptY / 135) * rect.height,
|
||||
date: stats.trendFullDates[idx] || '',
|
||||
count: stats.trend[idx]
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(prev => ({ ...prev, visible: false }))}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="trendGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#2563EB" stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor="#2563EB" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Grid lines */}
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1="0"
|
||||
y1={i * 30}
|
||||
x2="300"
|
||||
y2={i * 30}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
))}
|
||||
{/* Area path */}
|
||||
{stats.trend.length > 0 && (() => {
|
||||
const paddingX = 10;
|
||||
const paddingY = 8;
|
||||
const chartW = 300 - paddingX * 2;
|
||||
const chartH = 120 - paddingY * 2;
|
||||
const points = stats.trend.map((count, i) => {
|
||||
const x = paddingX + (i / (stats.trend.length - 1)) * chartW;
|
||||
const y = paddingY + chartH - (stats.maxTrend > 0 ? (count / stats.maxTrend) * chartH : 0);
|
||||
return { x, y, count, label: stats.trendLabels[i] };
|
||||
});
|
||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
||||
const areaPath = `${linePath} L ${points[points.length - 1].x} ${120 - paddingY} L ${points[0].x} ${120 - paddingY} Z`;
|
||||
return (
|
||||
<g>
|
||||
<path d={areaPath} fill="url(#trendGradient)" />
|
||||
<path d={linePath} fill="none" stroke="#2563EB" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
{/* Transparent capture layer for reliable mouse events */}
|
||||
<rect x="0" y="0" width="300" height="135" fill="transparent" />
|
||||
{points.map((p, i) => (
|
||||
<g key={i}>
|
||||
{/* 7天模式显示圆点和数值;30天模式隐藏 */}
|
||||
{stats.trend.length <= 10 && (
|
||||
<>
|
||||
<circle cx={p.x} cy={p.y} r="3.5" fill="#2563EB" stroke="#fff" strokeWidth="2" />
|
||||
<text x={p.x} y={p.y - 10} textAnchor="middle" fontSize="8" fill="#64748B" fontWeight="bold">{p.count}</text>
|
||||
</>
|
||||
)}
|
||||
{/* 标签稀疏化:7天每天显示,30天每隔5天显示 */}
|
||||
{(stats.trend.length <= 10 || i % 5 === 0) && (
|
||||
<text x={p.x} y={128} textAnchor="middle" fontSize={stats.trendLabels.length > 10 ? '7' : '8'} fill="#94A3B8" fontWeight="bold">{p.label}</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
{/* Tooltip */}
|
||||
{tooltip.visible && (
|
||||
<div
|
||||
className="absolute pointer-events-none bg-slate-800 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10"
|
||||
style={{ left: tooltip.x, top: tooltip.y - 40, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
<div className="font-bold">{tooltip.date}</div>
|
||||
<div className="text-slate-300">报告数: {tooltip.count}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-minimal">
|
||||
<div className="font-bold text-sm uppercase tracking-wider text-text-main mb-6">快捷入口</div>
|
||||
<div className="space-y-2">
|
||||
<Link to="/report-manage" className="flex items-center justify-between p-4 bg-slate-50 rounded-xl hover:bg-white hover:shadow-md border border-transparent hover:border-border transition-all group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-text-muted group-hover:bg-accent group-hover:text-white transition-colors shadow-sm">
|
||||
<FileText size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-text-main">报告管理</div>
|
||||
<div className="text-[11px] text-text-muted">查看与搜索所有报告</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} className="text-text-muted group-hover:text-accent group-hover:translate-x-1 transition-all" />
|
||||
</Link>
|
||||
|
||||
{(currentUser.role === 'super' || currentUser.role === 'admin') && (
|
||||
<Link to="/template-manage" className="flex items-center justify-between p-4 bg-slate-50 rounded-xl hover:bg-white hover:shadow-md border border-transparent hover:border-border transition-all group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-text-muted group-hover:bg-accent group-hover:text-white transition-colors shadow-sm">
|
||||
<Layout size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-text-main">模板管理</div>
|
||||
<div className="text-[11px] text-text-muted">配置报告标准模板</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} className="text-text-muted group-hover:text-accent group-hover:translate-x-1 transition-all" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{currentUser.role === 'super' && (
|
||||
<Link to="/system-settings" className="flex items-center justify-between p-4 bg-slate-50 rounded-xl hover:bg-white hover:shadow-md border border-transparent hover:border-border transition-all group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-text-muted group-hover:bg-accent group-hover:text-white transition-colors shadow-sm">
|
||||
<Settings size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-text-main">系统设置</div>
|
||||
<div className="text-[11px] text-text-muted">配置抽帧与API参数</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} className="text-text-muted group-hover:text-accent group-hover:translate-x-1 transition-all" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const buildLocalStats = (user: User, timeRange: '7days' | '1month'): DashboardStats => {
|
||||
const reports = storage.get<Report[]>('reports', []);
|
||||
const templates = storage.get<Template[]>('templates', []);
|
||||
const users = storage.get<User[]>('users', []);
|
||||
const userReports = getAccessibleReports(user, reports, users);
|
||||
const userTemplates = getUsableTemplates(user, templates);
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0];
|
||||
const todayReports = userReports.filter(r => (r.createdAt || '').slice(0, 10) === today);
|
||||
const currentMonth = today.slice(0, 7);
|
||||
const thisMonthReports = userReports.filter(r => r.createdAt && r.createdAt.startsWith(currentMonth));
|
||||
const daysCount = timeRange === '7days' ? 7 : 30;
|
||||
const trend: number[] = [];
|
||||
const labels: string[] = [];
|
||||
const fullDates: string[] = [];
|
||||
for (let i = daysCount - 1; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
labels.push(timeRange === '7days' ? `${d.getMonth() + 1}/${d.getDate()}` : `${d.getDate()}`);
|
||||
fullDates.push(dateStr);
|
||||
trend.push(userReports.filter(r => (r.createdAt || '').slice(0, 10) === dateStr).length);
|
||||
}
|
||||
|
||||
return {
|
||||
totalCount: userReports.length,
|
||||
monthCount: thisMonthReports.length,
|
||||
templateCount: userTemplates.length,
|
||||
userCount: users.length,
|
||||
todayCount: todayReports.length,
|
||||
trend,
|
||||
trendLabels: labels,
|
||||
trendFullDates: fullDates,
|
||||
maxTrend: Math.max(...trend, 1),
|
||||
};
|
||||
};
|
||||
70
src/pages/Login.test.tsx
Normal file
70
src/pages/Login.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AuthProvider } from '../auth/AuthContext';
|
||||
import { SystemSettings, Template, User } from '../types';
|
||||
import { storage } from '../utils/storage';
|
||||
import Login from './Login';
|
||||
|
||||
const renderLogin = () => render(
|
||||
<MemoryRouter>
|
||||
<AuthProvider>
|
||||
<Login />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe('Login page', () => {
|
||||
it('initializes the demo data that makes the pure frontend app usable', async () => {
|
||||
renderLogin();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.get<User[]>('users', []).map((user) => user.username)).toEqual(['admin', 'manager', '0001']);
|
||||
});
|
||||
|
||||
const templates = storage.get<Template[]>('templates', []);
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0]).toMatchObject({ scope: 'department', department: '外科' });
|
||||
expect(storage.get('formFieldsConfig', [])).not.toHaveLength(0);
|
||||
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
||||
expect(settings.frameCount).toBe(12);
|
||||
expect(settings.xfSpeechConfig).toEqual({ appId: '', apiKey: '', apiSecret: '' });
|
||||
expect(screen.getByText('admin / 123456')).toBeInTheDocument();
|
||||
expect(screen.getByText('manager / 123456')).toBeInTheDocument();
|
||||
expect(screen.getByText('0001 / 123456')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows disabled account errors returned by the backend auth API', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.endsWith('/logo_square.png')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'image/png' }),
|
||||
blob: () => Promise.resolve(new Blob(['test-image'], { type: 'image/png' })),
|
||||
text: () => Promise.resolve(''),
|
||||
json: () => Promise.resolve({}),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: url.endsWith('/api/auth/login') ? 403 : 401,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
blob: () => Promise.resolve(new Blob([])),
|
||||
text: () => Promise.resolve(JSON.stringify({ error: { code: 'FORBIDDEN', message: '账号已禁用' } })),
|
||||
json: () => Promise.resolve({ error: { code: 'FORBIDDEN', message: '账号已禁用' } }),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('请输入您的用户ID'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('请输入您的登录密码'), { target: { value: '123456' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '进入系统' }));
|
||||
|
||||
expect(await screen.findByText('该账号已被禁用')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
203
src/pages/Login.tsx
Normal file
203
src/pages/Login.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { User, Template, SystemSettings, FormField, DEFAULT_FORM_FIELDS, DEFAULT_AI_PROVIDERS } from '../types';
|
||||
import { defaultReportContent } from '../utils/defaultContent';
|
||||
import { storage } from '../utils/storage';
|
||||
import { User as UserIcon, Lock } from 'lucide-react';
|
||||
import { ApiError } from '../api/client';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { isLocalFallbackEnabled } from '../config/runtime';
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { user: currentUser, login } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const initData = () => {
|
||||
if (!isLocalFallbackEnabled()) return;
|
||||
const existingUsers = storage.get<User[]>('users', []);
|
||||
const hasAdmin = existingUsers.some((u) => u.username === 'admin' && u.password === '123456');
|
||||
|
||||
let savedTemplates = storage.get<Template[]>('templates', []);
|
||||
if (savedTemplates.length === 0) {
|
||||
const initialTemplate: Template = {
|
||||
id: 'surgery',
|
||||
name: '腹腔镜胆囊切除术报告',
|
||||
desc: '标准手术记录模板',
|
||||
content: defaultReportContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: 'admin',
|
||||
scope: 'department',
|
||||
department: '外科'
|
||||
};
|
||||
savedTemplates = [initialTemplate];
|
||||
storage.set('templates', savedTemplates);
|
||||
}
|
||||
|
||||
if (!hasAdmin) {
|
||||
const allTplIds = savedTemplates.map(t => t.id);
|
||||
const defaultUsers: User[] = [
|
||||
{ username: 'admin', password: '123456', role: 'super', name: '超级管理员', status: 'active', createdAt: '2024-01-01', department: 'admin', visibleTemplates: allTplIds, manageableTemplates: allTplIds },
|
||||
{ username: 'manager', password: '123456', role: 'admin', name: '管理员', status: 'active', createdAt: '2024-01-01', department: '外科', visibleTemplates: allTplIds, manageableTemplates: allTplIds },
|
||||
{ username: '0001', password: '123456', role: 'user', name: '张医生', status: 'active', createdAt: '2024-01-01', department: '外科', visibleTemplates: allTplIds, manageableTemplates: [] }
|
||||
];
|
||||
storage.set('users', defaultUsers);
|
||||
console.log('Default users initialized');
|
||||
}
|
||||
|
||||
const fieldsConfig = storage.get<FormField[]>('formFieldsConfig', []);
|
||||
if (fieldsConfig.length === 0) {
|
||||
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
|
||||
}
|
||||
|
||||
const savedAssets = storage.get<{id: string; name: string; dataUrl: string}[]>('imageAssets', []);
|
||||
if (savedAssets.length === 0) {
|
||||
fetch('/logo_square.png')
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
storage.set('imageAssets', [{ id: 'asset_logo', name: '医院Logo', dataUrl }]);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
||||
if (!settingsRaw.frameCount) {
|
||||
const defaultSettings = {
|
||||
frameCount: 12,
|
||||
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
|
||||
defaultTemplate: savedTemplates[0]?.id || '',
|
||||
frameMode: 'keep',
|
||||
activeAiProvider: 'kimi',
|
||||
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
||||
autoInsertFrames: true,
|
||||
autoInsertDelay: 1,
|
||||
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
|
||||
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' }
|
||||
};
|
||||
storage.set('systemSettings', defaultSettings);
|
||||
}
|
||||
};
|
||||
|
||||
initData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
}, [currentUser, navigate]);
|
||||
|
||||
const loginWithBackend = async (u: string, p: string) => {
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await login(u.trim(), p.trim());
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 403) {
|
||||
setError('该账号已被禁用');
|
||||
} else if (error instanceof ApiError && error.status === 401) {
|
||||
setError('用户ID或密码错误');
|
||||
} else {
|
||||
setError('登录服务暂不可用,请稍后重试');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void loginWithBackend(username, password);
|
||||
};
|
||||
|
||||
const fillLogin = (u: string, p: string) => {
|
||||
setUsername(u);
|
||||
setPassword(p);
|
||||
void loginWithBackend(u, p);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg p-6">
|
||||
<div className="bg-white rounded-3xl shadow-[0_20px_50px_-12px_rgba(0,0,0,0.08)] p-12 w-full max-w-[460px] border border-border">
|
||||
<div className="text-center mb-10">
|
||||
<div className="flex flex-col items-center">
|
||||
<img src="/logo_square.png" alt="Logo" className="w-16 h-16 object-contain mb-6" />
|
||||
<h1 className="text-2xl font-bold text-text-main tracking-tight mb-1">手术图文病历报告生成终端</h1>
|
||||
<p className="text-xs text-text-muted uppercase tracking-widest font-bold">智能图文报告管理系统</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[10px] font-bold text-text-main uppercase tracking-wider">用户ID</label>
|
||||
<div className="relative">
|
||||
<UserIcon className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入您的用户ID"
|
||||
required
|
||||
className="input-minimal pl-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[10px] font-bold text-text-main uppercase tracking-wider">密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入您的登录密码"
|
||||
required
|
||||
className="input-minimal pl-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn-accent w-full py-4 text-base shadow-[0_8px_20px_-4px_rgba(37,99,235,0.2)]"
|
||||
>
|
||||
{isSubmitting ? '登录中...' : '进入系统'}
|
||||
</button>
|
||||
{error && <div className="text-red-500 text-xs text-center font-bold animate-pulse">{error}</div>}
|
||||
</form>
|
||||
|
||||
<div className="mt-10 pt-8 border-t border-border">
|
||||
<h3 className="text-[10px] text-text-muted mb-4 uppercase tracking-widest font-bold text-center">快捷登录测试账号</h3>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{[
|
||||
{ u: 'admin', p: '123456', r: '超级管理员', c: 'bg-amber-100 text-amber-700' },
|
||||
{ u: 'manager', p: '123456', r: '管理员', c: 'bg-blue-100 text-blue-700' },
|
||||
{ u: '0001', p: '123456', r: '医生', c: 'bg-green-100 text-green-700' }
|
||||
].map(test => (
|
||||
<div
|
||||
key={test.u}
|
||||
onClick={() => fillLogin(test.u, test.p)}
|
||||
className="flex justify-between items-center p-3 bg-slate-50 rounded-xl cursor-pointer transition-all hover:bg-white hover:shadow-md border border-transparent hover:border-border group"
|
||||
>
|
||||
<span className="text-xs font-bold text-text-main">{test.u} / {test.p}</span>
|
||||
<span className={`text-[9px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wider ${test.c}`}>
|
||||
{test.r}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3118
src/pages/ReportEditor.tsx
Normal file
3118
src/pages/ReportEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
64
src/pages/ReportManage.test.tsx
Normal file
64
src/pages/ReportManage.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Report, User } from '../types';
|
||||
import { storage } from '../utils/storage';
|
||||
import ReportManage from './ReportManage';
|
||||
|
||||
const baseReport = {
|
||||
patientName: '患者',
|
||||
hospitalId: 'H001',
|
||||
content: '<p>报告</p>',
|
||||
authorName: '医生',
|
||||
createdAt: '2026-05-01',
|
||||
status: 'draft' as const,
|
||||
};
|
||||
|
||||
describe('ReportManage page', () => {
|
||||
it('filters doctors to their own reports', async () => {
|
||||
storage.set<User>('currentUser', { username: '0001', role: 'user', name: '张医生', department: '外科' });
|
||||
storage.set<User[]>('users', [
|
||||
{ username: '0001', role: 'user', name: '张医生', department: '外科' },
|
||||
{ username: '0002', role: 'user', name: '李医生', department: '外科' },
|
||||
]);
|
||||
storage.set<Report[]>('reports', [
|
||||
{ ...baseReport, id: 'RPT_1', title: '本人报告', author: '0001', department: '外科' },
|
||||
{ ...baseReport, id: 'RPT_2', title: '他人报告', author: '0002', department: '外科' },
|
||||
]);
|
||||
|
||||
render(<MemoryRouter><ReportManage /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByText('本人报告')).toBeInTheDocument();
|
||||
expect(screen.queryByText('他人报告')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all local reports to super users', async () => {
|
||||
storage.set<User>('currentUser', { username: 'admin', role: 'super', name: '超级管理员' });
|
||||
storage.set<Report[]>('reports', [
|
||||
{ ...baseReport, id: 'RPT_1', title: '医生 A 报告', author: '0001' },
|
||||
{ ...baseReport, id: 'RPT_2', title: '医生 B 报告', author: '0002' },
|
||||
]);
|
||||
|
||||
render(<MemoryRouter><ReportManage /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByText('医生 A 报告')).toBeInTheDocument();
|
||||
expect(screen.getByText('医生 B 报告')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters admins to reports from their own department', async () => {
|
||||
storage.set<User>('currentUser', { username: 'manager', role: 'admin', name: '管理员', department: '外科' });
|
||||
storage.set<User[]>('users', [
|
||||
{ username: '0001', role: 'user', name: '张医生', department: '外科' },
|
||||
{ username: '0002', role: 'user', name: '李医生', department: '内科' },
|
||||
]);
|
||||
storage.set<Report[]>('reports', [
|
||||
{ ...baseReport, id: 'RPT_1', title: '外科报告', author: '0001', department: '外科' },
|
||||
{ ...baseReport, id: 'RPT_2', title: '内科报告', author: '0002', department: '内科' },
|
||||
]);
|
||||
|
||||
render(<MemoryRouter><ReportManage /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByText('外科报告')).toBeInTheDocument();
|
||||
expect(screen.queryByText('内科报告')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
511
src/pages/ReportManage.tsx
Normal file
511
src/pages/ReportManage.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import { Search, Eye, Edit, Trash2, FileText, History, X, Download, Printer } from 'lucide-react';
|
||||
import { User, Report, DEFAULT_FORM_FIELDS } from '../types';
|
||||
import { storage } from '../utils/storage';
|
||||
import { printDocument } from '../utils/print';
|
||||
import { canDeleteReport, canEditReport, getAccessibleReports } from '../utils/permissions';
|
||||
import { deleteReportFromApi, listReports } from '../api/reports';
|
||||
import { isLocalFallbackEnabled } from '../config/runtime';
|
||||
|
||||
const formatDateTime = (iso: string) => {
|
||||
if (!iso) return '-';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
export default function ReportManage() {
|
||||
const navigate = useNavigate();
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [filteredReports, setFilteredReports] = useState<Report[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dateFilter, setDateFilter] = useState('');
|
||||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||
const [historyReport, setHistoryReport] = useState<Report | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportTarget, setExportTarget] = useState<Report | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
if (!user) {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
setCurrentUser(user);
|
||||
|
||||
const loadReports = async () => {
|
||||
const savedReports = storage.get<Report[]>('reports', []);
|
||||
try {
|
||||
const response = await listReports();
|
||||
setReports(response.items);
|
||||
storage.set('reports', response.items);
|
||||
} catch {
|
||||
if (isLocalFallbackEnabled()) setReports(savedReports);
|
||||
}
|
||||
};
|
||||
|
||||
void loadReports();
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
|
||||
const users = storage.get<User[]>('users', []);
|
||||
let filtered = getAccessibleReports(currentUser, reports, users);
|
||||
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(r =>
|
||||
r.title.toLowerCase().includes(term) ||
|
||||
r.patientName.toLowerCase().includes(term) ||
|
||||
r.hospitalId.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
if (statusFilter) {
|
||||
filtered = filtered.filter(r => r.status === statusFilter);
|
||||
}
|
||||
|
||||
if (dateFilter) {
|
||||
const now = new Date();
|
||||
filtered = filtered.filter(r => {
|
||||
const reportDate = new Date(r.createdAt);
|
||||
if (dateFilter === 'today') {
|
||||
return reportDate.toDateString() === now.toDateString();
|
||||
} else if (dateFilter === 'week') {
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
return reportDate >= weekAgo;
|
||||
} else if (dateFilter === 'month') {
|
||||
return reportDate.getMonth() === now.getMonth() && reportDate.getFullYear() === now.getFullYear();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
setFilteredReports(filtered);
|
||||
}, [reports, currentUser, searchTerm, statusFilter, dateFilter]);
|
||||
|
||||
const deleteReport = async (id: string) => {
|
||||
if (window.confirm('确定要删除此报告吗?')) {
|
||||
try {
|
||||
await deleteReportFromApi(id);
|
||||
} catch {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
alert('删除失败:后端服务不可用');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const updatedReports = reports.filter(r => r.id !== id);
|
||||
setReports(updatedReports);
|
||||
storage.set('reports', updatedReports);
|
||||
setSelectedIds(prev => prev.filter(pid => pid !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const viewReport = (id: string) => {
|
||||
navigate(`/report-view/${id}`);
|
||||
};
|
||||
|
||||
const editReport = (id: string) => {
|
||||
navigate(`/report-editor?id=${id}`);
|
||||
};
|
||||
|
||||
const openHistory = (report: Report) => {
|
||||
setHistoryReport(report);
|
||||
setHistoryModalOpen(true);
|
||||
};
|
||||
|
||||
const restoreHistory = (content: string) => {
|
||||
if (!historyReport) return;
|
||||
if (!window.confirm('确定要恢复此历史版本到编辑器吗?当前未保存的内容将丢失。')) return;
|
||||
navigate(`/report-editor?id=${historyReport.id}&restore=1`);
|
||||
storage.setSession(`restore_${historyReport.id}`, content);
|
||||
setHistoryModalOpen(false);
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.length === filteredReports.length && filteredReports.length > 0) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(filteredReports.map(r => r.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (!window.confirm(`确定要删除选中的 ${selectedIds.length} 份报告吗?`)) return;
|
||||
let hasApiFailure = false;
|
||||
await Promise.all(selectedIds.map(async (id) => {
|
||||
try {
|
||||
await deleteReportFromApi(id);
|
||||
} catch {
|
||||
hasApiFailure = true;
|
||||
}
|
||||
}));
|
||||
if (hasApiFailure && !isLocalFallbackEnabled()) {
|
||||
alert('批量删除失败:后端服务不可用');
|
||||
return;
|
||||
}
|
||||
const updated = reports.filter(r => !selectedIds.includes(r.id));
|
||||
setReports(updated);
|
||||
storage.set('reports', updated);
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
const buildExportData = (report: Report) => {
|
||||
const fields: Record<string, any> = {};
|
||||
DEFAULT_FORM_FIELDS.forEach(f => {
|
||||
fields[f.key] = (report as any)[f.key];
|
||||
});
|
||||
return {
|
||||
meta: {
|
||||
id: report.id,
|
||||
title: report.title,
|
||||
createdAt: report.createdAt,
|
||||
updatedAt: report.updatedAt,
|
||||
author: report.author,
|
||||
authorName: report.authorName,
|
||||
status: report.status,
|
||||
revision: report.revision || 1
|
||||
},
|
||||
fields
|
||||
};
|
||||
};
|
||||
|
||||
const downloadJSON = (data: any, filename: string) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exportSinglePDF = (report: Report) => {
|
||||
printDocument(report.content);
|
||||
};
|
||||
|
||||
const exportSingleJSON = (report: Report) => {
|
||||
const data = buildExportData(report);
|
||||
downloadJSON(data, `报告_${report.patientName || '未命名'}_${report.id}.json`);
|
||||
};
|
||||
|
||||
const exportBulkPDF = () => {
|
||||
const users = storage.get<User[]>('users', []);
|
||||
const selectedReports = getAccessibleReports(currentUser!, reports, users).filter(r => selectedIds.includes(r.id));
|
||||
const mergedHTML = selectedReports.map(r => r.content).join('<div style="page-break-after: always;"></div>');
|
||||
printDocument(mergedHTML);
|
||||
};
|
||||
|
||||
const exportBulkJSON = () => {
|
||||
const users = storage.get<User[]>('users', []);
|
||||
const selectedReports = getAccessibleReports(currentUser!, reports, users).filter(r => selectedIds.includes(r.id));
|
||||
const data = selectedReports.map(r => buildExportData(r));
|
||||
const timestamp = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||||
downloadJSON(data, `reports_export_${timestamp}.json`);
|
||||
};
|
||||
|
||||
const openExportModal = (report: Report) => {
|
||||
setExportTarget(report);
|
||||
setExportModalOpen(true);
|
||||
};
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-bg">
|
||||
<Sidebar />
|
||||
|
||||
<main className="flex-1 p-10 overflow-y-auto">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-main">报告管理</h1>
|
||||
<p className="text-text-muted text-sm mt-1">
|
||||
{currentUser.role === 'super' ? '查看/检索全院所有已撰写的报告' : currentUser.role === 'admin' ? '查看、编辑、打印本部门报告' : '查看、编辑、打印自己创建的报告'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-4">
|
||||
<div className="relative flex-1 min-w-[240px] max-w-[400px]">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索报告标题或患者姓名..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input-minimal pl-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="input-minimal max-w-[160px] bg-white"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="completed">已完成</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={dateFilter}
|
||||
onChange={(e) => setDateFilter(e.target.value)}
|
||||
className="input-minimal max-w-[160px] bg-white"
|
||||
>
|
||||
<option value="">全部时间</option>
|
||||
<option value="today">今天</option>
|
||||
<option value="week">本周</option>
|
||||
<option value="month">本月</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-border rounded-lg">
|
||||
<span className="text-sm font-semibold text-text-main">已选择 {selectedIds.length} 项</span>
|
||||
<div className="flex-1"></div>
|
||||
<button
|
||||
onClick={exportBulkPDF}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-white border border-border hover:bg-slate-100 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Printer size={14} /> 批量导出 PDF
|
||||
</button>
|
||||
<button
|
||||
onClick={exportBulkJSON}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-white border border-border hover:bg-slate-100 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Download size={14} /> 批量导出 JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
批量删除
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIds([])}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-lg text-text-muted hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
取消选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-minimal p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50">
|
||||
<th className="px-4 py-4 text-left border-b border-border w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
|
||||
checked={filteredReports.length > 0 && selectedIds.length === filteredReports.length}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">报告信息</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">患者</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">住院号</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">创建者</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-40">时间</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-24">状态</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredReports.length > 0 ? (
|
||||
filteredReports.map((report) => (
|
||||
<tr key={report.id} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="px-4 py-4 border-b border-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
|
||||
checked={selectedIds.includes(report.id)}
|
||||
onChange={() => toggleSelect(report.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-semibold text-text-main">{report.title}</div>
|
||||
<div className="text-xs text-text-muted font-mono mt-1">{report.id} · V{report.revision || 1}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-text-main">{report.patientName}</td>
|
||||
<td className="px-6 py-4 text-sm text-text-main">{report.hospitalId}</td>
|
||||
<td className="px-6 py-4 text-sm text-text-main">{report.authorName}</td>
|
||||
<td className="px-6 py-4 text-sm text-text-muted leading-relaxed">
|
||||
<div>创建: {formatDateTime(report.createdAt)}</div>
|
||||
<div>修改: {formatDateTime(report.updatedAt || report.createdAt)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${
|
||||
report.status === 'draft'
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{report.status === 'draft' ? '草稿' : '已完成'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => viewReport(report.id)}
|
||||
className="p-2 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
|
||||
title="查看"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
{canEditReport(currentUser, report, storage.get<User[]>('users', [])) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => editReport(report.id)}
|
||||
className="p-2 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
{canDeleteReport(currentUser, report, storage.get<User[]>('users', [])) && (
|
||||
<button
|
||||
onClick={() => deleteReport(report.id)}
|
||||
className="p-2 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openHistory(report)}
|
||||
className="p-2 rounded-lg bg-amber-50 text-amber-600 hover:bg-amber-100 transition-colors"
|
||||
title="历史版本"
|
||||
>
|
||||
<History size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openExportModal(report)}
|
||||
className="p-2 rounded-lg bg-emerald-50 text-emerald-600 hover:bg-emerald-100 transition-colors"
|
||||
title="导出"
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-24 text-center">
|
||||
<div className="flex flex-col items-center text-text-muted">
|
||||
<FileText size={48} className="mb-4 opacity-20" />
|
||||
<h3 className="text-base font-semibold text-text-main mb-1">暂无报告</h3>
|
||||
<p className="text-sm">点击"新建报告"开始撰写您的第一份报告</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{historyModalOpen && historyReport && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-8 w-full max-w-[600px] max-h-[80vh] overflow-y-auto shadow-2xl border border-border">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-text-main">操作历史</h3>
|
||||
<p className="text-sm text-text-muted">报告: {historyReport.title}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHistoryModalOpen(false)}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...(historyReport.history || [])].reverse().map((item, idx) => (
|
||||
<div key={idx} className="border border-border rounded-lg p-4 bg-slate-50">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded ${
|
||||
item.action === 'complete_report'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
{item.action === 'complete_report' ? '完成报告' : '保存草稿'}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">{formatDateTime(item.updatedAt)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-main mb-3">由 {item.updatedBy} {item.action === 'complete_report' ? '完成' : '保存'} · V{item.revision || 1}</p>
|
||||
<button
|
||||
onClick={() => restoreHistory(item.content)}
|
||||
className="text-xs font-bold text-accent hover:underline"
|
||||
>
|
||||
恢复此版本
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="border border-border rounded-lg p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-bold text-accent uppercase tracking-wider">当前版本 V{historyReport.revision || 1}</span>
|
||||
<span className="text-xs text-text-muted">{formatDateTime(historyReport.updatedAt || historyReport.createdAt)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-main">当前显示内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportModalOpen && exportTarget && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-bold text-text-main">导出报告</h3>
|
||||
<button
|
||||
onClick={() => setExportModalOpen(false)}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">选择导出格式:</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => { exportSinglePDF(exportTarget); setExportModalOpen(false); }}
|
||||
className="w-full px-4 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<Printer size={18} className="text-text-muted" />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-semibold text-text-main">导出 PDF</div>
|
||||
<div className="text-xs text-text-muted">调用浏览器打印并保存为 PDF</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { exportSingleJSON(exportTarget); setExportModalOpen(false); }}
|
||||
className="w-full px-4 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<Download size={18} className="text-text-muted" />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-semibold text-text-main">导出 JSON</div>
|
||||
<div className="text-xs text-text-muted">下载结构化字段数据</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/pages/ReportView.tsx
Normal file
145
src/pages/ReportView.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import { Printer, Edit, ChevronLeft } from 'lucide-react';
|
||||
import { User, Report } from '../types';
|
||||
import { storage } from '../utils/storage';
|
||||
import { canEditReport, canViewReport } from '../utils/permissions';
|
||||
import { getReport } from '../api/reports';
|
||||
import { isLocalFallbackEnabled } from '../config/runtime';
|
||||
|
||||
export default function ReportView() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [report, setReport] = useState<Report | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
if (!user) {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
setCurrentUser(user);
|
||||
|
||||
const loadReport = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const apiReport = await getReport(id);
|
||||
setReport(apiReport);
|
||||
const reports = storage.get<Report[]>('reports', []);
|
||||
storage.set('reports', [...reports.filter(r => r.id !== apiReport.id), apiReport]);
|
||||
return;
|
||||
} catch {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
alert('报告加载失败或无权访问');
|
||||
navigate('/report-manage');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const reports = storage.get<Report[]>('reports', []);
|
||||
const found = reports.find(r => r.id === id);
|
||||
|
||||
if (!found) {
|
||||
alert('报告不存在');
|
||||
navigate('/report-manage');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = storage.get<User[]>('users', []);
|
||||
if (!canViewReport(user, found, users)) {
|
||||
alert('您没有权限查看此报告');
|
||||
navigate('/report-manage');
|
||||
return;
|
||||
}
|
||||
|
||||
setReport(found);
|
||||
};
|
||||
|
||||
void loadReport();
|
||||
}, [id, navigate]);
|
||||
|
||||
if (!report || !currentUser) return null;
|
||||
|
||||
const canEdit = canEditReport(currentUser, report, storage.get<User[]>('users', []));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-bg">
|
||||
<Sidebar />
|
||||
|
||||
<main className="flex-1 p-10 overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-8 print:hidden">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/report-manage')}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-main">查看报告</h1>
|
||||
<p className="text-text-muted text-sm mt-1 uppercase tracking-wider font-bold">报告编号: {report.id}</p>
|
||||
<p className="text-text-muted text-xs mt-1 font-bold">修订版本: V{report.revision || 1}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => navigate(`/report-editor?id=${report.id}`)}
|
||||
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Edit size={16} />
|
||||
编辑报告
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="btn-accent inline-flex items-center gap-2"
|
||||
>
|
||||
<Printer size={16} />
|
||||
打印报告
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-[0_10px_25px_-5px_rgba(0,0,0,0.05)] p-12 max-w-[900px] mx-auto print:shadow-none print:p-0 print:m-0">
|
||||
<div className="text-center pb-10 border-b border-border mb-10">
|
||||
<h2 className="text-3xl font-bold text-text-main mb-6">{report.title}</h2>
|
||||
<div className="flex justify-center gap-8 flex-wrap">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">患者姓名</span>
|
||||
<span className="text-sm font-bold text-text-main">{report.patientName}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">创建者</span>
|
||||
<span className="text-sm font-bold text-text-main">{report.authorName}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">创建时间</span>
|
||||
<span className="text-sm font-bold text-text-main">{report.createdAt}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">报告状态</span>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${
|
||||
report.status === 'draft' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{report.status === 'draft' ? '草稿' : '已完成'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">修订版本</span>
|
||||
<span className="text-sm font-bold text-text-main">V{report.revision || 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="report-content leading-relaxed text-text-main text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: report.content }}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
673
src/pages/SystemSettings.tsx
Normal file
673
src/pages/SystemSettings.tsx
Normal file
@@ -0,0 +1,673 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import { Video, Globe, Layout, Check, Plus, X } from 'lucide-react';
|
||||
import { User, SystemSettings as ISystemSettings, Template, DEFAULT_AI_PROVIDERS, AiProviderConfig } from '../types';
|
||||
import { storage } from '../utils/storage';
|
||||
import { listTemplates } from '../api/templates';
|
||||
import { getSystemSettings, resetSystemSettings, updateSystemSettings } from '../api/settings';
|
||||
import { listAiModels } from '../api/ai';
|
||||
import { isLocalFallbackEnabled } from '../config/runtime';
|
||||
|
||||
const normalizeSettings = (
|
||||
input: Partial<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>,
|
||||
templates: Template[],
|
||||
): ISystemSettings & { frameMode?: 'uniform' | 'keep' } => {
|
||||
const aiProviders = {
|
||||
...DEFAULT_AI_PROVIDERS,
|
||||
...(input.aiProviders || {}),
|
||||
};
|
||||
const framePositions = Array.isArray(input.framePositions) && input.framePositions.length > 0
|
||||
? [...input.framePositions].sort((a, b) => a - b)
|
||||
: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6];
|
||||
|
||||
return {
|
||||
frameCount: framePositions.length,
|
||||
framePositions,
|
||||
defaultTemplate: input.defaultTemplate || templates[0]?.id || '',
|
||||
frameMode: input.frameMode || 'keep',
|
||||
activeAiProvider: input.activeAiProvider || 'kimi',
|
||||
aiProviders,
|
||||
autoInsertFrames: typeof input.autoInsertFrames === 'boolean' ? input.autoInsertFrames : false,
|
||||
autoInsertDelay: typeof input.autoInsertDelay === 'number' ? input.autoInsertDelay : 0,
|
||||
autoInsertFrameIndices: input.autoInsertFrameIndices || [],
|
||||
xfSpeechConfig: input.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' },
|
||||
};
|
||||
};
|
||||
|
||||
export default function SystemSettings() {
|
||||
const navigate = useNavigate();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [settings, setSettings] = useState<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>({
|
||||
frameCount: 12,
|
||||
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
|
||||
defaultTemplate: '',
|
||||
frameMode: 'keep',
|
||||
activeAiProvider: 'kimi',
|
||||
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
||||
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' }
|
||||
});
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null);
|
||||
const [modeModalOpen, setModeModalOpen] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
const xfAppIdRef = useRef<HTMLInputElement>(null);
|
||||
const xfApiKeyRef = useRef<HTMLInputElement>(null);
|
||||
const xfApiSecretRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeyInputRef.current) {
|
||||
const targetValue = settings.aiProviders[settings.activeAiProvider]?.apiKey || '';
|
||||
if (apiKeyInputRef.current.value !== targetValue) {
|
||||
apiKeyInputRef.current.value = targetValue;
|
||||
}
|
||||
}
|
||||
}, [settings.aiProviders[settings.activeAiProvider]?.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (xfAppIdRef.current) {
|
||||
const target = settings.xfSpeechConfig?.appId || '';
|
||||
if (xfAppIdRef.current.value !== target) xfAppIdRef.current.value = target;
|
||||
}
|
||||
if (xfApiKeyRef.current) {
|
||||
const target = settings.xfSpeechConfig?.apiKey || '';
|
||||
if (xfApiKeyRef.current.value !== target) xfApiKeyRef.current.value = target;
|
||||
}
|
||||
if (xfApiSecretRef.current) {
|
||||
const target = settings.xfSpeechConfig?.apiSecret || '';
|
||||
if (xfApiSecretRef.current.value !== target) xfApiSecretRef.current.value = target;
|
||||
}
|
||||
}, [settings.xfSpeechConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
if (!user) {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
setCurrentUser(user);
|
||||
|
||||
const savedSettings = storage.get<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>('systemSettings', {} as ISystemSettings & { frameMode?: 'uniform' | 'keep' });
|
||||
// Migrate old flat fields to new structured format
|
||||
if (!savedSettings.aiProviders) {
|
||||
const providers = { ...DEFAULT_AI_PROVIDERS };
|
||||
if ((savedSettings as any).kimiApiKey || (savedSettings as any).kimiApiEndpoint) {
|
||||
providers.kimi = {
|
||||
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
|
||||
apiKey: (savedSettings as any).kimiApiKey || '',
|
||||
modelName: 'moonshot-v1-32k-vision-preview'
|
||||
};
|
||||
}
|
||||
savedSettings.aiProviders = providers;
|
||||
savedSettings.activeAiProvider = 'kimi';
|
||||
storage.set('systemSettings', savedSettings);
|
||||
}
|
||||
const savedTemplates = storage.get<Template[]>('templates', []);
|
||||
if (savedSettings.frameCount) {
|
||||
if (!savedSettings.defaultTemplate && savedTemplates.length > 0) {
|
||||
savedSettings.defaultTemplate = savedTemplates[0].id;
|
||||
}
|
||||
if (!savedSettings.frameMode) savedSettings.frameMode = 'keep';
|
||||
if (typeof savedSettings.autoInsertFrames !== 'boolean') savedSettings.autoInsertFrames = false;
|
||||
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
|
||||
if (!savedSettings.xfSpeechConfig) savedSettings.xfSpeechConfig = { appId: '', apiKey: '', apiSecret: '' };
|
||||
setSettings(savedSettings);
|
||||
} else if (savedTemplates.length > 0) {
|
||||
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'keep', autoInsertFrames: typeof prev.autoInsertFrames === 'boolean' ? prev.autoInsertFrames : false, autoInsertDelay: typeof prev.autoInsertDelay === 'number' ? prev.autoInsertDelay : 0 }));
|
||||
}
|
||||
setTemplates(savedTemplates);
|
||||
void listTemplates('use').then((response) => {
|
||||
if (response.items.length === 0) return;
|
||||
setTemplates(response.items);
|
||||
storage.set('templates', response.items);
|
||||
if (!savedSettings.defaultTemplate) {
|
||||
setSettings(prev => ({ ...prev, defaultTemplate: response.items[0]?.id || '' }));
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
void getSystemSettings().then((apiSettings) => {
|
||||
const next = normalizeSettings(apiSettings, savedTemplates);
|
||||
setSettings(next);
|
||||
storage.set('systemSettings', next);
|
||||
}).catch(() => {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
setSettings(normalizeSettings({}, savedTemplates));
|
||||
}
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
const computeFramePositions = (count: number, mode: 'uniform' | 'keep', currentPositions: number[]) => {
|
||||
if (mode === 'uniform') {
|
||||
const positions: number[] = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
positions.push(round1((100 / (count + 1)) * i));
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
const sorted = [...currentPositions].sort((a, b) => a - b);
|
||||
if (count <= sorted.length) {
|
||||
return sorted.slice(0, count);
|
||||
}
|
||||
const need = count - sorted.length;
|
||||
const last = sorted[sorted.length - 1] || 0;
|
||||
const range = 100 - last;
|
||||
for (let i = 1; i <= need; i++) {
|
||||
sorted.push(round1(last + (range / (need + 1)) * i));
|
||||
}
|
||||
return sorted;
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const sortedPositions = [...settings.framePositions].sort((a, b) => a - b);
|
||||
const finalSettings = { ...settings, framePositions: sortedPositions, frameCount: sortedPositions.length };
|
||||
try {
|
||||
const savedSettings = await updateSystemSettings(finalSettings);
|
||||
const normalized = normalizeSettings(savedSettings, templates);
|
||||
storage.set('systemSettings', normalized);
|
||||
setSettings(normalized);
|
||||
} catch (error) {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
alert(`保存失败: ${(error as Error).message}`);
|
||||
return;
|
||||
}
|
||||
console.warn('Save settings API failed, keeping local compatibility path.', error);
|
||||
storage.set('systemSettings', finalSettings);
|
||||
setSettings(finalSettings);
|
||||
}
|
||||
setIsSaved(true);
|
||||
setTimeout(() => setIsSaved(false), 3000);
|
||||
};
|
||||
|
||||
const testApi = async () => {
|
||||
try {
|
||||
const savedSettings = await updateSystemSettings(settings);
|
||||
const normalized = normalizeSettings(savedSettings, templates);
|
||||
setSettings(normalized);
|
||||
storage.set('systemSettings', normalized);
|
||||
const response = await listAiModels();
|
||||
setAvailableModels(response.models);
|
||||
if (response.models.length > 0 && !normalized.aiProviders[normalized.activeAiProvider]?.modelName) {
|
||||
const next = { ...normalized.aiProviders };
|
||||
next[normalized.activeAiProvider] = { ...next[normalized.activeAiProvider], modelName: response.models[0] };
|
||||
setSettings({ ...normalized, aiProviders: next });
|
||||
}
|
||||
alert(`连接成功!可用模型数: ${response.models.length}`);
|
||||
} catch (e: any) {
|
||||
alert(`连接失败: ${e.message}`);
|
||||
setAvailableModels([]);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefault = async () => {
|
||||
if (window.confirm('确定要恢复系统设置出厂设置吗?所有自定义配置将被清除。')) {
|
||||
const defaultSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' } = {
|
||||
frameCount: 12,
|
||||
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
|
||||
defaultTemplate: templates[0]?.id || '',
|
||||
frameMode: 'keep',
|
||||
activeAiProvider: 'kimi',
|
||||
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
||||
autoInsertFrames: true,
|
||||
autoInsertDelay: 1,
|
||||
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
|
||||
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' }
|
||||
};
|
||||
try {
|
||||
const resetSettings = await resetSystemSettings();
|
||||
const normalized = normalizeSettings(resetSettings, templates);
|
||||
setSettings(normalized);
|
||||
storage.set('systemSettings', normalized);
|
||||
} catch (error) {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
alert(`重置失败: ${(error as Error).message}`);
|
||||
return;
|
||||
}
|
||||
console.warn('Reset settings API failed, keeping local compatibility path.', error);
|
||||
setSettings(defaultSettings);
|
||||
storage.set('systemSettings', defaultSettings);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetAllData = () => {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
alert('生产模式下仅允许清理当前浏览器缓存,请联系管理员执行后端数据维护。');
|
||||
return;
|
||||
}
|
||||
if (window.confirm('确定要重置全部数据吗?这将清除所有报告、模板和用户设置。')) {
|
||||
localStorage.clear();
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-bg">
|
||||
<Sidebar />
|
||||
|
||||
<main className="flex-1 p-10 overflow-y-auto">
|
||||
<header className="flex justify-between items-center mb-10">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-main">系统设置</h1>
|
||||
<p className="text-text-muted text-sm mt-1">
|
||||
{currentUser.role === 'super' ? '配置全局参数,包括视频抽帧策略与外部 AI API 对接。' : '设置您的默认报告模板。'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSave} className="max-w-[800px] space-y-8 pb-20">
|
||||
{currentUser.role === 'super' && (
|
||||
<div className="card-minimal">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-text-main flex items-center gap-2">
|
||||
<Video size={20} className="text-accent" />
|
||||
视频抽帧配置
|
||||
</h3>
|
||||
<span className="text-[10px] font-bold bg-slate-100 text-text-muted px-2 py-1 rounded-full uppercase tracking-wider">
|
||||
当前共 {settings.framePositions.length} 帧
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">抽取帧数</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={settings.frameCount}
|
||||
onChange={(e) => {
|
||||
const count = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
|
||||
setSettings({ ...settings, frameCount: count });
|
||||
}}
|
||||
className="input-minimal bg-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPendingFrameCount(settings.frameCount);
|
||||
setModeModalOpen(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg text-xs font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">当前抽帧方式</label>
|
||||
<div className="flex items-center h-[42px]">
|
||||
<span className="text-sm text-text-main">
|
||||
{settings.frameMode === 'uniform' ? '整体均匀抽取' : '保持当前抽帧'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoInsertFrames"
|
||||
checked={settings.autoInsertFrames || false}
|
||||
onChange={(e) => setSettings({ ...settings, autoInsertFrames: e.target.checked })}
|
||||
className="w-4 h-4 accent-accent cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="autoInsertFrames" className="text-sm text-text-main cursor-pointer">开启自动帧插入</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">自动帧插入延迟 (s)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={settings.autoInsertDelay || 0}
|
||||
onChange={(e) => setSettings({ ...settings, autoInsertDelay: Math.max(0, parseFloat(e.target.value) || 0) })}
|
||||
className="input-minimal bg-white w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-text-muted">开启后,选中的视频自动抽帧位置将按顺序自动插入到报告的空置图片占位符中,插满后不再提示。</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">抽帧位置百分比 (%)</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-3">
|
||||
{settings.framePositions.map((pos, idx) => (
|
||||
<div key={idx} className="relative group">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={pos}
|
||||
onChange={(e) => {
|
||||
const newPos = [...settings.framePositions];
|
||||
newPos[idx] = Math.min(100, Math.max(0, parseFloat(e.target.value) || 0));
|
||||
setSettings({ ...settings, framePositions: newPos });
|
||||
}}
|
||||
className="input-minimal w-full pr-6 text-center"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted">%</span>
|
||||
{settings.autoInsertFrames && (
|
||||
<span
|
||||
onClick={() => {
|
||||
const current = settings.autoInsertFrameIndices || [];
|
||||
const next = current.includes(idx)
|
||||
? current.filter(i => i !== idx)
|
||||
: [...current, idx].sort((a, b) => a - b);
|
||||
setSettings({ ...settings, autoInsertFrameIndices: next });
|
||||
}}
|
||||
className={`absolute top-1 left-1 cursor-pointer transition-colors ${
|
||||
(settings.autoInsertFrameIndices || []).includes(idx) ? 'text-green-500' : 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newPos = settings.framePositions.filter((_, i) => i !== idx);
|
||||
const newIndices = (settings.autoInsertFrameIndices || [])
|
||||
.filter(i => i !== idx)
|
||||
.map(i => i > idx ? i - 1 : i);
|
||||
setSettings({ ...settings, framePositions: newPos, frameCount: newPos.length, autoInsertFrameIndices: newIndices });
|
||||
}}
|
||||
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] opacity-0 group-hover:opacity-100 transition-all shadow-sm"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newPos = [...settings.framePositions, 50];
|
||||
setSettings({ ...settings, framePositions: newPos, frameCount: newPos.length });
|
||||
}}
|
||||
className="w-full h-10 rounded-xl border-2 border-dashed border-border flex items-center justify-center text-text-muted hover:border-accent hover:text-accent hover:bg-slate-50 transition-all"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted mt-2">指定视频进度的百分比位置进行自动抽帧。系统将按照这些位置提取关键帧供 AI 分析。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentUser.role === 'super' && (
|
||||
<div className="card-minimal">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-text-main flex items-center gap-2">
|
||||
<Globe size={20} className="text-accent" />
|
||||
AI 接口集成
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={testApi}
|
||||
className="text-[10px] font-bold text-accent uppercase tracking-wider hover:underline"
|
||||
>
|
||||
测试连接
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">AI 服务商</label>
|
||||
<select
|
||||
value={settings.activeAiProvider}
|
||||
onChange={(e) => setSettings({ ...settings, activeAiProvider: e.target.value })}
|
||||
className="input-minimal bg-white"
|
||||
>
|
||||
<option value="kimi">Kimi (Moonshot)</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">接口地址 (Base URL)</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.aiProviders[settings.activeAiProvider]?.endpoint || ''}
|
||||
onChange={(e) => {
|
||||
const next = { ...settings.aiProviders };
|
||||
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], endpoint: e.target.value };
|
||||
setSettings({ ...settings, aiProviders: next });
|
||||
}}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API 密钥</label>
|
||||
<input
|
||||
ref={apiKeyInputRef}
|
||||
type="password"
|
||||
onChange={(e) => {
|
||||
const next = { ...settings.aiProviders };
|
||||
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], apiKey: e.target.value };
|
||||
setSettings({ ...settings, aiProviders: next });
|
||||
}}
|
||||
onCopy={(e) => e.preventDefault()}
|
||||
onCut={(e) => e.preventDefault()}
|
||||
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">模型名称 (Model Name)</label>
|
||||
{availableModels.length > 0 ? (
|
||||
<select
|
||||
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
|
||||
onChange={(e) => {
|
||||
const next = { ...settings.aiProviders };
|
||||
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
|
||||
setSettings({ ...settings, aiProviders: next });
|
||||
}}
|
||||
className="input-minimal bg-white"
|
||||
>
|
||||
{availableModels.map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
|
||||
onChange={(e) => {
|
||||
const next = { ...settings.aiProviders };
|
||||
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
|
||||
setSettings({ ...settings, aiProviders: next });
|
||||
}}
|
||||
placeholder="kimi-k2-5"
|
||||
className="input-minimal"
|
||||
/>
|
||||
)}
|
||||
<p className="text-[11px] text-text-muted">{availableModels.length > 0 ? '已从服务商获取可用模型列表' : '点击"测试连接"成功后,此处可下拉选择模型'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentUser.role === 'super' && (
|
||||
<div className="card-minimal">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-text-main flex items-center gap-2">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
|
||||
讯飞语音听写Websocket接口配置
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APPID</label>
|
||||
<input
|
||||
ref={xfAppIdRef}
|
||||
type="password"
|
||||
onChange={(e) => {
|
||||
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), appId: e.target.value };
|
||||
setSettings({ ...settings, xfSpeechConfig: next });
|
||||
}}
|
||||
onCopy={(e) => e.preventDefault()}
|
||||
onCut={(e) => e.preventDefault()}
|
||||
autoComplete="new-password"
|
||||
placeholder="********"
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APIKey</label>
|
||||
<input
|
||||
ref={xfApiKeyRef}
|
||||
type="password"
|
||||
onChange={(e) => {
|
||||
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), apiKey: e.target.value };
|
||||
setSettings({ ...settings, xfSpeechConfig: next });
|
||||
}}
|
||||
onCopy={(e) => e.preventDefault()}
|
||||
onCut={(e) => e.preventDefault()}
|
||||
autoComplete="new-password"
|
||||
placeholder="********************************"
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APISecret</label>
|
||||
<input
|
||||
ref={xfApiSecretRef}
|
||||
type="password"
|
||||
onChange={(e) => {
|
||||
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), apiSecret: e.target.value };
|
||||
setSettings({ ...settings, xfSpeechConfig: next });
|
||||
}}
|
||||
onCopy={(e) => e.preventDefault()}
|
||||
onCut={(e) => e.preventDefault()}
|
||||
autoComplete="new-password"
|
||||
placeholder="********************************"
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-minimal">
|
||||
<h3 className="text-lg font-bold text-text-main mb-6 flex items-center gap-2">
|
||||
<Layout size={20} className="text-accent" />
|
||||
默认报告模板
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">图文报告生成默认模板</label>
|
||||
<select
|
||||
value={settings.defaultTemplate}
|
||||
onChange={(e) => setSettings({ ...settings, defaultTemplate: e.target.value })}
|
||||
className="input-minimal bg-white"
|
||||
>
|
||||
<option value="">未设置 (手动选择)</option>
|
||||
{templates.map(tpl => (
|
||||
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-text-muted">新建报告时将自动加载此模板内容,减少重复操作。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-6 border-t border-border">
|
||||
{currentUser.role === 'super' && (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetToDefault}
|
||||
className="text-xs font-bold text-red-500 hover:text-red-600 transition-colors uppercase tracking-wider"
|
||||
>
|
||||
恢复系统设置出厂设置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAllData}
|
||||
className="text-xs font-bold text-red-500 hover:text-red-600 transition-colors uppercase tracking-wider"
|
||||
>
|
||||
重置全部数据
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
{isSaved && (
|
||||
<span className="text-xs text-green-600 font-bold flex items-center gap-1">
|
||||
<Check size={14} />
|
||||
设置已保存
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-accent px-12"
|
||||
>
|
||||
保存全局配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{modeModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-8 w-full max-w-[420px] shadow-2xl border border-border">
|
||||
<h3 className="text-lg font-bold text-text-main mb-2">选择抽帧方式</h3>
|
||||
<p className="text-sm text-text-muted mb-6">
|
||||
您将抽取帧数设置为 <strong className="text-accent">{pendingFrameCount}</strong> 帧,请选择重新计算抽帧位置的方式:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (pendingFrameCount !== null) {
|
||||
const newPositions = computeFramePositions(pendingFrameCount, 'uniform', settings.framePositions);
|
||||
setSettings({ ...settings, frameCount: pendingFrameCount, frameMode: 'uniform', framePositions: newPositions });
|
||||
}
|
||||
setModeModalOpen(false);
|
||||
setPendingFrameCount(null);
|
||||
}}
|
||||
className="px-4 py-3 border border-border rounded-xl text-sm font-semibold text-text-main hover:border-accent hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
整体均匀抽取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (pendingFrameCount !== null) {
|
||||
const newPositions = computeFramePositions(pendingFrameCount, 'keep', settings.framePositions);
|
||||
setSettings({ ...settings, frameCount: pendingFrameCount, frameMode: 'keep', framePositions: newPositions });
|
||||
}
|
||||
setModeModalOpen(false);
|
||||
setPendingFrameCount(null);
|
||||
}}
|
||||
className="px-4 py-3 border border-border rounded-xl text-sm font-semibold text-text-main hover:border-accent hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
保持当前抽帧
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setModeModalOpen(false); setPendingFrameCount(null); }}
|
||||
className="w-full px-4 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1820
src/pages/TemplateManage.tsx
Normal file
1820
src/pages/TemplateManage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
859
src/pages/UserManage.tsx
Normal file
859
src/pages/UserManage.tsx
Normal file
@@ -0,0 +1,859 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import { UserPlus, Edit, Trash2, Upload, X } from 'lucide-react';
|
||||
import { User, Template } from '../types';
|
||||
import { storage } from '../utils/storage';
|
||||
import {
|
||||
createUser,
|
||||
deleteUserFromApi,
|
||||
listDepartments,
|
||||
listUsers,
|
||||
updateUser,
|
||||
type Department,
|
||||
} from '../api/users';
|
||||
import { listTemplates } from '../api/templates';
|
||||
import { deleteUserSignature, uploadUserSignature } from '../api/files';
|
||||
import { isLocalFallbackEnabled } from '../config/runtime';
|
||||
|
||||
const ADMIN_DISABLE_AUTH_KEY = 'DISABLE_ADMIN_2024';
|
||||
|
||||
export default function UserManage() {
|
||||
const navigate = useNavigate();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [formData, setFormData] = useState<Partial<User>>({
|
||||
username: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
department: '',
|
||||
status: 'active',
|
||||
visibleTemplates: [],
|
||||
manageableTemplates: []
|
||||
});
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [authKey, setAuthKey] = useState('');
|
||||
const [allTemplates, setAllTemplates] = useState<Template[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
if (!user || (user.role !== 'super' && user.role !== 'admin')) {
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
setCurrentUser(user);
|
||||
|
||||
const savedUsers = storage.get<User[]>('users', []).filter(Boolean);
|
||||
setUsers(savedUsers);
|
||||
|
||||
const savedTemplates = storage.get<Template[]>('templates', []).filter(Boolean);
|
||||
setAllTemplates(savedTemplates);
|
||||
|
||||
Promise.allSettled([
|
||||
listUsers(),
|
||||
listDepartments(),
|
||||
listTemplates('use'),
|
||||
]).then(([usersResult, departmentsResult, templatesResult]) => {
|
||||
if (usersResult.status === 'fulfilled') {
|
||||
const apiUsers = mergeUsersWithLocalCache(usersResult.value.items, storage.get<User[]>('users', []));
|
||||
setUsers(apiUsers);
|
||||
storage.set('users', apiUsers);
|
||||
}
|
||||
if (departmentsResult.status === 'fulfilled') {
|
||||
setDepartments(departmentsResult.value.items);
|
||||
}
|
||||
if (templatesResult.status === 'fulfilled') {
|
||||
setAllTemplates(templatesResult.value.items);
|
||||
storage.set('templates', templatesResult.value.items);
|
||||
}
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
const mergeUsersWithLocalCache = (apiUsers: User[], localUsers: User[]) =>
|
||||
apiUsers.map((apiUser) => {
|
||||
const local = localUsers.find((item) => item.username === apiUser.username);
|
||||
return {
|
||||
...local,
|
||||
...apiUser,
|
||||
signature: local?.signature ?? apiUser.signature,
|
||||
password: local?.password,
|
||||
};
|
||||
});
|
||||
|
||||
const displayUsers = useMemo(() => {
|
||||
if (!currentUser) return [];
|
||||
const safeUsers = (Array.isArray(users) ? users : []).filter(Boolean);
|
||||
if (currentUser.role === 'super') return safeUsers;
|
||||
return safeUsers.filter(u => u.department === currentUser.department && (u.role === 'user' || u.username === currentUser.username));
|
||||
}, [users, currentUser]);
|
||||
|
||||
const saveToLocalStorage = (updatedUsers: User[]) => {
|
||||
setUsers(updatedUsers);
|
||||
storage.set('users', updatedUsers);
|
||||
};
|
||||
|
||||
const compressImage = (file: File, maxSize: number = 500): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.src = e.target?.result as string;
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
let { width, height } = img;
|
||||
if (width > height && width > maxSize) {
|
||||
height = Math.round((height * maxSize) / width);
|
||||
width = maxSize;
|
||||
} else if (height > maxSize) {
|
||||
width = Math.round((width * maxSize) / height);
|
||||
height = maxSize;
|
||||
}
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
}
|
||||
resolve(canvas.toDataURL('image/jpeg', 0.8));
|
||||
};
|
||||
img.onerror = reject;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSignatureUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const compressed = await compressImage(file);
|
||||
setFormData(prev => ({ ...prev, signature: compressed }));
|
||||
} catch {
|
||||
alert('图片压缩失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (user: User) => {
|
||||
if (user.username === 'admin') {
|
||||
alert('不能删除默认超级管理员');
|
||||
return;
|
||||
}
|
||||
if (user.username === currentUser?.username) {
|
||||
alert('不能删除当前登录账号');
|
||||
return;
|
||||
}
|
||||
if (window.confirm(`确定要删除用户 "${user.username}" 吗?`)) {
|
||||
try {
|
||||
await deleteUserFromApi(user.id || user.username);
|
||||
} catch (error) {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
alert(`删除失败: ${(error as Error).message}`);
|
||||
return;
|
||||
}
|
||||
console.warn('Delete user API failed, keeping local compatibility path.', error);
|
||||
}
|
||||
const updated = users.filter(u => u.username !== user.username);
|
||||
saveToLocalStorage(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
if (currentUser?.role === 'admin') {
|
||||
if ((user.role !== 'user' && user.username !== currentUser.username) || user.department !== currentUser.department) {
|
||||
alert('您只能管理同部门的医生或您自己');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setIsEditing(true);
|
||||
const allTplIds = allTemplates.map(t => t.id);
|
||||
let manageable: string[] = [];
|
||||
let visible: string[] = [];
|
||||
|
||||
if (user.role === 'super') {
|
||||
manageable = allTplIds;
|
||||
visible = allTplIds;
|
||||
} else if (user.role === 'admin') {
|
||||
manageable = Array.isArray(user.manageableTemplates) ? user.manageableTemplates : allTplIds;
|
||||
visible = (Array.isArray(user.visibleTemplates) ? user.visibleTemplates : manageable)
|
||||
.filter(id => manageable.includes(id));
|
||||
} else {
|
||||
manageable = [];
|
||||
const deptAdmin = users.find(u => u.role === 'admin' && u.department === user.department);
|
||||
const adminManageable = deptAdmin?.manageableTemplates || [];
|
||||
visible = (Array.isArray(user.visibleTemplates) ? user.visibleTemplates : [])
|
||||
.filter(id => adminManageable.includes(id));
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...user,
|
||||
password: '',
|
||||
visibleTemplates: visible,
|
||||
manageableTemplates: manageable
|
||||
});
|
||||
setConfirmPassword('');
|
||||
setAuthKey('');
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setIsEditing(false);
|
||||
const defaultDept = currentUser?.role === 'admin' ? currentUser.department || '' : '';
|
||||
const defaultRole = 'user';
|
||||
let defaultVisible: string[] = [];
|
||||
let defaultManageable: string[] = [];
|
||||
|
||||
if (currentUser?.role === 'admin') {
|
||||
defaultManageable = [];
|
||||
defaultVisible = currentUser.manageableTemplates || [];
|
||||
}
|
||||
|
||||
setFormData({
|
||||
username: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: defaultRole,
|
||||
department: defaultDept,
|
||||
status: 'active',
|
||||
visibleTemplates: defaultVisible,
|
||||
manageableTemplates: defaultManageable
|
||||
});
|
||||
setConfirmPassword('');
|
||||
setAuthKey('');
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (!formData.username) {
|
||||
alert('用户ID不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditing && formData.role === 'super') {
|
||||
alert('系统中只能存在一个超级管理员,无法新增');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditing && users.find(u => u.username === formData.username)) {
|
||||
alert('用户ID已存在');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditing && formData.password && formData.password !== confirmPassword) {
|
||||
alert('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
let finalRole = formData.role as any;
|
||||
if (currentUser?.role === 'admin') {
|
||||
if (!isEditing) finalRole = 'user';
|
||||
else if (formData.username !== currentUser.username) finalRole = 'user';
|
||||
}
|
||||
const finalDepartment = currentUser?.role === 'admin' ? currentUser.department || '' : (formData.department || '');
|
||||
|
||||
if (finalRole === 'admin') {
|
||||
const existingAdmin = users.find(u => u.role === 'admin' && u.department === finalDepartment && u.username !== formData.username);
|
||||
if (existingAdmin) {
|
||||
alert('该部门已存在管理员,一个部门只能有一个管理员');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalRole === 'user') {
|
||||
const hasAdminInDept = users.some(u => u.role === 'admin' && u.department === finalDepartment);
|
||||
if (!hasAdminInDept) {
|
||||
alert('该部门暂无管理员,请先建立一个部门管理员再创建医生');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalRole !== 'user' && formData.status === 'inactive') {
|
||||
if (authKey.trim() !== ADMIN_DISABLE_AUTH_KEY) {
|
||||
alert('禁用管理员账号需要输入正确的授权密钥');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const allTplIds = allTemplates.map(t => t.id);
|
||||
let manageableTemplates: string[] = [];
|
||||
let visibleTemplates: string[] = [];
|
||||
|
||||
if (finalRole === 'super') {
|
||||
manageableTemplates = allTplIds;
|
||||
visibleTemplates = allTplIds;
|
||||
} else if (finalRole === 'admin') {
|
||||
manageableTemplates = (formData.manageableTemplates || []).filter(id => allTplIds.includes(id));
|
||||
visibleTemplates = (formData.visibleTemplates || [])
|
||||
.filter(id => manageableTemplates.includes(id));
|
||||
} else if (finalRole === 'user') {
|
||||
manageableTemplates = [];
|
||||
const deptAdmin = users.find(u => u.role === 'admin' && u.department === finalDepartment);
|
||||
const adminManageable = deptAdmin?.manageableTemplates || [];
|
||||
visibleTemplates = (formData.visibleTemplates || [])
|
||||
.filter(id => adminManageable.includes(id));
|
||||
}
|
||||
|
||||
const oldUser = isEditing ? users.find(u => u.username === formData.username) : undefined;
|
||||
let updatedUsers: User[];
|
||||
|
||||
if (isEditing && finalRole === 'admin' && oldUser && oldUser.role === 'admin' && currentUser && currentUser.role === 'super') {
|
||||
const oldManageable = Array.isArray(oldUser.manageableTemplates) ? oldUser.manageableTemplates : allTplIds;
|
||||
const removed = oldManageable.filter(id => !manageableTemplates.includes(id));
|
||||
const added = manageableTemplates.filter(id => !oldManageable.includes(id));
|
||||
|
||||
// Ensure admin's own visible gets new templates too
|
||||
let adminVisible = [...visibleTemplates];
|
||||
added.forEach(id => {
|
||||
if (!adminVisible.includes(id)) adminVisible.push(id);
|
||||
});
|
||||
|
||||
updatedUsers = users.map(u => {
|
||||
if (u.username === formData.username) {
|
||||
return { ...u, role: finalRole, department: finalDepartment, manageableTemplates, visibleTemplates: adminVisible, password: formData.password || u.password, signature: formData.signature } as User;
|
||||
}
|
||||
if (u.role === 'user' && u.department === (oldUser.department || finalDepartment)) {
|
||||
const currentVisible = Array.isArray(u.visibleTemplates) ? u.visibleTemplates : [];
|
||||
const nextVisible = currentVisible.filter(id => !removed.includes(id));
|
||||
added.forEach(id => {
|
||||
if (!nextVisible.includes(id)) nextVisible.push(id);
|
||||
});
|
||||
return { ...u, visibleTemplates: nextVisible };
|
||||
}
|
||||
return u;
|
||||
});
|
||||
} else {
|
||||
const payload: Partial<User> = {
|
||||
...formData,
|
||||
role: finalRole,
|
||||
department: finalDepartment,
|
||||
manageableTemplates,
|
||||
visibleTemplates
|
||||
};
|
||||
if (!formData.password) {
|
||||
delete payload.password;
|
||||
}
|
||||
if (isEditing) {
|
||||
updatedUsers = users.map(u => {
|
||||
if (u.username === formData.username) {
|
||||
return { ...u, ...payload } as User;
|
||||
}
|
||||
return u;
|
||||
});
|
||||
} else {
|
||||
const newUser: User = {
|
||||
...(payload as User),
|
||||
createdAt: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
updatedUsers = [...users, newUser];
|
||||
}
|
||||
}
|
||||
|
||||
let savedApiUser: User | null = null;
|
||||
const payload = {
|
||||
...(updatedUsers.find(u => u.username === formData.username) || formData),
|
||||
departmentId: departments.find(dept => dept.name === finalDepartment)?.id || formData.departmentId,
|
||||
} as User;
|
||||
|
||||
try {
|
||||
savedApiUser = isEditing
|
||||
? await updateUser(oldUser?.id || formData.id || formData.username || '', payload)
|
||||
: await createUser(payload);
|
||||
} catch (error: any) {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
alert(`后端保存失败:${error?.message || String(error)}`);
|
||||
return;
|
||||
}
|
||||
console.warn('Save user API failed, keeping local compatibility path.', error);
|
||||
if (error?.message) {
|
||||
alert(`后端保存失败,已保留本地修改:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedApiUser) {
|
||||
if (formData.signature?.startsWith('data:')) {
|
||||
try {
|
||||
const signatureFile = await uploadUserSignature(savedApiUser.id || savedApiUser.username, formData.signature);
|
||||
savedApiUser = {
|
||||
...savedApiUser,
|
||||
signatureFileId: signatureFile.id,
|
||||
signature: signatureFile.url,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
alert(`签名上传失败:${error?.message || String(error)}`);
|
||||
return;
|
||||
}
|
||||
console.warn('Upload signature API failed, keeping local signature cache.', error);
|
||||
alert(`签名上传到后端失败,已保留本地签名:${error?.message || String(error)}`);
|
||||
}
|
||||
} else if (oldUser?.signature && !formData.signature) {
|
||||
try {
|
||||
await deleteUserSignature(savedApiUser.id || savedApiUser.username);
|
||||
savedApiUser = {
|
||||
...savedApiUser,
|
||||
signatureFileId: undefined,
|
||||
signature: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isLocalFallbackEnabled()) {
|
||||
alert(`签名删除失败:${(error as Error).message}`);
|
||||
return;
|
||||
}
|
||||
console.warn('Delete signature API failed, keeping local compatibility path.', error);
|
||||
}
|
||||
}
|
||||
|
||||
const localSignature = formData.signature;
|
||||
const mergedSavedUser: User = {
|
||||
...oldUser,
|
||||
...savedApiUser,
|
||||
signature: savedApiUser.signature ?? localSignature,
|
||||
password: formData.password || oldUser?.password,
|
||||
};
|
||||
updatedUsers = isEditing
|
||||
? users.map(u => u.username === mergedSavedUser.username ? mergedSavedUser : u)
|
||||
: [...users, mergedSavedUser];
|
||||
}
|
||||
|
||||
saveToLocalStorage(updatedUsers);
|
||||
// 如果编辑的是当前登录用户,同步更新 currentUser
|
||||
const currentCached = updatedUsers.find(u => u.username === currentUser?.username);
|
||||
if (currentCached) {
|
||||
storage.set('currentUser', currentCached);
|
||||
setCurrentUser(currentCached);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
} catch (err: any) {
|
||||
alert('保存失败: ' + (err?.message || String(err)));
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTemplate = (templateId: string, field: 'visibleTemplates' | 'manageableTemplates') => {
|
||||
const current = (formData[field] || []) as string[];
|
||||
if (current.includes(templateId)) {
|
||||
setFormData({ ...formData, [field]: current.filter(id => id !== templateId) });
|
||||
} else {
|
||||
setFormData({ ...formData, [field]: [...current, templateId] });
|
||||
}
|
||||
};
|
||||
|
||||
// Real-time sync: when manageableTemplates changes for admin, ensure visibleTemplates stay within it
|
||||
useEffect(() => {
|
||||
if (!isModalOpen || !currentUser) return;
|
||||
const isAdminEditingSelf = currentUser.role === 'admin' && formData.username === currentUser.username;
|
||||
const isManageableReadonly = formData.role === 'super' || isAdminEditingSelf;
|
||||
if (formData.role === 'admin' && !isManageableReadonly) {
|
||||
const m = formData.manageableTemplates || [];
|
||||
const prevVisible = formData.visibleTemplates || [];
|
||||
const nextVisible = prevVisible.filter(id => m.includes(id));
|
||||
if (nextVisible.length !== prevVisible.length) {
|
||||
setFormData(prev => ({ ...prev, visibleTemplates: nextVisible }));
|
||||
}
|
||||
}
|
||||
}, [formData.manageableTemplates, formData.role, isModalOpen, currentUser, formData.username]);
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
const roleNames = {
|
||||
'super': '超级管理员',
|
||||
'admin': '管理员',
|
||||
'user': '医生'
|
||||
};
|
||||
|
||||
const isAdminLogin = currentUser.role === 'admin';
|
||||
const needAuthKey = formData.role !== 'user' && formData.status === 'inactive';
|
||||
|
||||
// 模板权限显示规则
|
||||
const isSuperEditingAdmin = currentUser.role === 'super' && formData.role === 'admin';
|
||||
const isSuperEditingUser = currentUser.role === 'super' && formData.role === 'user';
|
||||
const isAdminEditingSelf = currentUser.role === 'admin' && formData.username === currentUser.username;
|
||||
const isAdminEditingUser = currentUser.role === 'admin' && formData.role === 'user';
|
||||
|
||||
const showManageableTemplates = isSuperEditingAdmin || isAdminEditingSelf || formData.role === 'super';
|
||||
const isManageableReadonly = formData.role === 'super' || isAdminEditingSelf;
|
||||
const showVisibleTemplates = true;
|
||||
|
||||
// 可视模板候选
|
||||
let visibleCandidates = allTemplates;
|
||||
if (isSuperEditingAdmin) {
|
||||
visibleCandidates = allTemplates.filter(t => (formData.manageableTemplates || []).includes(t.id));
|
||||
} else if (isSuperEditingUser) {
|
||||
const deptAdmin = users.find(u => u.role === 'admin' && u.department === formData.department);
|
||||
const adminManageable = deptAdmin?.manageableTemplates || [];
|
||||
visibleCandidates = allTemplates.filter(t => adminManageable.includes(t.id));
|
||||
} else if (isAdminEditingSelf) {
|
||||
const adminManageable = currentUser.manageableTemplates || [];
|
||||
visibleCandidates = allTemplates.filter(t => adminManageable.includes(t.id));
|
||||
} else if (isAdminEditingUser) {
|
||||
const adminManageable = currentUser.manageableTemplates || [];
|
||||
visibleCandidates = allTemplates.filter(t => adminManageable.includes(t.id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-bg">
|
||||
<Sidebar />
|
||||
|
||||
<main className="flex-1 p-10 overflow-y-auto">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-main">用户管理</h1>
|
||||
<p className="text-text-muted text-sm mt-1">
|
||||
{isAdminLogin ? '管理同部门的医生用户' : '管理系统用户,仅超级管理员可赋予管理员权限'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="btn-accent inline-flex items-center gap-2"
|
||||
>
|
||||
<UserPlus size={18} />
|
||||
新增用户
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="card-minimal p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50">
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">用户ID</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">姓名</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">邮箱</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">联系电话</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">角色</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">部门</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">签名状态</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">状态</th>
|
||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{displayUsers.map((user) => (
|
||||
<tr key={user.username} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="px-6 py-4 text-sm text-text-main font-mono">{user.username}</td>
|
||||
<td className="px-6 py-4 text-sm text-text-main font-semibold">{user.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-text-main">{user.email || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm text-text-main">{user.phone || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
||||
user.role === 'super' ? 'bg-amber-100 text-amber-700' :
|
||||
user.role === 'admin' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{roleNames[user.role]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-text-main">{user.department || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
||||
user.signature ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{user.signature ? '已上传' : '未上传'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
||||
user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{user.status === 'active' ? '启用' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleEdit(user)}
|
||||
className="p-2 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
{user.username !== 'admin' && user.username !== currentUser.username && (
|
||||
<button
|
||||
onClick={() => handleDelete(user)}
|
||||
className="p-2 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-10 w-full max-w-[500px] max-h-[90vh] overflow-y-auto shadow-2xl border border-border">
|
||||
<h3 className="text-xl font-bold text-text-main mb-2">{isEditing ? '编辑用户' : '新增用户'}</h3>
|
||||
<p className="text-sm text-text-muted mb-8">填写用户信息并分配系统权限。</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">用户ID *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
disabled={isEditing}
|
||||
required
|
||||
className="input-minimal disabled:bg-slate-50 disabled:text-text-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">姓名 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">电话</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">
|
||||
{isEditing ? '修改密码 (留空则不修改)' : '密码 *'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required={!isEditing}
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditing && formData.password && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">确认新密码 *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">角色 *</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => {
|
||||
const newRole = e.target.value as any;
|
||||
const allTplIds = allTemplates.map(t => t.id);
|
||||
setFormData({
|
||||
...formData,
|
||||
role: newRole,
|
||||
manageableTemplates: newRole === 'user' ? [] : allTplIds,
|
||||
visibleTemplates: newRole === 'user' ? [] : allTplIds
|
||||
});
|
||||
}}
|
||||
required
|
||||
disabled={isAdminLogin || (isEditing && formData.username === 'admin')}
|
||||
className="input-minimal bg-white disabled:bg-slate-50 disabled:text-text-muted"
|
||||
>
|
||||
<option value="user">医生</option>
|
||||
{!isAdminLogin && <option value="admin">管理员</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">部门 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.department}
|
||||
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
|
||||
disabled={isAdminLogin}
|
||||
required
|
||||
className="input-minimal disabled:bg-slate-50 disabled:text-text-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">状态</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||
className="input-minimal bg-white"
|
||||
>
|
||||
<option value="active">启用</option>
|
||||
<option value="inactive">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{needAuthKey && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">授权密钥 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authKey}
|
||||
onChange={(e) => setAuthKey(e.target.value)}
|
||||
required
|
||||
placeholder="请输入授权密钥以禁用管理员"
|
||||
className="input-minimal"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted">禁用管理员账号需要输入系统授权密钥进行验证。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">电子签名</label>
|
||||
{formData.signature ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={formData.signature}
|
||||
alt="电子签名预览"
|
||||
className="h-16 border border-border rounded bg-white object-contain"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="px-3 py-1.5 text-xs font-medium rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors cursor-pointer inline-flex items-center gap-1">
|
||||
<Upload size={12} />
|
||||
重新上传
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleSignatureUpload} />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, signature: undefined }))}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<X size={12} />
|
||||
清除签名
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="px-4 py-2 text-sm font-medium rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors cursor-pointer inline-flex items-center gap-2">
|
||||
<Upload size={14} />
|
||||
上传签名
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleSignatureUpload} />
|
||||
</label>
|
||||
<span className="text-xs text-text-muted">支持 JPG、PNG,自动压缩至 500px 以内</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showManageableTemplates && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">
|
||||
{formData.role === 'super' ? '可管理模板' : '可管理模板'}
|
||||
</label>
|
||||
<div className="max-h-[150px] overflow-y-auto border border-border rounded-lg p-3 space-y-2 bg-slate-50">
|
||||
{allTemplates.map(tpl => (
|
||||
<label key={tpl.id} className={`flex items-center gap-2 p-1 rounded-md transition-colors ${isManageableReadonly ? '' : 'cursor-pointer hover:bg-white'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(formData.manageableTemplates || []).includes(tpl.id)}
|
||||
onChange={() => toggleTemplate(tpl.id, 'manageableTemplates')}
|
||||
disabled={isManageableReadonly}
|
||||
className="w-4 h-4 rounded-sm border-border text-accent focus:ring-accent disabled:opacity-50"
|
||||
/>
|
||||
<span className={`text-sm ${isManageableReadonly ? 'text-text-muted' : 'text-text-main'}`}>{tpl.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{allTemplates.length === 0 && <p className="text-xs text-text-muted italic">暂无模板</p>}
|
||||
</div>
|
||||
{isManageableReadonly && (
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{formData.role === 'super' ? '超级管理员默认可管理所有模板,不可更改。' : '管理员的模板管理权限由超级管理员设定,不可自行更改。'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showVisibleTemplates && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">
|
||||
{formData.role === 'super' ? '可视模板 (全部)' : '可视模板'}
|
||||
</label>
|
||||
<div className="max-h-[150px] overflow-y-auto border border-border rounded-lg p-3 space-y-2 bg-slate-50">
|
||||
{visibleCandidates.map(tpl => (
|
||||
<label key={tpl.id} className="flex items-center gap-2 cursor-pointer hover:bg-white p-1 rounded-md transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(formData.visibleTemplates || []).includes(tpl.id)}
|
||||
onChange={() => toggleTemplate(tpl.id, 'visibleTemplates')}
|
||||
disabled={formData.role === 'super'}
|
||||
className="w-4 h-4 rounded-sm border-border text-accent focus:ring-accent disabled:opacity-50"
|
||||
/>
|
||||
<span className={`text-sm ${formData.role === 'super' ? 'text-text-muted' : 'text-text-main'}`}>{tpl.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{visibleCandidates.length === 0 && <p className="text-xs text-text-muted italic">暂无可视模板</p>}
|
||||
</div>
|
||||
{formData.role === 'super' && (
|
||||
<p className="text-[10px] text-text-muted">超级管理员默认可视所有模板。</p>
|
||||
)}
|
||||
{(isSuperEditingAdmin || isAdminEditingSelf) && (
|
||||
<p className="text-[10px] text-text-muted">管理员的可视模板只能从其可管理模板中选择。</p>
|
||||
)}
|
||||
{(isSuperEditingUser || isAdminEditingUser) && (
|
||||
<p className="text-[10px] text-text-muted">医生的可视模板只能从其部门管理员的可管理模板中选择。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-accent"
|
||||
>
|
||||
保存用户
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/test/setup.ts
Normal file
59
src/test/setup.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach, beforeEach, vi } from 'vitest';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
|
||||
if (url.endsWith('/logo_square.png')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'image/png' }),
|
||||
blob: () => Promise.resolve(new Blob(['test-image'], { type: 'image/png' })),
|
||||
text: () => Promise.resolve(''),
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
blob: () => Promise.resolve(new Blob([])),
|
||||
text: () => Promise.resolve(JSON.stringify({ error: { code: 'UNAUTHORIZED', message: '未登录' } })),
|
||||
json: () => Promise.resolve({ error: { code: 'UNAUTHORIZED', message: '未登录' } }),
|
||||
});
|
||||
}));
|
||||
|
||||
vi.stubGlobal('alert', vi.fn());
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
|
||||
if (!document.execCommand) {
|
||||
document.execCommand = vi.fn();
|
||||
} else {
|
||||
vi.spyOn(document, 'execCommand').mockImplementation(() => true);
|
||||
}
|
||||
|
||||
if (!URL.createObjectURL) {
|
||||
URL.createObjectURL = vi.fn(() => 'blob:test-url');
|
||||
} else {
|
||||
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:test-url');
|
||||
}
|
||||
|
||||
if (!URL.revokeObjectURL) {
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
} else {
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
181
src/types.ts
Normal file
181
src/types.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
export interface User {
|
||||
id?: string;
|
||||
username: string;
|
||||
password?: string;
|
||||
role: 'super' | 'admin' | 'user';
|
||||
name: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
departmentId?: string;
|
||||
department?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
signatureFileId?: string;
|
||||
visibleTemplates?: string[];
|
||||
manageableTemplates?: string[];
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
title: string;
|
||||
patientName: string;
|
||||
hospitalId: string;
|
||||
patientGender?: string;
|
||||
patientAge?: string;
|
||||
department?: string;
|
||||
bedNumber?: string;
|
||||
surgeryDate?: string;
|
||||
startHour?: string;
|
||||
startMinute?: string;
|
||||
endHour?: string;
|
||||
endMinute?: string;
|
||||
surgeon?: string[];
|
||||
assistant?: string[];
|
||||
anesthesiologist?: string[];
|
||||
anesthesiaType?: string;
|
||||
reportNote?: string;
|
||||
content: string;
|
||||
videos?: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
duration: number;
|
||||
fileId?: string;
|
||||
}[];
|
||||
capturedFrames?: CapturedFrame[];
|
||||
author: string;
|
||||
authorName: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
status: 'draft' | 'completed';
|
||||
revision?: number;
|
||||
history?: { content: string; updatedAt: string; updatedBy: string; action: 'save_draft' | 'complete_report'; revision?: number }[];
|
||||
}
|
||||
|
||||
export interface CapturedFrame {
|
||||
id: number;
|
||||
videoIndex: number;
|
||||
videoName: string;
|
||||
time: number;
|
||||
timeFormatted: string;
|
||||
dataUrl: string;
|
||||
fileId?: string;
|
||||
isManual?: boolean;
|
||||
manualOrder?: number;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
desc?: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
author: string;
|
||||
fields?: FormField[];
|
||||
scope?: 'department' | 'personal';
|
||||
ownerUser?: string;
|
||||
department?: string;
|
||||
}
|
||||
|
||||
export interface AiProviderConfig {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
export interface XfSpeechConfig {
|
||||
appId: string;
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_XF_SPEECH: XfSpeechConfig = {
|
||||
appId: '',
|
||||
apiKey: '',
|
||||
apiSecret: ''
|
||||
};
|
||||
|
||||
export interface SystemSettings {
|
||||
frameCount: number;
|
||||
framePositions: number[];
|
||||
defaultTemplate?: string;
|
||||
frameMode?: 'uniform' | 'keep';
|
||||
autoInsertFrames?: boolean;
|
||||
autoInsertFrameIndices?: number[];
|
||||
autoInsertDelay?: number;
|
||||
activeAiProvider: string;
|
||||
aiProviders: Record<string, AiProviderConfig>;
|
||||
xfSpeechConfig?: XfSpeechConfig;
|
||||
}
|
||||
|
||||
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
|
||||
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-32k-vision-preview' },
|
||||
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
|
||||
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
|
||||
custom: { endpoint: '', apiKey: '', modelName: '' }
|
||||
};
|
||||
|
||||
export interface BindableField {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const BINDABLE_FIELDS: BindableField[] = [
|
||||
{ key: 'patientName', label: '姓名' },
|
||||
{ key: 'patientGender', label: '性别' },
|
||||
{ key: 'patientAge', label: '年龄' },
|
||||
{ key: 'department', label: '科别' },
|
||||
{ key: 'bedNumber', label: '床号' },
|
||||
{ key: 'hospitalId', label: '住院号' },
|
||||
{ key: 'surgeryDate', label: '手术日期' },
|
||||
{ key: 'title', label: '手术名称' },
|
||||
{ key: 'startTime', label: '手术开始时间' },
|
||||
{ key: 'endTime', label: '手术终止时间' },
|
||||
{ key: 'surgeon', label: '手术者' },
|
||||
{ key: 'assistant', label: '助手' },
|
||||
{ key: 'anesthesiologist', label: '麻醉师' },
|
||||
{ key: 'anesthesiaType', label: '麻醉方式' },
|
||||
];
|
||||
|
||||
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date' | 'signature' | 'image';
|
||||
|
||||
export interface FormField {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
type: FieldType;
|
||||
visibleInForm: boolean;
|
||||
isSystemLocked: boolean;
|
||||
options?: string[];
|
||||
timeFormat?: string;
|
||||
timeDefault?: 'current' | 'specific';
|
||||
fixedTimeValue?: string;
|
||||
hasUnderline?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FORM_FIELDS: FormField[] = [
|
||||
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true, hasUnderline: false },
|
||||
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true, hasUnderline: false },
|
||||
{ key: 'title', label: '手术名称', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'patientGender', label: '患者性别', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['男', '女'] },
|
||||
{ key: 'patientAge', label: '患者年龄', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'department', label: '科别', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'bedNumber', label: '床号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'surgeryDate', label: '手术日期', category: '时间', type: 'date', visibleInForm: true, isSystemLocked: true, timeFormat: 'YYYY-MM-DD', timeDefault: 'specific' },
|
||||
{ key: 'startTime', label: '手术开始时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: true, timeFormat: 'HH:mm', timeDefault: 'specific' },
|
||||
{ key: 'endTime', label: '手术终止时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: true, timeFormat: 'HH:mm', timeDefault: 'specific' },
|
||||
{ key: 'reportDate', label: '撰写时间', category: '时间', type: 'date', visibleInForm: true, isSystemLocked: true, timeFormat: 'YYYY年MM月DD日', timeDefault: 'current' },
|
||||
{ key: 'surgeon', label: '手术者', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['张医生', '李医生', '王医生'] },
|
||||
{ key: 'assistant', label: '助手', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['赵医生', '钱医生', '孙医生'] },
|
||||
{ key: 'anesthesiologist', label: '麻醉师', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['周医生', '吴医生', '郑医生'] },
|
||||
{ key: 'anesthesiaType', label: '麻醉方式', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
|
||||
{ key: 'preoperativeDiagnosis', label: '术前诊断', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },
|
||||
{ key: 'postoperativeDiagnosis', label: '术后诊断', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },
|
||||
{ key: 'postOpCondition', label: '手术后情况', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['患者麻醉恢复后安返病房'] },
|
||||
{ key: 'specimenDescription', label: '切除标本描述', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊一枚,壁厚约0.3cm,内含数枚结石'] },
|
||||
{ key: 'pathologyCheck', label: '是否送病理检查', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['是', '否'] },
|
||||
{ key: 'frozenPathology', label: '冰冻病理结果', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['未见恶性', '待石蜡'] },
|
||||
];
|
||||
48
src/utils/defaultContent.test.ts
Normal file
48
src/utils/defaultContent.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BINDABLE_FIELDS, DEFAULT_AI_PROVIDERS, DEFAULT_FORM_FIELDS } from '../types';
|
||||
import { defaultReportContent } from './defaultContent';
|
||||
|
||||
describe('default content and type contracts', () => {
|
||||
it('ships a report template with smart fields, image placeholders and an AI region', () => {
|
||||
expect(defaultReportContent).toContain('data-bind="patientName"');
|
||||
expect(defaultReportContent).toContain('data-bind="hospitalId"');
|
||||
expect(defaultReportContent).toContain('class="image-placeholder"');
|
||||
expect(defaultReportContent).toContain('data-mode="frame"');
|
||||
expect(defaultReportContent).toContain('data-mode="manual"');
|
||||
expect(defaultReportContent).toContain('class="ai-region"');
|
||||
expect(defaultReportContent).toContain('data-ai-id="手术步骤"');
|
||||
});
|
||||
|
||||
it('defines the required built-in report fields used by reports and exports', () => {
|
||||
const keys = DEFAULT_FORM_FIELDS.map((field) => field.key);
|
||||
|
||||
expect(keys).toEqual(expect.arrayContaining([
|
||||
'patientName',
|
||||
'hospitalId',
|
||||
'title',
|
||||
'surgeryDate',
|
||||
'startTime',
|
||||
'endTime',
|
||||
'surgeon',
|
||||
'assistant',
|
||||
'anesthesiologist',
|
||||
'anesthesiaType',
|
||||
]));
|
||||
expect(DEFAULT_FORM_FIELDS.filter((field) => field.isSystemLocked).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps bindable fields aligned with form field keys where applicable', () => {
|
||||
const formKeys = new Set(DEFAULT_FORM_FIELDS.map((field) => field.key));
|
||||
const specialBindableKeys = new Set(['startTime', 'endTime']);
|
||||
|
||||
for (const field of BINDABLE_FIELDS) {
|
||||
expect(formKeys.has(field.key) || specialBindableKeys.has(field.key)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('declares OpenAI-compatible provider presets', () => {
|
||||
expect(Object.keys(DEFAULT_AI_PROVIDERS)).toEqual(['kimi', 'deepseek', 'openai', 'custom']);
|
||||
expect(DEFAULT_AI_PROVIDERS.kimi.endpoint).toContain('moonshot');
|
||||
expect(DEFAULT_AI_PROVIDERS.openai.endpoint).toContain('/v1');
|
||||
});
|
||||
});
|
||||
143
src/utils/defaultContent.ts
Normal file
143
src/utils/defaultContent.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
const smartField = (key: string) => {
|
||||
return `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value no-underline" data-bind="${key}" contenteditable="true" style="min-width:24px;padding:0 2px;margin:0;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:inherit;font-size:inherit;vertical-align:baseline;box-sizing:border-box;outline:none;text-align:center;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
||||
};
|
||||
|
||||
export const defaultReportContent = `
|
||||
<div style="display: flex; justify-content: center; align-items: center; gap: 12px; margin-bottom: 4px;">
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:65px;height:65px;line-height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;transform:translate(-5px,-5px);">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">LOGO</span>
|
||||
</span>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 14pt; font-family: SimSun; border-bottom: 1px solid #000; padding-bottom: 1px; margin-bottom: 2px; display: inline-block; line-height: 1;">西 安 交 通 大 学 第 一 附 属 医 院</div>
|
||||
<div style="font-size: 16pt; font-family: SimSun;">手术记录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="font-family: SimSun; font-size: 11pt; font-weight: normal; margin: 0; padding: 0; line-height: 1; border-bottom: 1px solid #000;">
|
||||
姓名:${smartField('patientName')}
|
||||
性别:${smartField('patientGender')}
|
||||
年龄:${smartField('patientAge')}
|
||||
科别:${smartField('department')}
|
||||
床号:${smartField('bedNumber')}
|
||||
住院号:${smartField('hospitalId')}
|
||||
</p>
|
||||
|
||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||
<strong>手术日期:</strong>${smartField('surgeryDate')}
|
||||
</p>
|
||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||
<strong>术前诊断:</strong>${smartField('preoperativeDiagnosis')}
|
||||
</p>
|
||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||
<strong>术中诊断:</strong>${smartField('postoperativeDiagnosis')}
|
||||
</p>
|
||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||
<strong>手术名称:</strong>${smartField('title')}
|
||||
</p>
|
||||
|
||||
<table style="width: 100%; border: none; font-family: SimSun; font-size: 12pt; margin-top: 0; margin-bottom: 0;">
|
||||
<tr>
|
||||
<td style="border: none; padding: 0; width: 50%; line-height: 1.5;">手术开始时间:${smartField('startTime')}</td>
|
||||
<td style="border: none; padding: 0; width: 50%; line-height: 1.5;">手术终止时间:${smartField('endTime')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border: none; padding: 0; line-height: 1.5;">手术者:${smartField('surgeon')}</td>
|
||||
<td style="border: none; padding: 0; line-height: 1.5;">助手:${smartField('assistant')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border: none; padding: 0; line-height: 1.5;">麻醉师:${smartField('anesthesiologist')}</td>
|
||||
<td style="border: none; padding: 0; line-height: 1.5;">麻醉方式:${smartField('anesthesiaType')}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||
<strong>手术步骤、术中出现的情况及处理:</strong>
|
||||
</p>
|
||||
|
||||
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤、术中出现的情况及处理" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;">
|
||||
<div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none;">手术步骤、术中出现的情况及处理-AI可编辑区域</div>
|
||||
<div class="ai-content" style="min-height: 20px;"><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">1.患者仰卧位,麻醉成功后,常规消毒术野、铺无菌巾,于脐下穿刺建立CO2气腹,气腹压力为12mmHg,进镜探查无穿刺损伤,分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">2.腹腔镜探查:腹腔内无腹水形成,无明显粘连,肝脏色红质软,无明显结节硬化改变,胆囊大小约 cm× cm× cm,壁轻度水肿,张力可,胆囊三角解剖关系清楚,胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断:胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">3.切除胆囊:钳夹胆囊颈部并解剖胆囊三角,游离出胆囊动脉及胆囊管,明确胆囊与胆总管的关系,距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml,术中输血成分,输血量,是否有输血不良反应。</p></div>
|
||||
</div>
|
||||
|
||||
<!-- 手术图片说明表格 -->
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
|
||||
<tbody><tr>
|
||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图A 腹腔镜探查</p>
|
||||
</td>
|
||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图B 胆囊管夹闭与离断</p>
|
||||
</td>
|
||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图C 胆囊动脉夹闭与离断</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图D 胆囊剥离与床面止血</p>
|
||||
</td>
|
||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图E 胆囊取出与钛夹确认</p>
|
||||
</td>
|
||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图F 止血材料覆盖及检查</p>
|
||||
</td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
|
||||
<div class="template-info-section">
|
||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||
<strong>手术后情况</strong>:${smartField('postOpCondition')}
|
||||
</p>
|
||||
|
||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||
<strong>切除标本描述</strong>:${smartField('specimenDescription')}
|
||||
</p>
|
||||
|
||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||
<strong>是否送病理检查</strong>:${smartField('pathologyCheck')}
|
||||
</p>
|
||||
|
||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||
<strong>冰冻病理结果</strong>:${smartField('frozenPathology')}
|
||||
</p>
|
||||
|
||||
<p style="text-align: right; font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0; white-space: nowrap;">
|
||||
手术者签名:<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:200px;height:40px;line-height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span></span>
|
||||
</p>
|
||||
|
||||
<p style="margin: 0; padding: 0; line-height: 1.5;"> </p>
|
||||
|
||||
<p style="text-align: right; font-family: SimSun; line-height: 1.5; margin: 0; padding: 0;">
|
||||
${smartField('reportDate')}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Backward compatibility alias
|
||||
export const defaultContent = defaultReportContent;
|
||||
54
src/utils/permissions.test.ts
Normal file
54
src/utils/permissions.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Report, Template, User } from '../types';
|
||||
import { canEditReport, canUseTemplate, getAccessibleReports, getUsableTemplates } from './permissions';
|
||||
|
||||
const doctor: User = { username: '0001', role: 'user', name: '张医生', department: '外科', visibleTemplates: ['dept_tpl'] };
|
||||
const admin: User = { username: 'manager', role: 'admin', name: '管理员', department: '外科', visibleTemplates: ['dept_tpl'], manageableTemplates: ['dept_tpl'] };
|
||||
const superUser: User = { username: 'admin', role: 'super', name: '超级管理员', department: 'admin' };
|
||||
|
||||
const baseReport = {
|
||||
patientName: '患者',
|
||||
hospitalId: 'H001',
|
||||
content: '<p>报告</p>',
|
||||
authorName: '医生',
|
||||
createdAt: '2026-05-01',
|
||||
status: 'completed' as const,
|
||||
};
|
||||
|
||||
describe('permission helpers', () => {
|
||||
it('filters report visibility by role, department and author', () => {
|
||||
const reports: Report[] = [
|
||||
{ ...baseReport, id: 'RPT_1', title: '本人外科报告', author: '0001', department: '外科' },
|
||||
{ ...baseReport, id: 'RPT_2', title: '他人外科报告', author: '0002', department: '外科' },
|
||||
{ ...baseReport, id: 'RPT_3', title: '内科报告', author: '0003', department: '内科' },
|
||||
];
|
||||
|
||||
expect(getAccessibleReports(doctor, reports).map(r => r.id)).toEqual(['RPT_1']);
|
||||
expect(getAccessibleReports(admin, reports).map(r => r.id)).toEqual(['RPT_1', 'RPT_2']);
|
||||
expect(getAccessibleReports(superUser, reports).map(r => r.id)).toEqual(['RPT_1', 'RPT_2', 'RPT_3']);
|
||||
});
|
||||
|
||||
it('allows admins to edit department reports and doctors to edit their own completed reports', () => {
|
||||
const ownCompleted: Report = { ...baseReport, id: 'RPT_1', title: '本人报告', author: '0001', department: '外科' };
|
||||
const deptCompleted: Report = { ...baseReport, id: 'RPT_2', title: '本部门报告', author: '0002', department: '外科' };
|
||||
const otherDept: Report = { ...baseReport, id: 'RPT_3', title: '其他部门报告', author: '0003', department: '内科' };
|
||||
|
||||
expect(canEditReport(doctor, ownCompleted)).toBe(true);
|
||||
expect(canEditReport(doctor, deptCompleted)).toBe(false);
|
||||
expect(canEditReport(admin, deptCompleted)).toBe(true);
|
||||
expect(canEditReport(admin, otherDept)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns department templates and personal templates for doctors', () => {
|
||||
const templates: Template[] = [
|
||||
{ id: 'dept_tpl', name: '外科模板', content: '', createdAt: '', author: 'admin', scope: 'department', department: '外科' },
|
||||
{ id: 'other_dept_tpl', name: '内科模板', content: '', createdAt: '', author: 'admin', scope: 'department', department: '内科' },
|
||||
{ id: 'personal_tpl', name: '我的模板', content: '', createdAt: '', author: '0001', scope: 'personal', ownerUser: '0001' },
|
||||
{ id: 'other_personal_tpl', name: '他人模板', content: '', createdAt: '', author: '0002', scope: 'personal', ownerUser: '0002' },
|
||||
];
|
||||
|
||||
expect(getUsableTemplates(doctor, templates).map(t => t.id)).toEqual(['dept_tpl', 'personal_tpl']);
|
||||
expect(canUseTemplate(doctor, templates[3])).toBe(false);
|
||||
expect(getUsableTemplates(superUser, templates).map(t => t.id)).toEqual(['dept_tpl', 'other_dept_tpl', 'personal_tpl', 'other_personal_tpl']);
|
||||
});
|
||||
});
|
||||
53
src/utils/permissions.ts
Normal file
53
src/utils/permissions.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Report, Template, User } from '../types';
|
||||
|
||||
const getReportDepartment = (report: Report, users: User[] = []) => {
|
||||
if (report.department) return report.department;
|
||||
return users.find(user => user.username === report.author)?.department || '';
|
||||
};
|
||||
|
||||
export const canViewReport = (user: User, report: Report, users: User[] = []) => {
|
||||
if (user.role === 'super') return true;
|
||||
if (user.role === 'admin') {
|
||||
return !!user.department && getReportDepartment(report, users) === user.department;
|
||||
}
|
||||
return report.author === user.username;
|
||||
};
|
||||
|
||||
export const canEditReport = canViewReport;
|
||||
|
||||
export const canDeleteReport = canViewReport;
|
||||
|
||||
export const getAccessibleReports = (user: User, reports: Report[], users: User[] = []) => {
|
||||
return reports.filter(report => canViewReport(user, report, users));
|
||||
};
|
||||
|
||||
export const isPersonalTemplate = (template: Template) => template.scope === 'personal' || !!template.ownerUser;
|
||||
|
||||
export const isDepartmentTemplate = (template: Template) => !isPersonalTemplate(template);
|
||||
|
||||
export const canUseTemplate = (user: User, template: Template) => {
|
||||
if (user.role === 'super') return true;
|
||||
if (isPersonalTemplate(template)) return template.ownerUser === user.username;
|
||||
if (template.department && user.department && template.department !== user.department) return false;
|
||||
const visible = Array.isArray(user.visibleTemplates) ? user.visibleTemplates : [];
|
||||
return visible.length === 0 || visible.includes(template.id);
|
||||
};
|
||||
|
||||
export const canManageTemplate = (user: User, template: Template) => {
|
||||
if (user.role === 'super') return true;
|
||||
if (isPersonalTemplate(template)) return template.ownerUser === user.username;
|
||||
if (user.role === 'admin') {
|
||||
if (template.department && user.department && template.department !== user.department) return false;
|
||||
const manageable = Array.isArray(user.manageableTemplates) ? user.manageableTemplates : [];
|
||||
return manageable.length === 0 || manageable.includes(template.id);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getUsableTemplates = (user: User, templates: Template[]) => {
|
||||
return templates.filter(template => canUseTemplate(user, template));
|
||||
};
|
||||
|
||||
export const getManageableTemplates = (user: User, templates: Template[]) => {
|
||||
return templates.filter(template => canManageTemplate(user, template));
|
||||
};
|
||||
44
src/utils/print.test.ts
Normal file
44
src/utils/print.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { printDocument } from './print';
|
||||
|
||||
describe('printDocument', () => {
|
||||
it('writes report HTML into a hidden iframe and invokes browser print', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const print = vi.fn();
|
||||
const focus = vi.fn();
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName: string, options?: ElementCreationOptions) => {
|
||||
const element = originalCreateElement(tagName, options);
|
||||
if (tagName.toLowerCase() === 'iframe') {
|
||||
const iframeDocument = document.implementation.createHTMLDocument('');
|
||||
Object.defineProperty(element, 'contentDocument', {
|
||||
configurable: true,
|
||||
value: iframeDocument,
|
||||
});
|
||||
Object.defineProperty(element, 'contentWindow', {
|
||||
configurable: true,
|
||||
value: { document: iframeDocument, focus, print },
|
||||
});
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
printDocument('<p>报告正文</p>', '测试报告');
|
||||
|
||||
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
|
||||
expect(iframe).toBeInTheDocument();
|
||||
expect(iframe.contentDocument?.body.innerHTML).toContain('报告正文');
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
expect(print).toHaveBeenCalled();
|
||||
expect(document.title).not.toBe('测试报告');
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(document.querySelector('iframe')).not.toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
67
src/utils/print.ts
Normal file
67
src/utils/print.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export const printDocument = (htmlContent: string, docTitle: string = '图文报告') => {
|
||||
const originalTitle = document.title;
|
||||
document.title = docTitle;
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.right = '0';
|
||||
iframe.style.bottom = '0';
|
||||
iframe.style.width = '0';
|
||||
iframe.style.height = '0';
|
||||
iframe.style.border = '0';
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
const win = iframe.contentWindow;
|
||||
const doc = iframe.contentDocument || win?.document;
|
||||
if (doc && win) {
|
||||
doc.open();
|
||||
doc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${docTitle}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 15mm 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 0; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
|
||||
.content { width: 100%; min-height: 277mm; margin: 0 auto; }
|
||||
img { max-width: 100%; height: auto; display: block; margin: 8px auto; }
|
||||
p { margin: 0; padding: 0; line-height: 1.5; }
|
||||
h1 { font-size: 20px; margin: 16px 0 12px; font-weight: 600; text-align: center; }
|
||||
strong, b { font-weight: 600; }
|
||||
u { text-decoration: underline; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed; }
|
||||
td { padding: 8px; border: 1px solid #e2e8f0; vertical-align: top; }
|
||||
.image-placeholder { border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px; margin-bottom: 8px; background: #f8fafc; min-height: 70px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; }
|
||||
.image-placeholder.has-image { border: none; background: transparent; padding: 0; min-height: 0; }
|
||||
.delete-btn { display: none !important; }
|
||||
.image-placeholder:not(.has-image) { display: none !important; }
|
||||
.template-info-section { position: relative; margin-bottom: 16px; }
|
||||
.smart-field-wrapper { display: inline-flex; align-items: baseline; margin: 0; vertical-align: baseline; }
|
||||
.smart-field-wrapper .field-label { color: #64748b; user-select: none; }
|
||||
.smart-field-wrapper .field-value { min-width: 24px; padding: 0 2px; margin: 0; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: inherit; font-size: inherit; vertical-align: baseline; box-sizing: border-box; outline: none; text-align: center; }
|
||||
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
|
||||
.ai-region { border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; }
|
||||
.ai-region > [contenteditable="false"] { display: none !important; }
|
||||
@media print {
|
||||
.smart-field-wrapper .field-value { outline: none !important; box-shadow: none !important; border: none !important; border-bottom: 1px solid #000 !important; border-radius: 0 !important; background: transparent !important; padding: 0 2px 0px 2px !important; line-height: 1 !important; }
|
||||
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">${htmlContent}</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
doc.close();
|
||||
win.focus();
|
||||
setTimeout(() => {
|
||||
win.print();
|
||||
document.title = originalTitle;
|
||||
setTimeout(() => {
|
||||
if (iframe.parentNode) document.body.removeChild(iframe);
|
||||
}, 1000);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
47
src/utils/storage.test.ts
Normal file
47
src/utils/storage.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_AI_PROVIDERS, SystemSettings } from '../types';
|
||||
import { storage } from './storage';
|
||||
|
||||
describe('storage utility', () => {
|
||||
it('persists regular localStorage values as JSON', () => {
|
||||
storage.set('reports', [{ id: 'RPT_1', title: '测试报告' }]);
|
||||
|
||||
expect(storage.get('reports', [])).toEqual([{ id: 'RPT_1', title: '测试报告' }]);
|
||||
});
|
||||
|
||||
it('returns fallback values for missing or malformed data', () => {
|
||||
localStorage.setItem('broken', '{not-json');
|
||||
|
||||
expect(storage.get('missing', 'fallback')).toBe('fallback');
|
||||
expect(storage.get('broken', { safe: true })).toEqual({ safe: true });
|
||||
});
|
||||
|
||||
it('obfuscates systemSettings while keeping backward compatibility with plain JSON', () => {
|
||||
const settings: SystemSettings = {
|
||||
frameCount: 2,
|
||||
framePositions: [25, 75],
|
||||
defaultTemplate: 'surgery',
|
||||
activeAiProvider: 'kimi',
|
||||
aiProviders: DEFAULT_AI_PROVIDERS,
|
||||
};
|
||||
|
||||
storage.set('systemSettings', settings);
|
||||
expect(localStorage.getItem('systemSettings')).not.toContain('"frameCount"');
|
||||
expect(storage.get<SystemSettings>('systemSettings', {} as SystemSettings).framePositions).toEqual([25, 75]);
|
||||
|
||||
localStorage.setItem('systemSettings', JSON.stringify(settings));
|
||||
expect(storage.get<SystemSettings>('systemSettings', {} as SystemSettings).defaultTemplate).toBe('surgery');
|
||||
});
|
||||
|
||||
it('persists sessionStorage values separately', () => {
|
||||
storage.setSession('restore_RPT_1', '<p>历史版本</p>');
|
||||
|
||||
expect(storage.getSession('restore_RPT_1', '')).toBe('<p>历史版本</p>');
|
||||
storage.removeSession('restore_RPT_1');
|
||||
expect(storage.getSession('restore_RPT_1', null)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not expose a bundled AI provider key in default settings', () => {
|
||||
expect(DEFAULT_AI_PROVIDERS.kimi.apiKey).toBe('');
|
||||
});
|
||||
});
|
||||
74
src/utils/storage.ts
Normal file
74
src/utils/storage.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
const CRYPTO_KEY = 'MedicalReportSys2024';
|
||||
|
||||
function xorEncrypt(text: string, key: string): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||||
}
|
||||
return btoa(result);
|
||||
}
|
||||
|
||||
function xorDecrypt(encrypted: string, key: string): string {
|
||||
const text = atob(encrypted);
|
||||
let result = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const storage = {
|
||||
get<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
if (key === 'systemSettings') {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return JSON.parse(xorDecrypt(raw, CRYPTO_KEY)) as T;
|
||||
}
|
||||
}
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
|
||||
set<T>(key: string, value: T): void {
|
||||
try {
|
||||
let data = JSON.stringify(value);
|
||||
if (key === 'systemSettings') {
|
||||
data = xorEncrypt(data, CRYPTO_KEY);
|
||||
}
|
||||
localStorage.setItem(key, data);
|
||||
} catch (e) {
|
||||
console.error('Storage save failed (possibly quota exceeded):', e);
|
||||
}
|
||||
},
|
||||
|
||||
remove(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
},
|
||||
|
||||
getSession<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
|
||||
setSession<T>(key: string, value: T): void {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
console.error('Session storage save failed:', e);
|
||||
}
|
||||
},
|
||||
|
||||
removeSession(key: string): void {
|
||||
sessionStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user