fix(web): 清零前端 TS 构建错误 — 31 文件类型修复 + 面包屑 + 超时配置

- 修复 verbatimModuleSyntax 要求的 import type 声明
- 修复未使用导入(Badge/EditOutlined/Space/Input/Switch 等)
- 修复 mock.calls 类型注解([string,unknown] → any[])
- 修复 vitest 全局超时和 poolTimeout 配置
- 修复 PageContainer 缺少 onBack prop、MenuInfo children 可选
- 修复 CopilotAlert Badge status info→processing、useCopilotRisk 二次解包
- 修复 articles/doctors 测试 delete 调用缺少 version 参数
- 添加排班管理/预约管理面包屑标题 fallback
This commit is contained in:
iven
2026-05-15 23:03:08 +08:00
parent bf8bcdbd5d
commit ced1c0ad0c
30 changed files with 55 additions and 44 deletions

View File

@@ -44,7 +44,7 @@ describe('articleApi', () => {
it('create 应调用 POST /health/articles', async () => { it('create 应调用 POST /health/articles', async () => {
mockPost.mockResolvedValue(fakeRes) mockPost.mockResolvedValue(fakeRes)
const req = { title: '健康饮食指南', content: '正文内容', content_type: 'markdown' } const req = { title: '健康饮食指南', content: '正文内容', content_type: 'markdown' as const }
await articleApi.create(req) await articleApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/articles', req) expect(mockPost).toHaveBeenCalledWith('/health/articles', req)
@@ -60,7 +60,7 @@ describe('articleApi', () => {
it('delete 应调用 DELETE /health/articles/:id', async () => { it('delete 应调用 DELETE /health/articles/:id', async () => {
mockDelete.mockResolvedValue({ data: { success: true, data: null } }) mockDelete.mockResolvedValue({ data: { success: true, data: null } })
await articleApi.delete('art-001') await articleApi.delete('art-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/health/articles/art-001') expect(mockDelete).toHaveBeenCalledWith('/health/articles/art-001')
}) })

View File

@@ -60,7 +60,7 @@ describe('doctorApi', () => {
it('delete 应调用 DELETE /health/doctors/:id', async () => { it('delete 应调用 DELETE /health/doctors/:id', async () => {
mockDelete.mockResolvedValue(undefined) mockDelete.mockResolvedValue(undefined)
await doctorApi.delete('d-001') await doctorApi.delete('d-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/health/doctors/d-001') expect(mockDelete).toHaveBeenCalledWith('/health/doctors/d-001')
}) })

View File

@@ -10,7 +10,7 @@ export interface MenuInfo {
visible: boolean; visible: boolean;
menu_type: string; menu_type: string;
permission?: string; permission?: string;
children: MenuInfo[]; children?: MenuInfo[];
version: number; version: number;
} }

View File

@@ -4,10 +4,10 @@ import { CheckOutlined } from '@ant-design/icons';
import { listAlerts, dismissInsight } from '../../api/copilot'; import { listAlerts, dismissInsight } from '../../api/copilot';
import type { CopilotInsight } from '../../api/copilot'; import type { CopilotInsight } from '../../api/copilot';
const severityConfig: Record<string, { type: 'success' | 'info' | 'warning' | 'error'; label: string }> = { const severityConfig: Record<string, { type: 'success' | 'processing' | 'warning' | 'error'; label: string }> = {
critical: { type: 'error', label: '危急' }, critical: { type: 'error', label: '危急' },
warning: { type: 'warning', label: '警告' }, warning: { type: 'warning', label: '警告' },
info: { type: 'info', label: '提示' }, info: { type: 'processing', label: '提示' },
}; };
export function CopilotAlert() { export function CopilotAlert() {

View File

@@ -13,8 +13,7 @@ export function useCopilotRisk(patientId: string | undefined) {
setError(null); setError(null);
try { try {
const res = await getPatientRisk(patientId); const res = await getPatientRisk(patientId);
const payload = (res.data as { data?: RiskScore }).data ?? null; setData(res ?? null);
setData(payload);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载风险评分失败'); setError(err instanceof Error ? err.message : '加载风险评分失败');
} finally { } finally {

View File

@@ -12,6 +12,7 @@ interface PageContainerProps {
batchActions?: React.ReactNode; batchActions?: React.ReactNode;
selectedCount?: number; selectedCount?: number;
onClearSelection?: () => void; onClearSelection?: () => void;
onBack?: () => void;
children: React.ReactNode; children: React.ReactNode;
loading?: boolean; loading?: boolean;
} }
@@ -26,6 +27,7 @@ export function PageContainer({
batchActions, batchActions,
selectedCount, selectedCount,
onClearSelection, onClearSelection,
onBack,
children, children,
loading, loading,
}: PageContainerProps) { }: PageContainerProps) {
@@ -36,6 +38,11 @@ export function PageContainer({
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}> <Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
{onBack && (
<Button type="text" size="small" onClick={onBack} style={{ marginRight: 8 }}>
</Button>
)}
{title} {title}
</Typography.Title> </Typography.Title>
{subtitle && ( {subtitle && (

View File

@@ -43,6 +43,8 @@ const routeTitleFallback: Record<string, string> = {
'/health/follow-up-records': '随访记录', '/health/follow-up-records': '随访记录',
'/health/article-categories': '分类管理', '/health/article-categories': '分类管理',
'/health/article-tags': '标签管理', '/health/article-tags': '标签管理',
'/health/schedules': '排班管理',
'/health/appointments': '预约管理',
}; };
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined { function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react'; import { waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests'; import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createAlertRuleFixture } from '../../test/fixtures'; import { createFixtureList, createAlertRuleFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders'; import { renderWithProviders } from '../../test/utils/renderWithProviders';

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react'; import { waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests'; import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createArticleFixture } from '../../test/fixtures'; import { createFixtureList, createArticleFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders'; import { renderWithProviders } from '../../test/utils/renderWithProviders';

View File

@@ -430,7 +430,7 @@ export default function BannerManage() {
rowClassName={(record) => rowClassName={(record) =>
record.status === 'inactive' record.status === 'inactive'
? 'ant-table-row-inactive' ? 'ant-table-row-inactive'
: undefined : ''
} }
/> />

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { import {
Button, Descriptions, Form, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Tabs, Button, Descriptions, Form, Input, message, Modal, Popconfirm, Select, Table, Tag, Tabs,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { import {
Button, Form, Input, InputNumber, message, Modal, Popconfirm, Result, Select, Space, Switch, Table, Tag, Button, Form, Input, InputNumber, message, Modal, Popconfirm, Result, Select, Space, Table, Tag,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Button, Input, message, Popconfirm, Result, Select, Space, Table, Tag, Badge } from 'antd'; import { Button, message, Popconfirm, Result, Select, Space, Table, Tag, Badge } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';

View File

@@ -191,9 +191,9 @@ export default function DoctorList() {
}; };
// ---- 删除 ---- // ---- 删除 ----
const handleDelete = async (id: string) => { const handleDelete = async (id: string, version: number) => {
try { try {
await doctorApi.delete(id); await doctorApi.delete(id, version);
message.success('删除成功'); message.success('删除成功');
refresh(); refresh();
} catch { } catch {
@@ -286,7 +286,7 @@ export default function DoctorList() {
</Button> </Button>
<Popconfirm <Popconfirm
title="确定删除该医护?" title="确定删除该医护?"
onConfirm={() => handleDelete(record.id)} onConfirm={() => handleDelete(record.id, record.version)}
okText="确定" okText="确定"
cancelText="取消" cancelText="取消"
> >

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { import {
Button, Card, Descriptions, Form, Input, message, Modal, Popconfirm, Result, Select, Space, Table, Tag, Button, Card, Descriptions, Form, message, Modal, Popconfirm, Result, Select, Space, Table, Tag,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';

View File

@@ -4,7 +4,7 @@ import {
Popconfirm, InputNumber, Switch, Card, Typography, Popconfirm, InputNumber, Switch, Card, Typography,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, DeleteOutlined, EditOutlined, PlusOutlined, DeleteOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
@@ -35,9 +35,9 @@ const FIELD_TYPE_OPTIONS = [
{ value: 'scale', label: '量表' }, { value: 'scale', label: '量表' },
]; ];
function FieldEditor({ value, onChange }: { function FieldEditor({ value = [], onChange = () => {} }: {
value: TemplateFieldReq[]; value?: TemplateFieldReq[];
onChange: (v: TemplateFieldReq[]) => void; onChange?: (v: TemplateFieldReq[]) => void;
}) { }) {
const add = () => { const add = () => {
onChange([...value, { onChange([...value, {

View File

@@ -194,9 +194,9 @@ export default function MediaLibrary() {
</div> </div>
<div style={{ flex: 1, overflow: 'auto', padding: '8px 4px' }}> <div style={{ flex: 1, overflow: 'auto', padding: '8px 4px' }}>
{foldersLoading ? <div style={{ textAlign: 'center', padding: 20 }}><Spin size="small" /></div> : ( {foldersLoading ? <div style={{ textAlign: 'center', padding: 20 }}><Spin size="small" /></div> : (
<Tree defaultExpandAll selectedKeys={folderId ? [folderId] : ['__all__']} treeData={treeData} <Tree defaultExpandAll selectedKeys={folderId ? [folderId] : ['__all__']} treeData={treeData as any}
fieldNames={{ title: 'name', key: 'id', children: 'children' }} onSelect={handleFolderSelect} fieldNames={{ title: 'name', key: 'id', children: 'children' }} onSelect={handleFolderSelect}
titleRender={(node: TreeNode) => { titleRender={(node: any) => {
if (node.id === '__all__') return <span>{node.name}</span>; if (node.id === '__all__') return <span>{node.name}</span>;
const matched = folders.find((f) => f.id === node.id); const matched = folders.find((f) => f.id === node.id);
return ( return (

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { import {
Button, DatePicker, Form, Input, message, Modal, Popconfirm, Button, DatePicker, Form, Input, message, Modal, Popconfirm,
Result, Select, Space, Switch, Table, Tag, Result, Select, Space, Switch, Table, Tag,

View File

@@ -1,5 +1,3 @@
import { http, HttpResponse } from 'msw';
import { server } from '../../test/mocks/server';
import { createListPageTests } from '../../test/factories/listPageTests'; import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createPatientFixture } from '../../test/fixtures'; import { createFixtureList, createPatientFixture } from '../../test/fixtures';
import PatientList from './PatientList'; import PatientList from './PatientList';

View File

@@ -5,7 +5,7 @@ import { useVitalSSE } from '../../hooks/useVitalSSE';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { alertApi, type Alert } from '../../api/health/alerts'; import { alertApi, type Alert } from '../../api/health/alerts';
import { PageContainer } from '../../components/PageContainer'; import { PageContainer } from '../../components/PageContainer';
import { SEVERITY_COLOR, SEVERITY_LABEL, VITAL_CARD_METRICS } from '../../constants/health'; import { VITAL_CARD_METRICS } from '../../constants/health';
interface PatientAlertSummary { interface PatientAlertSummary {
patient_id: string; patient_id: string;
@@ -22,7 +22,7 @@ interface PatientAlertSummary {
*/ */
export default function RealtimeMonitor() { export default function RealtimeMonitor() {
const { hasPermission } = usePermission('health.alerts.list'); const { hasPermission } = usePermission('health.alerts.list');
const [alerts, setAlerts] = useState<Alert[]>([]); const [_alerts, setAlerts] = useState<Alert[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null); const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
const [alertSummary, setAlertSummary] = useState<PatientAlertSummary[]>([]); const [alertSummary, setAlertSummary] = useState<PatientAlertSummary[]>([]);

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect, useMemo } from 'react'; import { useState, useCallback, useEffect, useMemo } from 'react';
import { import {
Badge, Button, DatePicker, Form, Input, message, Modal, Popconfirm, Button, DatePicker, Form, Input, message, Modal, Popconfirm,
Result, Select, Space, Table, Tag, Result, Select, Space, Table, Tag,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';

View File

@@ -14,7 +14,7 @@ interface ArticleStyleLibraryProps {
onThemeChange: (themeId: string) => void; onThemeChange: (themeId: string) => void;
} }
const sectionLabel = (text: string, isDark: boolean) => ({ const sectionLabel = (_text: string, isDark: boolean) => ({
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
color: isDark ? '#64748b' : '#86868b', color: isDark ? '#64748b' : '#86868b',

View File

@@ -19,7 +19,7 @@ function parseStyleStr(str: string): Record<string, string> {
const renderElemConf = { const renderElemConf = {
type: TYPE, type: TYPE,
renderElem(elemNode: SlateElement): VNode { renderElem(elemNode: SlateElement): VNode {
const node = elemNode as Record<string, unknown>; const node = elemNode as unknown as Record<string, unknown>;
const style = (node.style as string) || ''; const style = (node.style as string) || '';
const innerHtml = (node.innerHtml as string) || ''; const innerHtml = (node.innerHtml as string) || '';
return h( return h(
@@ -44,7 +44,7 @@ const renderElemConf = {
const elemToHtmlConf = { const elemToHtmlConf = {
type: TYPE, type: TYPE,
elemToHtml(elemNode: SlateElement): string { elemToHtml(elemNode: SlateElement): string {
const node = elemNode as Record<string, unknown>; const node = elemNode as unknown as Record<string, unknown>;
const style = (node.style as string) || ''; const style = (node.style as string) || '';
const innerHtml = (node.innerHtml as string) || ''; const innerHtml = (node.innerHtml as string) || '';
return `<div data-w-e-type="${TYPE}" style="${style}">${innerHtml}</div>`; return `<div data-w-e-type="${TYPE}" style="${style}">${innerHtml}</div>`;
@@ -53,7 +53,7 @@ const elemToHtmlConf = {
const parseElemHtmlConf = { const parseElemHtmlConf = {
selector: `div[data-w-e-type="${TYPE}"]`, selector: `div[data-w-e-type="${TYPE}"]`,
parseElemHtml($elem: HTMLElement): SlateElement { parseElemHtml($elem: Element): SlateElement {
return { return {
type: TYPE, type: TYPE,
style: $elem.getAttribute('style') || '', style: $elem.getAttribute('style') || '',

View File

@@ -38,6 +38,7 @@ interface ActionDetailDrawerProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onActionComplete?: () => void; onActionComplete?: () => void;
onRefresh?: () => void;
} }
export default function ActionDetailDrawer({ export default function ActionDetailDrawer({
@@ -45,6 +46,7 @@ export default function ActionDetailDrawer({
open, open,
onClose, onClose,
onActionComplete, onActionComplete,
onRefresh: _onRefresh,
}: ActionDetailDrawerProps) { }: ActionDetailDrawerProps) {
const [thread, setThread] = useState<ThreadResponse | null>(null); const [thread, setThread] = useState<ThreadResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);

View File

@@ -7,7 +7,7 @@
* - 侧边栏折叠状态 * - 侧边栏折叠状态
* - 远程主题配置加载 * - 远程主题配置加载
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
// --- Mock localStorage --- // --- Mock localStorage ---
const localStorageStore: Record<string, string> = {} const localStorageStore: Record<string, string> = {}
@@ -39,7 +39,8 @@ vi.mock('../api/themes', () => ({
// --- Mock zustand 内部不依赖真实存储 --- // --- Mock zustand 内部不依赖真实存储 ---
// 在 mock 生效后导入被测模块 // 在 mock 生效后导入被测模块
import { useAppStore, THEME_OPTIONS } from './app' import { useAppStore, THEME_OPTIONS } from './app'
import type { ThemeName, ThemeConfig } from './app' import type { ThemeName } from './app'
import type { ThemeConfig } from '../api/themes'
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()

View File

@@ -312,7 +312,7 @@ describe('connectSSE', () => {
it('有 token 时创建 EventSource 并传递正确 URL', () => { it('有 token 时创建 EventSource 并传递正确 URL', () => {
localStorage.setItem('access_token', 'my-jwt-token') localStorage.setItem('access_token', 'my-jwt-token')
process.env.VITE_API_BASE_URL = undefined vi.stubEnv('VITE_API_BASE_URL', '')
const dispose = useMessageStore.getState().connectSSE() const dispose = useMessageStore.getState().connectSSE()
@@ -339,7 +339,7 @@ describe('connectSSE', () => {
const dispose = useMessageStore.getState().connectSSE() const dispose = useMessageStore.getState().connectSSE()
const eventTypes = mockEventSourceAddEventListener.mock.calls.map( const eventTypes = mockEventSourceAddEventListener.mock.calls.map(
(call: [string, unknown]) => call[0], (call: any[]) => call[0],
) )
expect(eventTypes).toContain('message') expect(eventTypes).toContain('message')
expect(eventTypes).toContain('alert') expect(eventTypes).toContain('alert')
@@ -355,7 +355,7 @@ describe('connectSSE', () => {
// 找到 message 事件的回调 // 找到 message 事件的回调
const messageCall = mockEventSourceAddEventListener.mock.calls.find( const messageCall = mockEventSourceAddEventListener.mock.calls.find(
(call: [string, unknown]) => call[0] === 'message', (call: any[]) => call[0] === 'message',
) )
const messageCallback = messageCall![1] as () => void const messageCallback = messageCall![1] as () => void
@@ -376,7 +376,7 @@ describe('connectSSE', () => {
const dispose = useMessageStore.getState().connectSSE() const dispose = useMessageStore.getState().connectSSE()
const alertCall = mockEventSourceAddEventListener.mock.calls.find( const alertCall = mockEventSourceAddEventListener.mock.calls.find(
(call: [string, unknown]) => call[0] === 'alert', (call: any[]) => call[0] === 'alert',
) )
const alertCallback = alertCall![1] as () => void const alertCallback = alertCall![1] as () => void

View File

@@ -355,7 +355,7 @@ describe('usePluginStore', () => {
const schema = createFakeSchema({ const schema = createFakeSchema({
ui: { ui: {
pages: [ pages: [
{ type: 'tabs' as const, label: '综合视图', icon: 'LayoutOutlined' }, { type: 'tabs' as const, label: '综合视图', icon: 'LayoutOutlined', tabs: [] },
], ],
}, },
}); });

View File

@@ -18,7 +18,7 @@ Object.defineProperty(window, 'matchMedia', {
}); });
// ResizeObserver mockAnt Design Table 依赖) // ResizeObserver mockAnt Design Table 依赖)
global.ResizeObserver = class ResizeObserver { globalThis.ResizeObserver = class ResizeObserver {
observe() {} observe() {}
unobserve() {} unobserve() {}
disconnect() {} disconnect() {}

View File

@@ -1,5 +1,5 @@
import { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react'; import { render, type RenderOptions } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ConfigProvider } from 'antd'; import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';

View File

@@ -9,6 +9,8 @@ export default defineConfig({
globals: true, globals: true,
setupFiles: ['./src/test/setup.ts'], setupFiles: ['./src/test/setup.ts'],
exclude: ['e2e/**', 'node_modules/**'], exclude: ['e2e/**', 'node_modules/**'],
testTimeout: 15000,
poolTimeout: 60000,
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'lcov'], reporter: ['text', 'lcov'],