Commit f167d242 authored by drigle's avatar drigle

fix: make operation feedback visible and dismissible

parent 346c685a
import '@/global.less'; import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { createElement } from 'react';
import AppFeedback from '@/components/AppFeedback';
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
...@@ -15,4 +17,8 @@ export async function getInitialState() { ...@@ -15,4 +17,8 @@ export async function getInitialState() {
}; };
} }
export default defineApp({}); export function rootContainer(container) {
return createElement(AppFeedback, null, container);
}
export default defineApp({ rootContainer });
import { Toast } from '@heroui/react';
export default function AppFeedback({ children }) {
return (
<>
{children}
<Toast.Provider
aria-label="操作提示"
className="app-feedback-region"
maxVisibleToasts={3}
placement="top"
width="min(30rem, calc(100vw - 2rem))"
>
{({ toast }) => (
<Toast className="app-feedback-toast" toast={toast} variant={toast.content.variant}>
<Toast.Indicator variant={toast.content.variant} />
<Toast.Content className="min-w-0">
<Toast.Title>{toast.content.title}</Toast.Title>
{toast.content.description ? (
<Toast.Description className="max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words text-foreground">
{toast.content.description}
</Toast.Description>
) : null}
{toast.content.actionProps ? <Toast.ActionButton {...toast.content.actionProps} /> : null}
</Toast.Content>
<Toast.CloseButton aria-label="关闭提示" className="app-feedback-close" />
</Toast>
)}
</Toast.Provider>
</>
);
}
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi'; import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar'; import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar'; import DashboardSidebar from './DashboardSidebar';
...@@ -34,7 +33,6 @@ export default function AppShell({ ...@@ -34,7 +33,6 @@ export default function AppShell({
<div className="rc-app-page">{children}</div> <div className="rc-app-page">{children}</div>
</div> </div>
</Sidebar.Main> </Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider> </Sidebar.Provider>
); );
} }
import { toast } from '@heroui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
const FEEDBACK_OPTIONS = {
error: { title: '操作失败', variant: 'danger', timeout: 8000 },
success: { title: '操作成功', variant: 'success', timeout: 4000 },
warning: { title: '请注意', variant: 'warning', timeout: 8000 },
};
// Keep request state separate from dismissal: closing a prompt does not turn a failed load into success.
export default function useFeedbackState(tone = 'error') {
const [message, setMessage] = useState('');
const toastKeyRef = useRef(null);
const isMountedRef = useRef(true);
const updateMessage = useCallback((nextMessage) => {
if (!isMountedRef.current) return;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
setMessage(nextMessage);
if (!nextMessage) return;
const { title, variant, timeout } = FEEDBACK_OPTIONS[tone] || FEEDBACK_OPTIONS.error;
// Report every attempt, including repeated failures with identical messages.
toastKeyRef.current = toast(title, { description: nextMessage, variant, timeout });
}, [tone]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
};
}, []);
return [message, updateMessage];
}
...@@ -5,7 +5,8 @@ import authService from '@/services/authService'; ...@@ -5,7 +5,8 @@ import authService from '@/services/authService';
import { resolveLoginRedirect } from '@/utils/authRedirect'; import { resolveLoginRedirect } from '@/utils/authRedirect';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react'; import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi'; import { history, useSearchParams } from 'umi';
...@@ -27,7 +28,7 @@ export default function LoginPage() { ...@@ -27,7 +28,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null); const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState();
const redirect = resolveLoginRedirect(searchParams.get('redirect')); const redirect = resolveLoginRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
...@@ -172,14 +173,6 @@ export default function LoginPage() { ...@@ -172,14 +173,6 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{error ? (
<Alert className="login-error" status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button <Button
className="login-submit-button" className="login-submit-button"
fullWidth fullWidth
......
import xinyuanLogo from '@/assets/xy-logo-red.png'; import xinyuanLogo from '@/assets/xy-logo-red.png';
import { APP_DISPLAY_NAME, APP_ROUTE_NAME } from '@/constants/app'; import { APP_DISPLAY_NAME, APP_ROUTE_NAME } from '@/constants/app';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons'; import useFeedbackState from '@/hooks/useFeedbackState';
import { MagnifyingGlassIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card'; import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group'; import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import { import {
Alert,
Avatar, Avatar,
Chip, Chip,
Modal, Modal,
...@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({
const [status, setStatus] = useState( const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle', fallbackApplications.length > 0 ? 'success' : 'idle',
); );
const [errorMessage, setErrorMessage] = useState(''); const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState(''); const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => { const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME; return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
...@@ -442,13 +442,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -442,13 +442,6 @@ export default function ApplicationSwitcherDialog({
offset={12} offset={12}
size={24} size={24}
> >
{status === 'error' && errorMessage ? (
<Alert className="mb-4" status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? ( {status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? ( ) : filteredApplicationSections.length > 0 ? (
......
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import { Eye } from '@/components/AppIcons'; import { Eye } from '@/components/AppIcons';
import { Button, Description, Surface, Tabs } from '@heroui/react'; import { Button, Description, Surface, Tabs } from '@heroui/react';
...@@ -93,12 +94,14 @@ export default function ForwardApprovals() { ...@@ -93,12 +94,14 @@ export default function ForwardApprovals() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [editLoading, setEditLoading] = useState(false); const [editLoading, setEditLoading] = useState(false);
const [editSaving, setEditSaving] = useState(false); const [editSaving, setEditSaving] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState();
const [notice, setNotice] = useState(''); const [, setNotice] = useFeedbackState('success');
const [notificationWarning, setNotificationWarning] = useState(''); const [, setNotificationWarning] = useFeedbackState('warning');
const [activeRecordId, setActiveRecordId] = useState(''); const [activeRecordId, setActiveRecordId] = useState('');
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
...@@ -112,6 +115,8 @@ export default function ForwardApprovals() { ...@@ -112,6 +115,8 @@ export default function ForwardApprovals() {
(async () => { (async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
setIsDetailDialogOpen(false); setIsDetailDialogOpen(false);
setIsEditDialogOpen(false); setIsEditDialogOpen(false);
try { try {
...@@ -148,6 +153,7 @@ export default function ForwardApprovals() { ...@@ -148,6 +153,7 @@ export default function ForwardApprovals() {
} }
} catch (loadError) { } catch (loadError) {
if (!cancelled) { if (!cancelled) {
setLoadFailed(true);
setError(loadError?.message || '转发审批列表加载失败'); setError(loadError?.message || '转发审批列表加载失败');
} }
} finally { } finally {
...@@ -160,7 +166,7 @@ export default function ForwardApprovals() { ...@@ -160,7 +166,7 @@ export default function ForwardApprovals() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentUserId, forwardId]); }, [currentUserId, forwardId, reloadKey, setError]);
const editableInvolvedCount = useMemo( const editableInvolvedCount = useMemo(
() => () =>
...@@ -505,9 +511,7 @@ export default function ForwardApprovals() { ...@@ -505,9 +511,7 @@ export default function ForwardApprovals() {
/> />
{loading ? <NoticeBanner>正在加载转发审批记录...</NoticeBanner> : null} {loading ? <NoticeBanner>正在加载转发审批记录...</NoticeBanner> : null}
{!loading && error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && loadFailed ? <EmptyCollection title="转发审批加载失败" description="请重新加载,详细错误见操作提示。" action={<Button variant="secondary" onPress={() => setReloadKey((current) => current + 1)}>重新加载</Button>} /> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null}
{notificationWarning ? <NoticeBanner>{notificationWarning}</NoticeBanner> : null}
<div className="min-w-0 max-w-full"> <div className="min-w-0 max-w-full">
<Tabs className="w-fit max-w-full min-w-0" selectedKey={activeFilter} variant="primary" onSelectionChange={(key) => { setActiveFilter(String(key)); setPage(0); }}> <Tabs className="w-fit max-w-full min-w-0" selectedKey={activeFilter} variant="primary" onSelectionChange={(key) => { setActiveFilter(String(key)); setPage(0); }}>
...@@ -526,7 +530,7 @@ export default function ForwardApprovals() { ...@@ -526,7 +530,7 @@ export default function ForwardApprovals() {
{editableInvolvedCount > 0 ? <NoticeBanner>你当前有 {editableInvolvedCount} 条待填写记录。</NoticeBanner> : null} {editableInvolvedCount > 0 ? <NoticeBanner>你当前有 {editableInvolvedCount} 条待填写记录。</NoticeBanner> : null}
<section className="min-w-0"> <section className={loadFailed ? 'hidden' : 'min-w-0'}>
<SectionHeader title="转发记录列表" /> <SectionHeader title="转发记录列表" />
<DataGrid <DataGrid
aria-label="转发审批列表" aria-label="转发审批列表"
......
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
Button, Button,
Checkbox, Checkbox,
...@@ -58,10 +59,11 @@ export default function ModelBatchData() { ...@@ -58,10 +59,11 @@ export default function ModelBatchData() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState();
const [notice, setNotice] = useState(''); const [, setNotice] = useFeedbackState('success');
const [notificationWarning, setNotificationWarning] = useState(''); const [, setNotificationWarning] = useFeedbackState('warning');
const [isHiddenDialogOpen, setIsHiddenDialogOpen] = useState(false); const [isHiddenDialogOpen, setIsHiddenDialogOpen] = useState(false);
const [isForwardDialogOpen, setIsForwardDialogOpen] = useState(false); const [isForwardDialogOpen, setIsForwardDialogOpen] = useState(false);
const [forwardForm, setForwardForm] = useState(createEmptyForwardDraft); const [forwardForm, setForwardForm] = useState(createEmptyForwardDraft);
...@@ -84,6 +86,7 @@ export default function ModelBatchData() { ...@@ -84,6 +86,7 @@ export default function ModelBatchData() {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setError('');
try { try {
await ensureModelMonitorSubjects(); await ensureModelMonitorSubjects();
const [nextModel, nextBatch] = await Promise.all([loadModel(modelId), loadModelBatch(batchId)]); const [nextModel, nextBatch] = await Promise.all([loadModel(modelId), loadModelBatch(batchId)]);
...@@ -106,7 +109,7 @@ export default function ModelBatchData() { ...@@ -106,7 +109,7 @@ export default function ModelBatchData() {
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [batchId, modelId]); }, [batchId, modelId, reloadKey, setError]);
const forwardStatusIndex = useMemo(() => buildForwardStatusIndex(forwards), [forwards]); const forwardStatusIndex = useMemo(() => buildForwardStatusIndex(forwards), [forwards]);
const forwardItemByRecordId = useMemo(() => buildForwardItemIndex(forwardItems), [forwardItems]); const forwardItemByRecordId = useMemo(() => buildForwardItemIndex(forwardItems), [forwardItems]);
...@@ -229,10 +232,8 @@ export default function ModelBatchData() { ...@@ -229,10 +232,8 @@ export default function ModelBatchData() {
<div className="space-y-5"> <div className="space-y-5">
<PageHeader eyebrow={batch?.batchCode || '模型批次'} title={batch?.batchName || '批次数据'} description="展示当前批次已同步的全部业务字段。页面不提供字段筛选或图表分析,勾选记录后可隐藏或进入转发审批。" /> <PageHeader eyebrow={batch?.batchCode || '模型批次'} title={batch?.batchName || '批次数据'} description="展示当前批次已同步的全部业务字段。页面不提供字段筛选或图表分析,勾选记录后可隐藏或进入转发审批。" />
{loading ? <NoticeBanner>正在加载批次数据和转发状态...</NoticeBanner> : null} {loading ? <NoticeBanner>正在加载批次数据和转发状态...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && !batch ? <EmptyCollection title="批次数据加载失败" description="请重新加载,详细错误见操作提示。" action={<Button variant="secondary" onPress={() => setReloadKey((current) => current + 1)}>重新加载</Button>} /> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null} {batch?.syncStatus === 'partial' || batch?.syncStatus === 'failed' ? <NoticeBanner key={`${batch.id}:${batch.syncStatus}:${batch.errorMessage}`} tone="error">最近一次同步未完整成功:{batch.errorMessage || '该批次不可重试,请联系管理员排查。'}</NoticeBanner> : null}
{notificationWarning ? <NoticeBanner>{notificationWarning}</NoticeBanner> : null}
{batch?.syncStatus === 'partial' || batch?.syncStatus === 'failed' ? <NoticeBanner tone="error">最近一次同步未完整成功:{batch.errorMessage || '该批次不可重试,请联系管理员排查。'}</NoticeBanner> : null}
{!loading && batch ? ( {!loading && batch ? (
<section className="min-w-0 space-y-4"> <section className="min-w-0 space-y-4">
......
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import useFeedbackState from '@/hooks/useFeedbackState';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { Eye } from '@/components/AppIcons'; import { Eye } from '@/components/AppIcons';
import { Button, Description, Label, ProgressBar, toast } from '@heroui/react'; import { Button, Description, Label, ProgressBar, toast } from '@heroui/react';
...@@ -35,7 +36,8 @@ export default function ModelBatches() { ...@@ -35,7 +36,8 @@ export default function ModelBatches() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [reloadKey, setReloadKey] = useState(0);
const [, setError] = useFeedbackState();
const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false); const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false);
const [sourceModel, setSourceModel] = useState(null); const [sourceModel, setSourceModel] = useState(null);
const [sourceBatches, setSourceBatches] = useState([]); const [sourceBatches, setSourceBatches] = useState([]);
...@@ -54,6 +56,7 @@ export default function ModelBatches() { ...@@ -54,6 +56,7 @@ export default function ModelBatches() {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setError('');
try { try {
await ensureModelMonitorSubjects(); await ensureModelMonitorSubjects();
const nextModel = await loadModel(modelId); const nextModel = await loadModel(modelId);
...@@ -69,7 +72,7 @@ export default function ModelBatches() { ...@@ -69,7 +72,7 @@ export default function ModelBatches() {
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [modelId]); }, [modelId, reloadKey, setError]);
const filteredBatches = useMemo(() => { const filteredBatches = useMemo(() => {
const search = keyword.trim().toLowerCase(); const search = keyword.trim().toLowerCase();
...@@ -137,12 +140,21 @@ export default function ModelBatches() { ...@@ -137,12 +140,21 @@ export default function ModelBatches() {
try { try {
const result = await synchronizeModelBatch(sourceModel, sourceBatch, userInfo, setProgress); const result = await synchronizeModelBatch(sourceModel, sourceBatch, userInfo, setProgress);
await refresh(result.model); await refresh(result.model);
if (result.failedCount) {
setError(`批次“${sourceBatch.batch_name}”同步存在异常:${result.failedCount} 条数据写入失败。该批次不可再次同步,请查看批次数据中的错误摘要。`);
} else {
toast.success(`批次“${sourceBatch.batch_name}”同步完成:新增 ${result.insertedCount} 条。`); toast.success(`批次“${sourceBatch.batch_name}”同步完成:新增 ${result.insertedCount} 条。`);
setError(''); setError('');
}
setIsSyncDialogOpen(false); setIsSyncDialogOpen(false);
} catch (syncError) { } catch (syncError) {
setError(syncError?.message || '模型批次同步失败。'); const message = syncError?.message || '模型批次同步失败。';
setError(message);
try {
await refresh(); await refresh();
} catch (refreshError) {
setError(`${message} 刷新批次状态失败:${refreshError?.message || '请稍后刷新页面确认。'}`);
}
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
...@@ -152,7 +164,7 @@ export default function ModelBatches() { ...@@ -152,7 +164,7 @@ export default function ModelBatches() {
<div className="space-y-5"> <div className="space-y-5">
<PageHeader eyebrow={model?.modelCode || '模型'} title={model?.modelName || '模型批次'} description="这里仅展示已经同步到本系统的批次。外部未同步批次通过右侧同步入口选择。" /> <PageHeader eyebrow={model?.modelCode || '模型'} title={model?.modelName || '模型批次'} description="这里仅展示已经同步到本系统的批次。外部未同步批次通过右侧同步入口选择。" />
{loading ? <NoticeBanner>正在加载模型批次...</NoticeBanner> : null} {loading ? <NoticeBanner>正在加载模型批次...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && !model ? <EmptyCollection title="模型批次加载失败" description="请重新加载,详细错误见操作提示。" action={<Button variant="secondary" onPress={() => setReloadKey((current) => current + 1)}>重新加载</Button>} /> : null}
{!loading && model ? ( {!loading && model ? (
<section className="min-w-0 space-y-4"> <section className="min-w-0 space-y-4">
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
Calendar, Calendar,
Checkbox, Checkbox,
Chip, Chip,
CloseButton,
CloseIcon, CloseIcon,
DateField, DateField,
DatePicker, DatePicker,
...@@ -32,6 +33,7 @@ import { parseDateTime } from '@internationalized/date'; ...@@ -32,6 +33,7 @@ import { parseDateTime } from '@internationalized/date';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'umi'; import { Link } from 'umi';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
APPROVAL_STATUS_OPTIONS, APPROVAL_STATUS_OPTIONS,
formatDateTime, formatDateTime,
...@@ -129,8 +131,15 @@ export function WorkflowStatusTag({ status }) { ...@@ -129,8 +131,15 @@ export function WorkflowStatusTag({ status }) {
} }
export function NoticeBanner({ tone = 'info', children }) { export function NoticeBanner({ tone = 'info', children }) {
const [isDismissed, setIsDismissed] = useState(false);
const status = tone === 'error' ? 'danger' : tone === 'success' ? 'success' : 'accent'; const status = tone === 'error' ? 'danger' : tone === 'success' ? 'success' : 'accent';
return <Alert status={status}><Alert.Content><Alert.Description>{children}</Alert.Description></Alert.Content></Alert>; if (isDismissed) return null;
return (
<Alert status={status}>
<Alert.Content className="min-w-0"><Alert.Description className="break-words">{children}</Alert.Description></Alert.Content>
{tone === 'error' ? <CloseButton aria-label="关闭错误摘要" className="size-11 shrink-0" onPress={() => setIsDismissed(true)} /> : null}
</Alert>
);
} }
export function EmptyCollection({ title, description, action }) { export function EmptyCollection({ title, description, action }) {
...@@ -183,12 +192,20 @@ export function UserSelectField({ value, onChange, label, required = false, plac ...@@ -183,12 +192,20 @@ export function UserSelectField({ value, onChange, label, required = false, plac
const [query, setQuery] = useState(value?.label || ''); const [query, setQuery] = useState(value?.label || '');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError('');
searchUsers(query) searchUsers(query)
.then((result) => { if (!cancelled) setOptions(result); }) .then((result) => { if (!cancelled) setOptions(result); })
.catch((loadError) => {
if (!cancelled) {
setOptions([]);
setError(loadError?.message || '用户列表加载失败,请重新搜索。');
}
})
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [query]); }, [query]);
...@@ -202,6 +219,7 @@ export function UserSelectField({ value, onChange, label, required = false, plac ...@@ -202,6 +219,7 @@ export function UserSelectField({ value, onChange, label, required = false, plac
? [value, ...options] ? [value, ...options]
: options; : options;
return <XinyuanAutocomplete return <XinyuanAutocomplete
errorText={error}
emptyText={loading ? '正在加载...' : '没有匹配用户'} emptyText={loading ? '正在加载...' : '没有匹配用户'}
inputValue={query} inputValue={query}
isLoading={loading} isLoading={loading}
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
AppGrid, AppGrid,
Clock, Clock,
...@@ -10,7 +11,7 @@ import { ItemCardGroup } from '@heroui-pro/react/item-card-group'; ...@@ -10,7 +11,7 @@ import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import { KPI } from '@heroui-pro/react/kpi'; import { KPI } from '@heroui-pro/react/kpi';
import { ListView } from '@heroui-pro/react/list-view'; import { ListView } from '@heroui-pro/react/list-view';
import { NumberValue } from '@heroui-pro/react/number-value'; import { NumberValue } from '@heroui-pro/react/number-value';
import { Card, ScrollShadow } from '@heroui/react'; import { Button, Card, ScrollShadow } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
import { import {
...@@ -32,12 +33,14 @@ export default function ModelOverview() { ...@@ -32,12 +33,14 @@ export default function ModelOverview() {
const [batches, setBatches] = useState([]); const [batches, setBatches] = useState([]);
const [forwards, setForwards] = useState([]); const [forwards, setForwards] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState();
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setError('');
try { try {
await ensureModelMonitorSubjects(); await ensureModelMonitorSubjects();
const [modelList, batchList, forwardList] = await Promise.all([ const [modelList, batchList, forwardList] = await Promise.all([
...@@ -58,7 +61,7 @@ export default function ModelOverview() { ...@@ -58,7 +61,7 @@ export default function ModelOverview() {
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [reloadKey, setError]);
const totalRecords = useMemo( const totalRecords = useMemo(
() => batches.reduce((total, batch) => total + batch.totalRecordCount, 0), () => batches.reduce((total, batch) => total + batch.totalRecordCount, 0),
...@@ -124,7 +127,7 @@ export default function ModelOverview() { ...@@ -124,7 +127,7 @@ export default function ModelOverview() {
/> />
{loading ? <NoticeBanner>正在初始化模型监控对象并加载概览...</NoticeBanner> : null} {loading ? <NoticeBanner>正在初始化模型监控对象并加载概览...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && error ? <EmptyCollection title="模型总览加载失败" description="请重新加载,详细错误见操作提示。" action={<Button variant="secondary" onPress={() => setReloadKey((current) => current + 1)}>重新加载</Button>} /> : null}
{!loading && !error ? ( {!loading && !error ? (
<> <>
...@@ -145,7 +148,7 @@ export default function ModelOverview() { ...@@ -145,7 +148,7 @@ export default function ModelOverview() {
</div> </div>
{failedBatches.length ? ( {failedBatches.length ? (
<NoticeBanner tone="error"> <NoticeBanner key={failedBatches.map((batch) => batch.id).join(',')} tone="error">
有 {failedBatches.length} 个批次同步异常。请进入对应模型批次查看错误摘要;一次性同步批次不允许重试。 有 {failedBatches.length} 个批次同步异常。请进入对应模型批次查看错误摘要;一次性同步批次不允许重试。
</NoticeBanner> </NoticeBanner>
) : null} ) : null}
......
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import useFeedbackState from '@/hooks/useFeedbackState';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { Delete, Edit, Eye, FolderOpenIcon, More, PlusIcon } from '@/components/AppIcons'; import { Delete, Edit, Eye, FolderOpenIcon, More, PlusIcon } from '@/components/AppIcons';
import { import {
...@@ -144,8 +145,10 @@ export default function Models() { ...@@ -144,8 +145,10 @@ export default function Models() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [notice, setNotice] = useState(''); const [reloadKey, setReloadKey] = useState(0);
const [, setError] = useFeedbackState();
const [, setNotice] = useFeedbackState('success');
const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false); const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false);
const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false); const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false);
...@@ -164,7 +167,7 @@ export default function Models() { ...@@ -164,7 +167,7 @@ export default function Models() {
const [syncLoading, setSyncLoading] = useState(false); const [syncLoading, setSyncLoading] = useState(false);
const [syncProgress, setSyncProgress] = useState({ completed: 0, total: 0 }); const [syncProgress, setSyncProgress] = useState({ completed: 0, total: 0 });
const [syncResult, setSyncResult] = useState(null); const [syncResult, setSyncResult] = useState(null);
const [syncDialogError, setSyncDialogError] = useState(''); const [, setSyncDialogError] = useFeedbackState();
async function refresh() { async function refresh() {
const [modelList, groupList] = await Promise.all([loadModels(), loadModelGroups()]); const [modelList, groupList] = await Promise.all([loadModels(), loadModelGroups()]);
...@@ -176,17 +179,22 @@ export default function Models() { ...@@ -176,17 +179,22 @@ export default function Models() {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
try { try {
await ensureModelMonitorSubjects(); await ensureModelMonitorSubjects();
if (!cancelled) await refresh(); if (!cancelled) await refresh();
} catch (loadError) { } catch (loadError) {
if (!cancelled) setError(loadError?.message || '模型列表初始化失败。'); if (!cancelled) {
setLoadFailed(true);
setError(loadError?.message || '模型列表初始化失败。');
}
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [reloadKey, setError]);
const groupTree = useMemo(() => buildGroupTree(groups), [groups]); const groupTree = useMemo(() => buildGroupTree(groups), [groups]);
const groupOptions = useMemo(() => flattenGroupOptions(groupTree), [groupTree]); const groupOptions = useMemo(() => flattenGroupOptions(groupTree), [groupTree]);
...@@ -440,7 +448,11 @@ export default function Models() { ...@@ -440,7 +448,11 @@ export default function Models() {
failedRecordCount, failedRecordCount,
failures, failures,
}); });
if (failures.length) {
setSyncDialogError(`同步结束:${failures.length} 个批次存在异常,共新增 ${insertedCount} 条数据。请查看弹窗中的逐项错误摘要。`);
} else {
setNotice(`已完成 ${completedCodes.length}/${selectedEntries.length} 个批次同步,共新增 ${insertedCount} 条数据。`); setNotice(`已完成 ${completedCodes.length}/${selectedEntries.length} 个批次同步,共新增 ${insertedCount} 条数据。`);
}
setError(''); setError('');
const [, localBatchList] = await Promise.all([refresh(), loadModelBatches()]); const [, localBatchList] = await Promise.all([refresh(), loadModelBatches()]);
const nextSyncedCodes = localBatchList.map((batch) => String(batch.batchCode)); const nextSyncedCodes = localBatchList.map((batch) => String(batch.batchCode));
...@@ -458,10 +470,9 @@ export default function Models() { ...@@ -458,10 +470,9 @@ export default function Models() {
<PageHeader eyebrow="模型监控" title="全部模型" description="模型由同步自动创建。你可以用无限级分组、标签和说明整理模型,点击模型后进入独立批次页面。" /> <PageHeader eyebrow="模型监控" title="全部模型" description="模型由同步自动创建。你可以用无限级分组、标签和说明整理模型,点击模型后进入独立批次页面。" />
<div className="space-y-8"> <div className="space-y-8">
{loading ? <NoticeBanner>正在初始化模型对象并加载列表...</NoticeBanner> : null} {loading ? <NoticeBanner>正在初始化模型对象并加载列表...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && loadFailed ? <EmptyCollection title="模型列表加载失败" description="请重新加载,详细错误见操作提示。" action={<Button variant="secondary" onPress={() => setReloadKey((current) => current + 1)}>重新加载</Button>} /> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null}
{!loading ? ( {!loading && !loadFailed ? (
<> <>
<div className="grid min-w-0 gap-5 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start"> <div className="grid min-w-0 gap-5 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start">
<Surface className="min-w-0 rounded-3xl p-4" variant="default"> <Surface className="min-w-0 rounded-3xl p-4" variant="default">
...@@ -591,7 +602,6 @@ export default function Models() { ...@@ -591,7 +602,6 @@ export default function Models() {
<div className="space-y-5"> <div className="space-y-5">
<Stepper aria-label="同步步骤" className="mx-auto max-w-4xl" currentStep={syncStep - 1} size="sm">{['加载批次', '选择批次', '执行同步'].map((label) => <Stepper.Step key={label}><Stepper.Indicator /><Stepper.Content><Stepper.Title>{label}</Stepper.Title></Stepper.Content><Stepper.Separator /></Stepper.Step>)}</Stepper> <Stepper aria-label="同步步骤" className="mx-auto max-w-4xl" currentStep={syncStep - 1} size="sm">{['加载批次', '选择批次', '执行同步'].map((label) => <Stepper.Step key={label}><Stepper.Indicator /><Stepper.Content><Stepper.Title>{label}</Stepper.Title></Stepper.Content><Stepper.Separator /></Stepper.Step>)}</Stepper>
{syncDialogError ? <NoticeBanner tone="error">{syncDialogError}</NoticeBanner> : null}
{syncStep === 1 && syncLoading ? <NoticeBanner>正在加载全部来源模型、批次和本地同步状态...</NoticeBanner> : null} {syncStep === 1 && syncLoading ? <NoticeBanner>正在加载全部来源模型、批次和本地同步状态...</NoticeBanner> : null}
{syncStep === 2 ? ( {syncStep === 2 ? (
...@@ -663,7 +673,7 @@ export default function Models() { ...@@ -663,7 +673,7 @@ export default function Models() {
<Surface className="rounded-2xl p-4" variant="secondary"> <Surface className="rounded-2xl p-4" variant="secondary">
<ProgressBar aria-label="批量同步进度" value={syncProgress.total ? Math.round(syncProgress.completed / syncProgress.total * 100) : syncLoading ? 10 : 0}><div className="flex items-start justify-between gap-4 text-sm"><div className="min-w-0"><Label>{syncLoading ? '正在逐批校验并写入 Record...' : '批量同步已结束'}</Label>{syncProgress.currentBatch ? <Description className="mt-1 truncate">{syncProgress.currentBatch}{syncProgress.currentRecordsTotal ? ` · ${syncProgress.currentRecordsCompleted}/${syncProgress.currentRecordsTotal} 条` : ''}</Description> : null}</div><ProgressBar.Output>{syncProgress.completed}/{syncProgress.total}</ProgressBar.Output></div><ProgressBar.Track><ProgressBar.Fill /></ProgressBar.Track></ProgressBar> <ProgressBar aria-label="批量同步进度" value={syncProgress.total ? Math.round(syncProgress.completed / syncProgress.total * 100) : syncLoading ? 10 : 0}><div className="flex items-start justify-between gap-4 text-sm"><div className="min-w-0"><Label>{syncLoading ? '正在逐批校验并写入 Record...' : '批量同步已结束'}</Label>{syncProgress.currentBatch ? <Description className="mt-1 truncate">{syncProgress.currentBatch}{syncProgress.currentRecordsTotal ? ` · ${syncProgress.currentRecordsCompleted}/${syncProgress.currentRecordsTotal} 条` : ''}</Description> : null}</div><ProgressBar.Output>{syncProgress.completed}/{syncProgress.total}</ProgressBar.Output></div><ProgressBar.Track><ProgressBar.Fill /></ProgressBar.Track></ProgressBar>
{syncResult ? <div className="mt-4 grid grid-cols-2 gap-3 text-center text-sm sm:grid-cols-4"><div><div className="font-medium">{syncResult.completedBatchCount}</div><Description>完成批次</Description></div><div><div className="font-medium">{syncResult.failedBatchCount}</div><Description>异常批次</Description></div><div><div className="font-medium">{syncResult.insertedCount}</div><Description>新增数据</Description></div><div><div className="font-medium">{syncResult.failedRecordCount}</div><Description>失败数据</Description></div></div> : null} {syncResult ? <div className="mt-4 grid grid-cols-2 gap-3 text-center text-sm sm:grid-cols-4"><div><div className="font-medium">{syncResult.completedBatchCount}</div><Description>完成批次</Description></div><div><div className="font-medium">{syncResult.failedBatchCount}</div><Description>异常批次</Description></div><div><div className="font-medium">{syncResult.insertedCount}</div><Description>新增数据</Description></div><div><div className="font-medium">{syncResult.failedRecordCount}</div><Description>失败数据</Description></div></div> : null}
{syncResult?.failures?.length ? <div className="mt-4"><NoticeBanner tone="error">{syncResult.failures.map((failure) => `${failure.label}:${failure.message}`).join(';')}</NoticeBanner></div> : null} {syncResult?.failures?.length ? <div className="mt-4"><NoticeBanner key={syncResult.failures.map((failure) => `${failure.label}:${failure.message}`).join(';')} tone="error">{syncResult.failures.map((failure) => `${failure.label}:${failure.message}`).join(';')}</NoticeBanner></div> : null}
</Surface> </Surface>
) : null} ) : null}
</div> </div>
......
...@@ -169,6 +169,17 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` ...@@ -169,6 +169,17 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
- 本期不提供模型或批次删除,也不限制同步和分组维护权限。 - 本期不提供模型或批次删除,也不限制同步和分组维护权限。
- 模型列表、批次页和数据页必须支持直接 URL 访问及稳定面包屑返回。 - 模型列表、批次页和数据页必须支持直接 URL 访问及稳定面包屑返回。
### 5.1 操作提示与关闭
- 登录、站点配置、模型加载、分组/模型保存、同步、隐藏/恢复、转发、填写和审批失败时,统一显示 HeroUI Toast 浮层;用户查询失败同时在人员选择器内显示加载错误,保留当前表单和选中记录。
- 提示挂载在应用根部,通过 Portal 呈现在视口顶部,层级高于业务 Modal、删除确认和移动侧栏;弹窗打开时也能看到并关闭提示,提示出现不抢走表单焦点。
- 错误和通知处理异常提示默认 8 秒后消失,成功提示默认 4 秒后消失;悬停或键盘聚焦时暂停计时。关闭按钮始终可见,移动端也可直接点击,无需先悬停。
- 再次提交发生同样错误时重新弹出提示,并替换同一来源的上一条提示。清空错误或离开对应页面时清理该来源的提示;关闭提示只影响展示,不把失败状态改为成功、不清空表单、不触发保存或再次同步。
- 模型总览、模型列表、批次、批次数据和转发审批加载失败时显示可重新加载的空状态,详细原因由浮层提示;重新加载只读取页面数据,不重新同步批次。通知中心的重试方式见对应流程文档。
- 单批次同步返回部分写入失败时显示异常提示,不显示完整成功;批次继续保留错误摘要和不可再次同步限制。
- 批量同步结束存在失败批次时,额外弹出异常汇总,逐项错误仍保留在同步弹窗的结果区;结果区、概览和批次数据的同步异常摘要均可关闭。关闭摘要不修改 `sync_status/error_message`,一次性同步批次仍不能重试。
- 转发或审批状态已写入但站内通知/OA 处理失败时使用 warning 提示,保留“业务状态已更新”的说明,不回滚业务状态。
## 6. 文档与历史数据 ## 6. 文档与历史数据
- 旧 `analysis_*` Subject 和 Record 保留,不迁移、不删除。 - 旧 `analysis_*` Subject 和 Record 保留,不迁移、不删除。
......
import { Bell, Close, OpenExternal, Search } from '@/components/AppIcons'; import { Bell, Close, OpenExternal, Search } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Card, Card,
...@@ -200,7 +200,7 @@ export default function NotificationListPage() { ...@@ -200,7 +200,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0); const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState();
const [busyKey, setBusyKey] = useState(''); const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -404,14 +404,13 @@ export default function NotificationListPage() { ...@@ -404,14 +404,13 @@ export default function NotificationListPage() {
</div> </div>
{error ? ( {error ? (
<Alert status="danger"> <EmptyState className="min-h-64" size="md">
<Alert.Content> <EmptyState.Header>
<Alert.Description>{error}</Alert.Description> <EmptyState.Title>通知列表加载失败</EmptyState.Title>
</Alert.Content> <EmptyState.Description>请重新加载通知,详细错误见操作提示。</EmptyState.Description>
<Button size="sm" variant="ghost" onPress={refresh}> </EmptyState.Header>
重试 <EmptyState.Content><Button variant="secondary" onPress={refresh}>重试</Button></EmptyState.Content>
</Button> </EmptyState>
</Alert>
) : null} ) : null}
{loading && notifications.length === 0 ? ( {loading && notifications.length === 0 ? (
......
...@@ -35,5 +35,6 @@ ...@@ -35,5 +35,6 @@
- 所有请求继续使用现有登录 Token、请求拦截器和退出处理,不新增通知专用鉴权。 - 所有请求继续使用现有登录 Token、请求拦截器和退出处理,不新增通知专用鉴权。
- 接口 401/403、网络失败或动态 action 配置缺失时不得伪造成功状态或未读数量。 - 接口 401/403、网络失败或动态 action 配置缺失时不得伪造成功状态或未读数量。
- 加载或操作错误使用应用统一 HeroUI Toast 浮层,覆盖在弹窗和侧栏上方;关闭按钮始终可见,加载错误默认 8 秒自动消失,操作错误默认 4 秒自动消失,悬停/聚焦暂停计时。列表加载失败时保留“重试”空状态,关闭提示不清除失败状态,点击重试后重新请求列表。
- 通知中心不读取或修改模型监控 Subject、Record、审批字段及用户 `applications` 授权结构。 - 通知中心不读取或修改模型监控 Subject、Record、审批字段及用户 `applications` 授权结构。
- 本应用不在五项完整功能白名单中,不实现 `shortTitle`、Subject View 权限或审计材料中心文件工作流。 - 本应用不在五项完整功能白名单中,不实现 `shortTitle`、Subject View 权限或审计材料中心文件工作流。
...@@ -31,6 +31,26 @@ html { ...@@ -31,6 +31,26 @@ html {
color-scheme: light !important; color-scheme: light !important;
} }
/* Toasts must stay above dialogs and the mobile sidebar, outside page scroll containers. */
.app-feedback-region.toast-region {
z-index: 300;
top: max(1rem, env(safe-area-inset-top));
}
.app-feedback-toast.toast {
gap: 0.5rem;
padding-inline-end: 3.5rem;
}
.app-feedback-toast[data-frontmost="true"] .app-feedback-close {
top: 0.25rem;
right: 0.25rem;
width: 2.75rem;
height: 2.75rem;
opacity: 1;
pointer-events: auto;
}
body { body {
margin: 0; margin: 0;
font-family: var(--rc-font-sans); font-family: var(--rc-font-sans);
......
import { getSystemInfo } from '@/services/DataService'; import { getSystemInfo } from '@/services/DataService';
import useFeedbackState from '@/hooks/useFeedbackState';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import localeData from 'dayjs/plugin/localeData'; import localeData from 'dayjs/plugin/localeData';
...@@ -18,7 +19,7 @@ export default function SiteWrapper() { ...@@ -18,7 +19,7 @@ export default function SiteWrapper() {
const [siteInfo, setSiteInfo] = useState(null); const [siteInfo, setSiteInfo] = useState(null);
const [metadata, setMetadata] = useState(null); const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useFeedbackState();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment