diff --git a/package.json b/package.json index 1de8c01..fed92ba 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "dev": "PUBLIC_API=https://update.reactnative.cn/api rsbuild dev", "dev:local": "PUBLIC_API=http://localhost:9000 rsbuild dev", "build": "NODE_ENV=production rsbuild build", + "build:analyze": "BUNDLE_ANALYZE=true NODE_ENV=production rsbuild build", "preview": "rsbuild preview", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "bun test", 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.tsx b/src/components/dangerous-confirm-modal.tsx new file mode 100644 index 0000000..4a719b1 --- /dev/null +++ b/src/components/dangerous-confirm-modal.tsx @@ -0,0 +1,106 @@ +import { ExclamationCircleFilled } from '@ant-design/icons'; +import { Alert, Input, Modal, Typography } from 'antd'; +import { type ReactNode, useEffect, useState } from 'react'; + +const { Text } = Typography; + +export interface DangerousConfirmModalProps { + open: boolean; + title: string; + description: ReactNode; + expectedConfirmText?: string; + confirmPlaceholder?: string; + dangerButtonText?: string; + loading?: boolean; + onCancel: () => void; + onConfirm: () => void | Promise; +} + +/** + * 高危毁灭性操作二次确认 Guard 弹窗组件 + * 当配置了 expectedConfirmText 时,用户必须在输入框中输入匹配的文本方可点击确认。 + */ +export function DangerousConfirmModal({ + open, + title, + description, + expectedConfirmText, + confirmPlaceholder = '请输入对应的确认名称以继续', + dangerButtonText = '确认执行', + loading = false, + onCancel, + onConfirm, +}: DangerousConfirmModalProps) { + 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; + + return ( + + + {title} + + } + onCancel={onCancel} + onOk={onConfirm} + okText={dangerButtonText} + okButtonProps={{ + danger: true, + type: 'primary', + disabled: !isMatched, + loading, + }} + cancelText="取消" + destroyOnClose + > + + + {expectedConfirmText && ( +
+

+ 为防止误操作,请输入提示文本{' '} + + {expectedConfirmText} + {' '} + 以进行确认: +

+ setInputText(e.target.value)} + placeholder={confirmPlaceholder} + 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.tsx b/src/components/section-error-boundary.tsx new file mode 100644 index 0000000..f286ca1 --- /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/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..8f28c8f 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 { 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,11 @@ 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); + + const confirmTargetText = appName?.trim() || ''; return ( <> @@ -78,23 +84,28 @@ const SettingModal = () => { + + setShowConfirmModal(false)} + onConfirm={async () => { + await deleteApp.mutateAsync(appId); + setShowConfirmModal(false); + Modal.destroyAll(); + 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')}