Initialize backendized SurClaw report system

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

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

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

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

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

77
e2e/helpers.ts Normal file
View File

@@ -0,0 +1,77 @@
import { Page } from '@playwright/test';
export const baseUsers = [
{
username: 'admin',
password: '123456',
role: 'super',
name: '超级管理员',
department: 'admin',
status: 'active',
visibleTemplates: ['dept_surgery'],
manageableTemplates: ['dept_surgery'],
},
{
username: 'manager',
password: '123456',
role: 'admin',
name: '管理员',
department: '外科',
status: 'active',
visibleTemplates: ['dept_surgery'],
manageableTemplates: ['dept_surgery'],
},
{
username: '0001',
password: '123456',
role: 'user',
name: '张医生',
department: '外科',
status: 'active',
visibleTemplates: ['dept_surgery'],
manageableTemplates: [],
},
{
username: '0002',
password: '123456',
role: 'user',
name: '李医生',
department: '内科',
status: 'active',
visibleTemplates: ['dept_internal'],
manageableTemplates: [],
},
];
export const baseTemplates = [
{
id: 'dept_surgery',
name: '外科部门模板',
desc: 'E2E 外科模板',
content: '<p><span class="field-value" data-bind="patientName" contenteditable="true"></span></p><div class="ai-region" data-ai-id="手术步骤"><div class="ai-content"><p>默认内容</p></div></div>',
createdAt: '2026-05-01T00:00:00.000Z',
author: 'admin',
scope: 'department',
department: '外科',
},
{
id: 'dept_internal',
name: '内科部门模板',
desc: 'E2E 内科模板',
content: '<p>内科模板</p>',
createdAt: '2026-05-01T00:00:00.000Z',
author: 'admin',
scope: 'department',
department: '内科',
},
];
export const seedLocalStorage = async (page: Page, data: Record<string, unknown>) => {
await page.addInitScript((seed) => {
window.localStorage.clear();
window.sessionStorage.clear();
for (const [key, value] of Object.entries(seed)) {
window.localStorage.setItem(key, JSON.stringify(value));
}
}, data);
};

40
e2e/login.spec.ts Normal file
View File

@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
test('default quick login enters dashboard', async ({ page }) => {
await page.route('**/api/auth/me', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'UNAUTHORIZED', message: '未登录' } }),
});
});
await page.route('**/api/auth/login', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
user: {
id: 'u-admin',
username: 'admin',
role: 'super',
name: '超级管理员',
tenantId: 'tenant-demo',
departmentId: 'dept-admin',
departmentName: 'admin',
status: 'active',
createdAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
},
},
}),
});
});
await page.goto('/');
await page.getByText('admin / 123456').click();
await expect(page.getByRole('heading', { name: '工作台概览' })).toBeVisible();
await expect(page.getByText('超级管理员')).toBeVisible();
});

View File

@@ -0,0 +1,32 @@
import { expect, test } from '@playwright/test';
import { baseTemplates, baseUsers, seedLocalStorage } from './helpers';
test('doctor can save current report as a personal template visible only to self', async ({ page }) => {
await seedLocalStorage(page, {
users: baseUsers,
templates: baseTemplates,
currentUser: baseUsers[2],
reports: [],
});
page.on('dialog', async (dialog) => {
if (dialog.type() === 'prompt') {
await dialog.accept('我的测试模板');
return;
}
await dialog.accept();
});
await page.goto('/report-editor');
await expect(page.getByRole('button', { name: '保存为我的模板' })).toBeVisible();
await page.getByRole('button', { name: '保存为我的模板' }).click();
await expect.poll(async () => {
return page.evaluate(() => {
const templates = JSON.parse(window.localStorage.getItem('templates') || '[]');
return templates.some((template: any) => template.name === '我的测试模板' && template.scope === 'personal' && template.ownerUser === '0001');
});
}).toBe(true);
await expect(page.locator('option:not([disabled])', { hasText: '我的测试模板' })).toHaveCount(1);
});

View File

@@ -0,0 +1,82 @@
import { expect, test } from '@playwright/test';
import { baseTemplates, baseUsers, seedLocalStorage } from './helpers';
const reports = [
{
id: 'RPT_SURGERY_SELF',
title: '外科本人报告',
patientName: '患者甲',
hospitalId: 'H001',
department: '外科',
content: '<p>外科本人报告</p>',
author: '0001',
authorName: '张医生',
createdAt: '2026-05-01',
status: 'completed',
revision: 1,
},
{
id: 'RPT_SURGERY_OTHER',
title: '外科他人报告',
patientName: '患者乙',
hospitalId: 'H002',
department: '外科',
content: '<p>外科他人报告</p>',
author: '0003',
authorName: '王医生',
createdAt: '2026-05-01',
status: 'completed',
revision: 1,
},
{
id: 'RPT_INTERNAL',
title: '内科报告',
patientName: '患者丙',
hospitalId: 'H003',
department: '内科',
content: '<p>内科报告</p>',
author: '0002',
authorName: '李医生',
createdAt: '2026-05-01',
status: 'completed',
revision: 1,
},
];
test('admin only sees department reports, doctor only sees own reports, super sees all', async ({ page }) => {
await seedLocalStorage(page, {
users: baseUsers,
templates: baseTemplates,
reports,
currentUser: baseUsers[1],
});
await page.goto('/report-manage');
await expect(page.getByText('外科本人报告')).toBeVisible();
await expect(page.getByText('外科他人报告')).toBeVisible();
await expect(page.getByText('内科报告')).not.toBeVisible();
await seedLocalStorage(page, {
users: baseUsers,
templates: baseTemplates,
reports,
currentUser: baseUsers[2],
});
await page.goto('/report-manage');
await expect(page.getByText('外科本人报告')).toBeVisible();
await expect(page.getByText('外科他人报告')).not.toBeVisible();
await expect(page.getByText('内科报告')).not.toBeVisible();
await seedLocalStorage(page, {
users: baseUsers,
templates: baseTemplates,
reports,
currentUser: baseUsers[0],
});
await page.goto('/report-manage');
await expect(page.getByText('外科本人报告')).toBeVisible();
await expect(page.getByText('外科他人报告')).toBeVisible();
await expect(page.getByText('内科报告')).toBeVisible();
});

View File

@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test';
import { baseTemplates, baseUsers, seedLocalStorage } from './helpers';
test('editing a completed report increments revision and preserves history', async ({ page }) => {
await seedLocalStorage(page, {
users: baseUsers,
templates: baseTemplates,
currentUser: baseUsers[2],
reports: [
{
id: 'RPT_DONE',
title: '已完成报告',
patientName: '患者甲',
hospitalId: 'H001',
department: '外科',
content: '<p>旧报告内容</p>',
author: '0001',
authorName: '张医生',
createdAt: '2026-05-01',
updatedAt: '2026-05-01T08:00:00.000Z',
status: 'completed',
revision: 1,
history: [],
},
],
});
await page.goto('/report-editor?id=RPT_DONE');
await expect(page.getByText('编辑报告: RPT_DONE')).toBeVisible();
await page.getByRole('button', { name: '完成报告' }).click();
await page.waitForURL('**/report-manage');
const report = await page.evaluate(() => {
return JSON.parse(window.localStorage.getItem('reports') || '[]')[0];
});
expect(report.revision).toBe(2);
expect(report.history).toHaveLength(1);
expect(report.history[0].revision).toBe(1);
});