feat: GeoScene frontend POC + Docker deploy for remote host

Migrate maps to @geoscene/core, polish monitoring/alerts UX, fix timeline
basemap flicker and district alert regions, and ship compose/nginx Docker
deploy assets with CBPOA_ROOT data mounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 03:28:29 +08:00
parent fe8bed58f5
commit e22b004f9e
77 changed files with 3421 additions and 3589 deletions

View File

@@ -1,143 +0,0 @@
/**
* Phase-3 acceptance tests: role-aware 预警 (alerts) view.
*
* Two view-preset invariants (D2 — frontend presets, NOT access control):
*
* 1. PRIVACY INVARIANT (doctor / ?view=cluster): the doctor sees ONLY the aggregated
* density raster + the disease filter — ZERO individual patient/case point markers.
* The page mirrors every individual marker it would actually render into a hidden
* data-testid="patient-point" element (the live Leaflet CircleMarkers are canvas/SVG
* objects with no testid and can't be counted directly). In cluster mode the page
* forces showAlertMarkers=false, so that mirror set is empty → patient-point count 0.
*
* 2. 官员 (official) GRID-HIDE: the 100m 网格 is meaningless for leadership, so the grid
* toggle wrapper (data-testid="grid-layer-wrapper") is not rendered at all.
*
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically
* (no live :8000 backend). Role is seeded via localStorage['cbpoa_role'].
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
/**
* Seed auth token (+ optional role) and mock all /api/** calls before page load.
* Response shapes copied from user-flows.spec.ts.
*/
async function seedAuthAndMockApi(page: Page, role?: string) {
await page.addInitScript((r) => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
if (r) localStorage.setItem('cbpoa_role', r);
}, role ?? '');
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/diagnoses') || url.includes('/diagnosis-list')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
test.describe('role-aware 预警 view (Phase 3)', () => {
test.use({ viewport: { width: 1280, height: 800 } });
test('医生 /alerts?view=cluster: cluster-view mounts, disease filter present, ZERO patient points', async ({
page,
}) => {
await seedAuthAndMockApi(page, 'doctor');
await page.goto('/alerts?view=cluster');
// Page + the aggregated density (cluster) map both mount.
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toBeVisible();
// The disease filter is the doctor's core tool — it must be on the page.
await expect(page.getByText('按病种筛选')).toBeVisible();
// PRIVACY INVARIANT: not a single individual patient/case point may be rendered.
// Asserted at the data level (the mirrored DOM set), independent of Leaflet internals.
await expect(page.getByTestId(TESTIDS.patientPoint)).toHaveCount(0);
// The 预警标记 toggle (which would turn individual markers on) must be absent,
// so there is no way for the doctor to opt out of the privacy invariant.
await expect(page.getByRole('button', { name: '预警标记' })).toHaveCount(0);
});
test('官员 /alerts: 100m grid hidden — grid-layer-wrapper not rendered', async ({ page }) => {
await seedAuthAndMockApi(page, 'official');
await page.goto('/alerts');
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
// The grid toggle wrapper must be entirely absent for leadership.
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(0);
});
test('admin /alerts: full behavior — grid toggle present, no forced cluster view', async ({ page }) => {
await seedAuthAndMockApi(page, 'admin');
await page.goto('/alerts');
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
// Admin keeps the grid toggle and is NOT forced into cluster view.
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(1);
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toHaveCount(0);
});
});

View File

@@ -1,133 +0,0 @@
/**
* Phase-3 acceptance tests: 视角/perspective switcher (D2 — frontend view-presets only).
*
* Roles are NOT access control: the switcher only changes the default landing page +
* granularity/filter presets. This suite verifies the switcher renders, selecting a role
* navigates to that role's default landing URL (with its query params), and the choice
* survives a reload (persisted to localStorage['cbpoa_role']).
*
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically.
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
/** Seed auth token and mock all /api/** calls (shapes copied from user-flows.spec.ts). */
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
test.describe('视角/perspective switcher (Phase 3)', () => {
// Use a desktop viewport so the top bar renders the switcher inline.
test.use({ viewport: { width: 1280, height: 800 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('perspective-switcher is visible in the top bar', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toBeVisible();
});
test('selecting 厅领导 (official) navigates to /overview?granularity=district', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await expect(switcher).toBeVisible();
await switcher.selectOption('official');
await expect(page).toHaveURL(/\/overview/);
await expect(page).toHaveURL(/granularity=district/);
});
test('selecting 医生 (doctor) navigates to /alerts?view=cluster', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await expect(switcher).toBeVisible();
await switcher.selectOption('doctor');
await expect(page).toHaveURL(/\/alerts/);
await expect(page).toHaveURL(/view=cluster/);
});
test('selected role persists across reload (localStorage cbpoa_role)', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await switcher.selectOption('doctor');
await expect(page).toHaveURL(/\/alerts/);
// localStorage should now hold the chosen role.
const stored = await page.evaluate(() => localStorage.getItem('cbpoa_role'));
expect(stored).toBe('doctor');
await page.reload();
// After reload the switcher reflects the persisted role.
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toHaveValue('doctor');
});
});