diff --git a/bun-test-setup.ts b/bun-test-setup.ts index 03e1cd3..555e500 100644 --- a/bun-test-setup.ts +++ b/bun-test-setup.ts @@ -9,6 +9,12 @@ global.localStorage = win.localStorage as any; global.addEventListener = win.addEventListener.bind(win) as any; global.removeEventListener = win.removeEventListener.bind(win) as any; global.dispatchEvent = win.dispatchEvent.bind(win) as any; +global.getComputedStyle = win.getComputedStyle.bind(win) as any; +global.Element = win.Element as any; +global.HTMLElement = win.HTMLElement as any; +global.SVGElement = win.SVGElement as any; +global.Node = win.Node as any; +global.HTMLInputElement = win.HTMLInputElement as any; global.StorageEvent = win.StorageEvent as any; // Mock dependencies that cause side effects or fail on static assets imports @@ -69,3 +75,16 @@ mock.module('react-router-dom', () => ({ useLoaderData: () => null, useActionData: () => null, })); + +if (typeof window.matchMedia !== 'function') { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as any; +} diff --git a/bun.lock b/bun.lock index 6857b1c..b1255b8 100644 --- a/bun.lock +++ b/bun.lock @@ -731,6 +731,8 @@ "happy-dom": ["happy-dom@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "hash-wasm": ["hash-wasm@4.12.0", "", {}, "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ=="], "history": ["history@5.3.0", "", { "dependencies": { "@babel/runtime": "^7.7.6" } }, "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ=="], @@ -957,6 +959,8 @@ "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="], "svg-parser": ["svg-parser@2.0.4", "", {}, "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ=="], diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 9a5df51..5f59582 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -18,6 +18,11 @@ export default defineConfig({ ), }, }, + performance: { + chunkSplit: { + strategy: 'split-by-experience', + }, + }, plugins: [ pluginReact({ reactCompiler: true, diff --git a/src/components/dangerous-confirm-modal.test.tsx b/src/components/dangerous-confirm-modal.test.tsx new file mode 100644 index 0000000..4516a93 --- /dev/null +++ b/src/components/dangerous-confirm-modal.test.tsx @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { ConfigProvider } from 'antd'; +import { DangerousConfirmModal } from './dangerous-confirm-modal'; + +describe('DangerousConfirmModal', () => { + afterEach(() => { + cleanup(); + }); + + test('does not render content when closed', () => { + render( + + {}} + onConfirm={() => {}} + /> + , + ); + expect(screen.queryByText('Test Title')).toBeNull(); + }); + + test('renders title, warning message and input when open', () => { + render( + + {}} + onConfirm={() => {}} + /> + , + ); + expect(screen.getByText('Test Title')).not.toBeNull(); + expect(screen.getByText('Test Desc')).not.toBeNull(); + expect(screen.getByText('my-app')).not.toBeNull(); + }); + + test('disables confirm button when text does not match', () => { + render( + + {}} + onConfirm={() => {}} + /> + , + ); + + const confirmBtn = screen.getByText('Confirm Delete').closest('button'); + expect((confirmBtn as HTMLButtonElement).disabled).toBe(true); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'wrong-name' } }); + expect((confirmBtn as HTMLButtonElement).disabled).toBe(true); + }); + + test('enables confirm button and triggers onConfirm when text matches exactly (with whitespace trimming)', () => { + const onConfirmMock = mock(() => {}); + render( + + {}} + onConfirm={onConfirmMock} + /> + , + ); + + const confirmBtn = screen.getByText('Confirm Delete').closest('button')!; + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: ' my-app ' } }); + expect((confirmBtn as HTMLButtonElement).disabled).toBe(false); + + fireEvent.click(confirmBtn); + expect(onConfirmMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/dangerous-confirm-modal.tsx b/src/components/dangerous-confirm-modal.tsx new file mode 100644 index 0000000..b18d14d --- /dev/null +++ b/src/components/dangerous-confirm-modal.tsx @@ -0,0 +1,113 @@ +import { ExclamationCircleFilled } from '@ant-design/icons'; +import { Alert, Input, Modal, Typography } from 'antd'; +import { type ReactNode, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +export interface DangerousConfirmModalProps { + open: boolean; + title: string; + description: ReactNode; + expectedConfirmText?: string; + confirmPlaceholder?: string; + dangerButtonText?: string; + cancelButtonText?: string; + loading?: boolean; + onCancel: () => void; + onConfirm: () => void | Promise; +} + +/** + * 高危毁灭性操作二次确认 Guard 弹窗组件 + * 当配置了 expectedConfirmText 时,用户必须在输入框中手动键入完全匹配的文本方可点击确认。 + */ +export function DangerousConfirmModal({ + open, + title, + description, + expectedConfirmText, + confirmPlaceholder, + dangerButtonText, + cancelButtonText, + loading = false, + onCancel, + onConfirm, +}: DangerousConfirmModalProps) { + const { t } = useTranslation(); + const [inputText, setInputText] = useState(''); + + // 重置输入状态 + useEffect(() => { + if (open) { + setInputText(''); + } + }, [open]); + + const hasExpectedText = + expectedConfirmText !== undefined && expectedConfirmText !== null; + const isMatched = hasExpectedText + ? expectedConfirmText.trim() !== '' && + inputText.trim() === expectedConfirmText.trim() + : true; + + const placeholderText = + confirmPlaceholder ?? t('dangerous_modal.confirm_placeholder'); + const okText = dangerButtonText ?? t('dangerous_modal.confirm_button'); + const cancelText = cancelButtonText ?? t('dangerous_modal.cancel'); + + return ( + + + {title} + + } + onCancel={onCancel} + onOk={onConfirm} + okText={okText} + okButtonProps={{ + danger: true, + type: 'primary', + disabled: !isMatched, + loading, + }} + cancelText={cancelText} + destroyOnHidden + > + + + {expectedConfirmText && ( +
+

+ {t('dangerous_modal.confirm_prompt_prefix')}{' '} + {expectedConfirmText}{' '} + {t('dangerous_modal.confirm_prompt_suffix')} +

+ setInputText(e.target.value)} + placeholder={placeholderText} + status={inputText && !isMatched ? 'error' : ''} + autoFocus + /> +
+ )} +
+ ); +} diff --git a/src/components/lazy-chart.tsx b/src/components/lazy-chart.tsx new file mode 100644 index 0000000..6a9e531 --- /dev/null +++ b/src/components/lazy-chart.tsx @@ -0,0 +1,119 @@ +import { + type ComponentProps, + type ComponentType, + lazy, + Suspense, + useCallback, + useState, +} from 'react'; +import { SectionErrorBoundary } from './section-error-boundary'; +import { ChartSkeleton } from './skeletons'; + +type ChartComponentType = 'Area' | 'Line' | 'Pie' | 'DualAxes'; + +function createLazyChart(type: T) { + return lazy(() => + import('@ant-design/charts').then((module) => ({ + default: module[type] as ComponentType, + })), + ); +} + +interface AsyncChartProps { + chartType: T; + errorTitle: string; + height?: number; + chartProps: Record; +} + +function AsyncChartWrapper({ + chartType, + errorTitle, + height, + chartProps, +}: AsyncChartProps) { + const [retryCount, setRetryCount] = useState(0); + const [LazyComponent, setLazyComponent] = useState(() => + createLazyChart(chartType), + ); + + const handleReset = useCallback(() => { + setLazyComponent(() => createLazyChart(chartType)); + setRetryCount((c) => c + 1); + }, [chartType]); + + return ( + + } + > + + + + ); +} + +export function AsyncArea({ + height, + ...props +}: ComponentProps & { + height?: number; +}) { + return ( + + ); +} + +export function AsyncLine({ + height, + ...props +}: ComponentProps & { + height?: number; +}) { + return ( + + ); +} + +export function AsyncPie({ + height, + ...props +}: ComponentProps & { + height?: number; +}) { + return ( + + ); +} + +export function AsyncDualAxes({ + height, + ...props +}: ComponentProps & { + height?: number; +}) { + return ( + + ); +} diff --git a/src/components/section-error-boundary.test.tsx b/src/components/section-error-boundary.test.tsx new file mode 100644 index 0000000..c177d9b --- /dev/null +++ b/src/components/section-error-boundary.test.tsx @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { SectionErrorBoundary } from './section-error-boundary'; + +function ProblemChild({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) { + throw new Error('Component Error Test'); + } + return
Normal Content
; +} + +describe('SectionErrorBoundary', () => { + afterEach(() => { + cleanup(); + }); + + test('renders children normally when no error occurs', () => { + render( + + + , + ); + expect(screen.queryByText('Normal Content')).not.toBeNull(); + }); + + test('captures error and displays fallback UI when child throws', () => { + const originalError = console.error; + console.error = () => {}; + + render( + + + , + ); + + expect(screen.queryByText('Custom Section Error')).not.toBeNull(); + expect(screen.queryByText('Component Error Test')).not.toBeNull(); + + console.error = originalError; + }); + + test('invokes onReset and allows retry when retry button is clicked', () => { + const originalError = console.error; + console.error = () => {}; + + const onResetMock = mock(() => {}); + + render( + + + , + ); + + const retryBtn = screen.getByText('重试组件').closest('button')!; + fireEvent.click(retryBtn); + + expect(onResetMock).toHaveBeenCalledTimes(1); + + console.error = originalError; + }); +}); diff --git a/src/components/section-error-boundary.tsx b/src/components/section-error-boundary.tsx new file mode 100644 index 0000000..551ef1a --- /dev/null +++ b/src/components/section-error-boundary.tsx @@ -0,0 +1,72 @@ +import { ReloadOutlined } from '@ant-design/icons'; +import { Alert, Button } from 'antd'; +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface Props { + children: ReactNode; + title?: string; + onReset?: () => void; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class SectionErrorBoundary extends Component { + public override state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public override componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('SectionErrorBoundary caught an error:', error, errorInfo); + } + + private handleReset = () => { + this.setState({ hasError: false, error: null }); + this.props.onReset?.(); + }; + + public override render() { + if (this.state.hasError) { + return ( + +

+ {this.state.error?.message || '未知渲染错误'} +

+ + + } + style={{ margin: '12px 0' }} + /> + ); + } + + return this.props.children; + } +} diff --git a/src/components/skeletons.tsx b/src/components/skeletons.tsx new file mode 100644 index 0000000..5de912a --- /dev/null +++ b/src/components/skeletons.tsx @@ -0,0 +1,93 @@ +import { Card, Skeleton, Table } from 'antd'; + +export interface SkeletonProps { + height?: number | string; + className?: string; +} + +/** + * 图表数据加载中的骨架屏组件 + */ +export function ChartSkeleton({ height = 300, className }: SkeletonProps) { + return ( + + + + ); +} + +/** + * 表格数据加载中的骨架屏组件 + */ +export function TableSkeleton({ rows = 5 }: { rows?: number }) { + return ( + + + ({ + key: index, + }))} + columns={[ + { + title: ( + + ), + dataIndex: 'key', + render: () => , + }, + { + title: ( + + ), + dataIndex: 'col2', + render: () => , + }, + { + title: ( + + ), + dataIndex: 'col3', + render: () => , + }, + ]} + pagination={false} + /> + + ); +} + +/** + * 卡片数据加载中的骨架屏组件 + */ +export function CardSkeleton({ count = 3 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton list + + + + ))} +
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c2776b8..4819690 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -857,6 +857,14 @@ "failed": "{{num}} patch(es) failed", "failed_tip": "Some incremental patches failed to generate. Affected users will fall back to full bundle downloads; updates still work." }, + "dangerous_modal": { + "warning_title": "High-Risk Action", + "confirm_prompt_prefix": "To prevent accidental operation, please type", + "confirm_prompt_suffix": "to confirm:", + "confirm_placeholder": "Type the text to confirm", + "confirm_button": "Confirm Action", + "cancel": "Cancel" + }, "setting_modal": { "app_id": "App ID", "app_key": "App Key", @@ -870,7 +878,10 @@ "delete_app": "Delete App", "delete_confirm": "This app cannot be recovered after deletion.", "delete_ok": "Delete App", - "delete_button": "Delete" + "delete_button": "Delete", + "delete_title": "Confirm App Deletion", + "delete_desc": "This action cannot be undone! Deleting this app will permanently purge all package versions, dependencies, and configuration history.", + "delete_permanent": "Permanently Delete App" }, "common": { "cancel": "Cancel", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f85c654..37a70e2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -857,6 +857,14 @@ "failed": "{{num}} 个补丁生成失败", "failed_tip": "部分增量补丁生成失败,受影响的用户将回退为全量包下载,不影响更新可用性。" }, + "dangerous_modal": { + "warning_title": "高风险操作提示", + "confirm_prompt_prefix": "为防止误操作,请输入提示文本", + "confirm_prompt_suffix": "以进行确认:", + "confirm_placeholder": "请输入对应的确认文本以继续", + "confirm_button": "确认执行", + "cancel": "取消" + }, "setting_modal": { "app_id": "App ID", "app_key": "App Key", @@ -870,7 +878,10 @@ "delete_app": "删除应用", "delete_confirm": "应用删除后无法恢复", "delete_ok": "确认删除", - "delete_button": "删除" + "delete_button": "删除", + "delete_title": "确认删除该应用", + "delete_desc": "此操作不可逆!删除应用将清除该应用下所有的更新包版本、依赖及配置历史。", + "delete_permanent": "确认永久删除" }, "common": { "cancel": "取消", diff --git a/src/pages/admin-metrics.tsx b/src/pages/admin-metrics.tsx index 8028249..cb6c5da 100644 --- a/src/pages/admin-metrics.tsx +++ b/src/pages/admin-metrics.tsx @@ -1,4 +1,3 @@ -import { Line } from '@ant-design/charts'; import { useQuery } from '@tanstack/react-query'; import { Card, @@ -14,6 +13,7 @@ import dayjs from 'dayjs'; import { useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; +import { AsyncLine } from '@/components/lazy-chart'; import { api } from '@/services/api'; import { patchSearchParams } from '@/utils/helper'; import { metricsKeys } from '@/utils/query-keys'; @@ -459,7 +459,7 @@ export const Component = () => { {lineData.length > 0 ? ( - + ) : (
{t('admin_metrics.no_data')} diff --git a/src/pages/manage/components/json-editor.tsx b/src/pages/manage/components/json-editor.tsx index 9b426f0..a20d030 100644 --- a/src/pages/manage/components/json-editor.tsx +++ b/src/pages/manage/components/json-editor.tsx @@ -1,39 +1,108 @@ -import { useEffect, useRef } from 'react'; -import { - createJSONEditor, - type JSONEditorPropsOptional, - Mode, -} from 'vanilla-jsoneditor'; +import { ReloadOutlined } from '@ant-design/icons'; +import { Alert, Button, Spin } from 'antd'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { JSONEditorPropsOptional } from 'vanilla-jsoneditor'; export default function JsonEditor({ className, ...props }: JSONEditorPropsOptional & { className?: string }) { const refContainer = useRef(null); - const refEditor = useRef>(null); + const refEditor = useRef(null); + const propsRef = useRef(props); + propsRef.current = props; - // biome-ignore lint/correctness/useExhaustiveDependencies: no need to re-create editor + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [loadCount, setLoadCount] = useState(0); + + const handleRetry = useCallback(() => { + setLoadError(false); + setLoading(true); + setLoadCount((c) => c + 1); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reload dynamic import on loadCount change useEffect(() => { - // create editor - refEditor.current = createJSONEditor({ - target: refContainer.current!, - props: { - mode: Mode.text, - ...props, - }, - }); + let destroyed = false; + + // 动态按需加载 vanilla-jsoneditor 库,避免同步编译入主包 + import('vanilla-jsoneditor') + .then(({ createJSONEditor, Mode }) => { + if (destroyed || !refContainer.current) return; + + refEditor.current = createJSONEditor({ + target: refContainer.current, + props: { + mode: Mode.text, + ...propsRef.current, + }, + }); + setLoading(false); + }) + .catch((err) => { + console.error('Failed to load vanilla-jsoneditor:', err); + if (!destroyed) { + setLoadError(true); + setLoading(false); + } + }); return () => { + destroyed = true; if (refEditor.current) { refEditor.current.destroy(); + refEditor.current = null; } }; - }, []); + }, [loadCount]); useEffect(() => { - refEditor.current?.updateProps(props); - // biome-ignore lint/correctness/useExhaustiveDependencies: no need + if (refEditor.current) { + refEditor.current.updateProps(props); + } + // biome-ignore lint/correctness/useExhaustiveDependencies: update props when object props change }, [props]); - return
; + return ( +
+ {loading && ( +
+ +
+ )} + {loadError && ( +
+ } + onClick={handleRetry} + style={{ marginTop: 8 }} + > + 重新加载编辑器 + + } + /> +
+ )} +
+
+ ); } diff --git a/src/pages/manage/components/setting-modal.tsx b/src/pages/manage/components/setting-modal.tsx index 15af063..fc843af 100644 --- a/src/pages/manage/components/setting-modal.tsx +++ b/src/pages/manage/components/setting-modal.tsx @@ -1,6 +1,8 @@ import { DeleteFilled } from '@ant-design/icons'; -import { Button, Form, Input, Modal, Switch, Typography } from 'antd'; +import { Button, Form, Input, Switch, Typography } from 'antd'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { DangerousConfirmModal } from '@/components/dangerous-confirm-modal'; import { rootRouterPath, router } from '@/router'; import { useDeleteApp } from '@/services/mutations'; import { useUserInfo } from '@/utils/hooks'; @@ -12,7 +14,12 @@ const SettingModal = () => { const { appId } = useManageContext(); const deleteApp = useDeleteApp(); const appKey = Form.useWatch('appKey') as string; + const appName = Form.useWatch('name') as string; const ignoreBuildTime = Form.useWatch('ignoreBuildTime') as string; + const [showConfirmModal, setShowConfirmModal] = useState(false); + + // 优先使用 appName,如尚未设定或加载中时使用稳定的 appKey 作为二次确认识别串 + const confirmTargetText = appName?.trim() || appKey?.trim() || ''; return ( <> @@ -78,23 +85,27 @@ const SettingModal = () => { + + setShowConfirmModal(false)} + onConfirm={async () => { + await deleteApp.mutateAsync(appId); + setShowConfirmModal(false); + router.navigate(rootRouterPath.apps); + }} + /> ); }; diff --git a/src/pages/realtime-metrics.tsx b/src/pages/realtime-metrics.tsx index 598a53e..d02084f 100644 --- a/src/pages/realtime-metrics.tsx +++ b/src/pages/realtime-metrics.tsx @@ -1,4 +1,3 @@ -import { Line } from '@ant-design/charts'; import { useQuery } from '@tanstack/react-query'; import { Card, DatePicker, Input, Radio, Spin } from 'antd'; import type { Dayjs } from 'dayjs'; @@ -9,6 +8,7 @@ import { useSearchParams } from 'react-router-dom'; import { AppDetailHeader } from '@/components/app-detail-header'; import { AppDrawerLayout, useAppWorkspaceList } from '@/components/app-drawer'; import { useAppSettingsModal } from '@/components/app-settings-modal'; +import { AsyncLine } from '@/components/lazy-chart'; import { rootRouterPath, router } from '@/router'; import { api } from '@/services/api'; import { patchSearchParams, rememberRecentApp } from '@/utils/helper'; @@ -636,7 +636,7 @@ export const Component = () => { {t('realtime_metrics.please_select_app')}
) : filteredChartData.length > 0 ? ( - + ) : (
{t('realtime_metrics.no_data')} diff --git a/src/pages/version-health.tsx b/src/pages/version-health.tsx index 7b9f0ff..9c88a5a 100644 --- a/src/pages/version-health.tsx +++ b/src/pages/version-health.tsx @@ -1,4 +1,3 @@ -import { Line } from '@ant-design/charts'; import { useQuery } from '@tanstack/react-query'; import { Card, DatePicker, Spin, Table, Tag } from 'antd'; import type { Dayjs } from 'dayjs'; @@ -9,6 +8,7 @@ import { useSearchParams } from 'react-router-dom'; import { AppDetailHeader } from '@/components/app-detail-header'; import { AppDrawerLayout, useAppWorkspaceList } from '@/components/app-drawer'; import { useAppSettingsModal } from '@/components/app-settings-modal'; +import { AsyncLine } from '@/components/lazy-chart'; import { rootRouterPath, router } from '@/router'; import { api } from '@/services/api'; import { patchSearchParams, rememberRecentApp } from '@/utils/helper'; @@ -433,7 +433,7 @@ export const Component = () => { {trendData.length > 0 ? ( - + ) : (
{t('version_health.no_data')}