Commit 5bc7b82c authored by drigle's avatar drigle

fix: unify HeroUI operation feedback and retry states

parent 07785e81
...@@ -35,6 +35,7 @@ Global CSS order: ...@@ -35,6 +35,7 @@ Global CSS order:
@import "./design-system/theme.css"; @import "./design-system/theme.css";
@import "./design-system/tokens.css"; @import "./design-system/tokens.css";
@import "./design-system/shell.css"; @import "./design-system/shell.css";
@import "./design-system/feedback.css";
``` ```
The official licensed package is installed independently in this repository. The official licensed package is installed independently in this repository.
...@@ -105,7 +106,18 @@ typing alone never reloads the list. ...@@ -105,7 +106,18 @@ typing alone never reloads the list.
ActionBar and compound Pagination. ActionBar and compound Pagination.
- All ECharts Pie/Donut series use the shared `XinyuanEChart` adapter; pages do - All ECharts Pie/Donut series use the shared `XinyuanEChart` adapter; pages do
not provide pie `itemStyle.color` values or replace the dedicated palette. not provide pie `itemStyle.color` values or replace the dedicated palette.
- Routine success feedback is a fixed, auto-dismissing Toast. - API success, error and fallback warnings use the root `AppFeedback` HeroUI
`Toast.Provider` with official compound components. Success lasts 4 seconds;
errors and warnings last 8 seconds. Closing feedback does not clear request
failure state or form drafts; failed reads retain an EmptyState and retry.
- `feedback.css` only adjusts Toast stacking, safe-area positioning and hiding
close buttons on background toasts, following problem rectification commit
`bfc5862`. The region uses z-index 300 above business Modals and mobile Sidebar.
The close icon is a HeroUI `Button` (`ghost`, `sm`, `isIconOnly`) using the
official `slot="close"` and `AppIcons.Close`; it stays visible on the frontmost
toast and participates in layout to the right of the content. No absolute
positioning, enlarged bordered close button or extra right padding is added.
Colors, borders, radius, shadow, hover and focus use HeroUI theme defaults.
- Modal view mode uses one secondary Surface for the complete read-only field - Modal view mode uses one secondary Surface for the complete read-only field
group; edit mode removes that Surface and renders secondary fields directly. group; edit mode removes that Surface and renders secondary fields directly.
- Detail view modal footers expose only the contextual edit action; destructive - Detail view modal footers expose only the contextual edit action; destructive
......
{ {
"name": "audit_material_manager", "name": "audit_material_manager",
"version": "1.1.4", "version": "1.0.4",
"build": "abc", "build": "abc",
"description": "merge master", "description": "merge master",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
import "@/global.less"; import "@/global.less";
import "@/tailwind.css"; import "@/tailwind.css";
import { Toast } from "@heroui/react"; import AppFeedback from "@/components/AppFeedback";
import { createElement, Fragment } from "react"; import { createElement } from "react";
import { defineApp } from "umi"; import { defineApp } from "umi";
export async function getInitialState() { export async function getInitialState() {
...@@ -12,11 +12,6 @@ export async function getInitialState() { ...@@ -12,11 +12,6 @@ export async function getInitialState() {
export default defineApp({ export default defineApp({
rootContainer(container) { rootContainer(container) {
return createElement( return createElement(AppFeedback, null, container);
Fragment,
null,
container,
createElement(Toast.Provider, { placement: "top" }),
);
}, },
}); });
import { Button, Toast } from '@heroui/react';
import { Close } from '@/components/AppIcons';
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 gap-2" 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>
<Button
aria-label="关闭提示"
className="app-feedback-close shrink-0 self-start"
isIconOnly
size="sm"
slot="close"
variant="ghost"
>
<Close size={16} />
</Button>
</Toast>
)}
</Toast.Provider>
</>
);
}
/* Keep operation feedback above Modal and the mobile sidebar, outside page scrolling. */
.app-feedback-region.toast-region {
z-index: 300;
top: max(1rem, env(safe-area-inset-top));
}
/* Only the front toast is interactive; its ghost close button stays visible without hover. */
.app-feedback-toast:not([data-frontmost="true"]) .app-feedback-close {
visibility: hidden;
pointer-events: none;
}
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 },
};
// Dismissing feedback must not clear the request's failure state or the form draft.
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;
// Every attempt reports feedback, including identical errors on a retry.
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];
}
import useFeedbackState from '@/hooks/useFeedbackState';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { import {
APPLICATION_BASE_PATH, APPLICATION_BASE_PATH,
...@@ -5,7 +6,7 @@ import { ...@@ -5,7 +6,7 @@ import {
} from '@/config/application'; } from '@/config/application';
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 { 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';
...@@ -54,7 +55,7 @@ export default function LoginPage() { ...@@ -54,7 +55,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('error');
const redirect = normalizeRedirect(searchParams.get('redirect')); const redirect = normalizeRedirect(searchParams.get('redirect'));
...@@ -209,14 +210,6 @@ export default function LoginPage() { ...@@ -209,14 +210,6 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button <Button
fullWidth fullWidth
className="login-submit-button" className="login-submit-button"
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
import { ExclamationCircleIcon, MagnifyingGlassIcon, XMarkIcon } from '@/components/AppIcons'; import { MagnifyingGlassIcon, XMarkIcon } from '@/components/AppIcons';
import xinyuanLogo from '@/assets/xy-logo-red.png'; import xinyuanLogo from '@/assets/xy-logo-red.png';
import { import {
APPLICATION_BASE_PATH, APPLICATION_BASE_PATH,
...@@ -9,7 +10,6 @@ import { EmptyState } from '@heroui-pro/react/empty-state'; ...@@ -9,7 +10,6 @@ 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,
Description, Description,
...@@ -291,7 +291,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -291,7 +291,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 || ''; return applications.find(isCurrentApplication)?.name || '';
...@@ -332,6 +332,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -332,6 +332,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setApplicationSearch(''); setApplicationSearch('');
setErrorMessage('');
return; return;
} }
...@@ -372,7 +373,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -372,7 +373,7 @@ export default function ApplicationSwitcherDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [fallbackApplications, open]); }, [fallbackApplications, open, setErrorMessage]);
return ( return (
<Modal.Root> <Modal.Root>
...@@ -418,15 +419,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -418,15 +419,6 @@ export default function ApplicationSwitcherDialog({
<Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}> <Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}>
<ScrollShadow className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1" hideScrollBar={false} offset={12} size={24}> <ScrollShadow className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1" hideScrollBar={false} offset={12} size={24}>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator><ExclamationCircleIcon 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 ? (
......
...@@ -142,11 +142,11 @@ ...@@ -142,11 +142,11 @@
- 提供部门、提供时间 - 提供部门、提供时间
- 涉密标记、审计部材料标记 - 涉密标记、审计部材料标记
13. 创建、修改、批量操作、下载和删除成功后,以视口顶部居中的 Toast 提示结果,并在 4 秒后自动消失;失败信息保留在当前业务区域,便于用户定位和重试 13. 创建、修改、批量操作、下载和删除的成功、失败反馈统一使用视口顶部居中的 Toast;成功默认 4 秒、错误默认 8 秒后消失,关闭按钮始终可见,弹框打开时也可查看和关闭
14. 列表或统计请求失败时保留真实错误态并展示重试提示;真实查询结果为空时展示空状态,不注入演示材料、测试数字或伪造项目记录 14. 列表或统计请求失败时保留真实错误态并展示重试提示;真实查询结果为空时展示空状态,不注入演示材料、测试数字或伪造项目记录
15. 用户点击“编辑”后进入编辑态,可修改材料标题、所属周期/年份、来源应用、来源系统、分类、标签、提供信息与合规属性;来源应用使用文本输入,来源系统和材料分类从配置下拉选择 15. 用户点击“编辑”后进入编辑态,可修改材料标题、所属周期/年份、来源应用、来源系统、分类、标签、提供信息与合规属性;来源应用使用文本输入,来源系统和材料分类从配置下拉选择
16. 编辑态下点击“取消编辑”放弃未保存修改并恢复当前材料原值 16. 编辑态下点击“取消编辑”放弃未保存修改并恢复当前材料原值
17. 编辑态下点击“保存修改”后调用材料更新接口,保存成功后生效并回到查看态 17. 编辑态下点击“保存修改”后调用材料更新接口;保存成功并完成列表刷新后回到查看态,弹框继续展示已保存内容,并弹出“详情已保存”成功提示
18. 详情弹框提供单个删除入口,删除前需二次确认,确认后对当前材料执行软删除并关闭弹框 18. 详情弹框提供单个删除入口,删除前需二次确认,确认后对当前材料执行软删除并关闭弹框
19. 详情弹框中的编辑区域按“基础信息 / 提供信息 / 合规属性”三段式维护,减少字段混排 19. 详情弹框中的编辑区域按“基础信息 / 提供信息 / 合规属性”三段式维护,减少字段混排
...@@ -156,6 +156,17 @@ ...@@ -156,6 +156,17 @@
2. 默认优先软删除,不建议直接物理删除文件 2. 默认优先软删除,不建议直接物理删除文件
3. 如需保留历史,可通过分类、标签或状态进行归档管理 3. 如需保留历史,可通过分类、标签或状态进行归档管理
### 4.5 操作提示与失败重试
- 功能入口:登录与验证码、站点配置、应用切换、工作台、材料归档(上传、编辑、复制链接、下载、删除及批量操作)和通知中心。
- 根部统一挂载 HeroUI `Toast.Provider`,接口成功、失败及配置降级警告以浮层显示;Portal 位于视口顶部,层级高于业务 Modal、确认框和移动侧栏,页面滚动时仍可见,不抢走表单焦点。接口反馈不再渲染为页面常驻横条;字段配置错误、必填规则及删除确认等说明保留在对应控件或确认框。
- 成功默认 4 秒、错误和警告默认 8 秒后消失,悬停或键盘聚焦暂停计时。当前提示右侧的关闭图标始终可见,使用 HeroUI 小号透明按钮,通过 Toast 的 `close` slot 支持点击和键盘关闭;后排堆叠提示隐藏关闭按钮,避免误关闭。相同错误再次发生时重新提示,同一来源的新提示替换旧提示;清空反馈或离开对应页面时清理该来源提示,关闭应用切换弹框时清理其降级提示。
- 关闭或自动消失仅影响提示展示,不把失败改为成功,不修改 `am_material.metadata`,不清空上传文件、编辑草稿或选中记录,不触发写入操作。保存、上传和批量操作失败时,用户可修正内容后再次提交。
- 材料详情弹框仅在 `updateMaterialRecord` 成功后显示“详情已保存”;提示在列表刷新结束并回到查看态时开始计时,避免刷新等待耗尽提示时长。更新失败仅显示错误并保留编辑草稿;材料标题为空或正在保存时不提交、不显示成功。重新开始编辑、取消编辑、再次保存、切换材料或关闭详情弹框时清理上一条成功提示。
- 材料列表、工作台和通知加载失败时显示失败空状态及“重新加载/重试”,详细原因通过 Toast 展示,避免将加载失败误显示为“暂无数据”或零值统计。关闭 Toast 后失败空状态仍保留,只有读取成功后才恢复数据展示。
- 列表重试保留已提交的字段筛选、材料分类和页码;通知重试保留消息/待办、全部/未读、关键词和页码。重试仅重新读取资源,不重新执行上传、保存、删除或待办操作。
- 来源系统和材料分类仍从 Subject `settings.options` 读取;配置读取异常以 warning 提示,并在选择器中保留字段错误与禁用状态。应用列表失败继续回退到账号应用信息,站点配置失败继续使用现有降级配置。
--- ---
## 5. 联动规则 ## 5. 联动规则
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons'; import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable'; import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Card, Card,
...@@ -11,7 +11,6 @@ import { ...@@ -11,7 +11,6 @@ import {
SearchField, SearchField,
Spinner, Spinner,
Tabs, Tabs,
toast,
} from '@heroui/react'; } from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react'; import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
...@@ -150,7 +149,9 @@ export default function NotificationListPage() { ...@@ -150,7 +149,9 @@ 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('error');
const [, setOperationError] = useFeedbackState('error');
const [, setSuccess] = useFeedbackState('success');
const [busyKey, setBusyKey] = useState(''); const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -192,7 +193,7 @@ export default function NotificationListPage() { ...@@ -192,7 +193,7 @@ export default function NotificationListPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [category, keyword, page, readStatus, reloadKey]); }, [category, keyword, page, readStatus, reloadKey, setError]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -212,21 +213,21 @@ export default function NotificationListPage() { ...@@ -212,21 +213,21 @@ export default function NotificationListPage() {
const runItemOperation = useCallback( const runItemOperation = useCallback(
async (key, operation, successMessage) => { async (key, operation, successMessage) => {
setBusyKey(key); setBusyKey(key);
setOperationError('');
setSuccess('');
try { try {
await operation(); await operation();
toast.success(successMessage, { timeout: 3000 }); setSuccess(successMessage);
refresh(); refresh();
return true; return true;
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', { setOperationError(operationError?.message || '操作失败,请稍后重试');
timeout: 4000,
});
return false; return false;
} finally { } finally {
setBusyKey(''); setBusyKey('');
} }
}, },
[refresh], [refresh, setOperationError, setSuccess],
); );
const handleMarkRead = useCallback( const handleMarkRead = useCallback(
...@@ -260,6 +261,7 @@ export default function NotificationListPage() { ...@@ -260,6 +261,7 @@ export default function NotificationListPage() {
const handleOpenTarget = useCallback( const handleOpenTarget = useCallback(
async (notification, target) => { async (notification, target) => {
setBusyKey(notification.name); setBusyKey(notification.name);
setOperationError('');
try { try {
if (!isNotificationRead(notification, userInfo?._id)) { if (!isNotificationRead(notification, userInfo?._id)) {
await markNotificationRead(notification.name); await markNotificationRead(notification.name);
...@@ -271,14 +273,12 @@ export default function NotificationListPage() { ...@@ -271,14 +273,12 @@ export default function NotificationListPage() {
} }
refresh(); refresh();
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { setOperationError(operationError?.message || '无法打开通知关联流程');
timeout: 4000,
});
} finally { } finally {
setBusyKey(''); setBusyKey('');
} }
}, },
[refresh, userInfo?._id], [refresh, setOperationError, userInfo?._id],
); );
return ( return (
...@@ -357,14 +357,15 @@ export default function NotificationListPage() { ...@@ -357,14 +357,15 @@ export default function NotificationListPage() {
</div> </div>
{error ? ( {error ? (
<Alert status="danger"> <EmptyState className="min-h-48" 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> <Button variant="secondary" onPress={refresh}>重试</Button>
</Alert> </EmptyState.Content>
</EmptyState>
) : null} ) : null}
{loading && notifications.length === 0 ? ( {loading && notifications.length === 0 ? (
......
import useFeedbackState from "@/hooks/useFeedbackState";
import { import {
DataTable, DataTable,
DataTableChip, DataTableChip,
...@@ -45,9 +46,9 @@ import { ...@@ -45,9 +46,9 @@ import {
Tabs, Tabs,
TextArea, TextArea,
Tooltip, Tooltip,
toast,
} from "@heroui/react"; } from "@heroui/react";
import { DropZone } from "@heroui-pro/react/drop-zone"; import { DropZone } from "@heroui-pro/react/drop-zone";
import { EmptyState } from "@heroui-pro/react/empty-state";
import { import {
startTransition, startTransition,
useContext, useContext,
...@@ -385,8 +386,11 @@ export default function ResourceBookingPage() { ...@@ -385,8 +386,11 @@ export default function ResourceBookingPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [error, setError] = useState(""); const [, setError] = useFeedbackState("error");
const [notice, setNotice] = useState(""); const [, setNotice] = useFeedbackState("success");
const [, setOptionsWarning] = useFeedbackState("warning");
const [loadFailed, setLoadFailed] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [sourceSystemOptions, setSourceSystemOptions] = useState([]); const [sourceSystemOptions, setSourceSystemOptions] = useState([]);
const [sourceSystemOptionsLoading, setSourceSystemOptionsLoading] = const [sourceSystemOptionsLoading, setSourceSystemOptionsLoading] =
useState(true); useState(true);
...@@ -416,12 +420,6 @@ export default function ResourceBookingPage() { ...@@ -416,12 +420,6 @@ export default function ResourceBookingPage() {
[selectedFile], [selectedFile],
); );
useEffect(() => {
if (notice) {
toast.success(notice, { timeout: 4000 });
}
}, [notice]);
const activeMaterial = useMemo(() => { const activeMaterial = useMemo(() => {
return materials.find((item) => item.id === activeMaterialId) || null; return materials.find((item) => item.id === activeMaterialId) || null;
}, [activeMaterialId, materials]); }, [activeMaterialId, materials]);
...@@ -449,6 +447,9 @@ export default function ResourceBookingPage() { ...@@ -449,6 +447,9 @@ export default function ResourceBookingPage() {
setArchiveCategoryOptions(result.archiveCategoryOptions || []); setArchiveCategoryOptions(result.archiveCategoryOptions || []);
setArchiveCategoryOptionsError(result.archiveCategoryError || ""); setArchiveCategoryOptionsError(result.archiveCategoryError || "");
setArchiveCategoryOptionsLoading(false); setArchiveCategoryOptionsLoading(false);
setOptionsWarning(
[...new Set([result.sourceSystemError, result.archiveCategoryError].filter(Boolean))].join("\n"),
);
} }
run(); run();
...@@ -456,13 +457,15 @@ export default function ResourceBookingPage() { ...@@ -456,13 +457,15 @@ export default function ResourceBookingPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [setOptionsWarning]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function runFirstPage() { async function runFirstPage() {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError("");
const result = await loadMaterialPage({ const result = await loadMaterialPage({
...submittedMaterialSearchForm, ...submittedMaterialSearchForm,
page, page,
...@@ -480,6 +483,7 @@ export default function ResourceBookingPage() { ...@@ -480,6 +483,7 @@ export default function ResourceBookingPage() {
}), }),
); );
setTotal(result.total || 0); setTotal(result.total || 0);
setLoadFailed(Boolean(result.error));
setError(result.error || ""); setError(result.error || "");
setLoading(false); setLoading(false);
...@@ -496,7 +500,7 @@ export default function ResourceBookingPage() { ...@@ -496,7 +500,7 @@ export default function ResourceBookingPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [activeMaterialId, submittedMaterialSearchForm, page, pageSize]); }, [activeMaterialId, submittedMaterialSearchForm, page, pageSize, reloadKey, setError]);
const categoryTabs = useMemo( const categoryTabs = useMemo(
() => { () => {
...@@ -749,6 +753,7 @@ export default function ResourceBookingPage() { ...@@ -749,6 +753,7 @@ export default function ResourceBookingPage() {
}), }),
); );
setTotal(result.total || 0); setTotal(result.total || 0);
setLoadFailed(Boolean(result.error));
setError(result.error || ""); setError(result.error || "");
return result; return result;
...@@ -823,6 +828,7 @@ export default function ResourceBookingPage() { ...@@ -823,6 +828,7 @@ export default function ResourceBookingPage() {
setIsDetailOpen(false); setIsDetailOpen(false);
setIsDetailEditing(false); setIsDetailEditing(false);
setDetailForm(buildDetailForm(activeMaterial)); setDetailForm(buildDetailForm(activeMaterial));
setNotice("");
} }
function handleStartDetailEdit() { function handleStartDetailEdit() {
...@@ -840,6 +846,7 @@ export default function ResourceBookingPage() { ...@@ -840,6 +846,7 @@ export default function ResourceBookingPage() {
setDetailForm(buildDetailForm(activeMaterial)); setDetailForm(buildDetailForm(activeMaterial));
setIsDetailEditing(false); setIsDetailEditing(false);
setError(""); setError("");
setNotice("");
} }
async function handleUploadSubmit() { async function handleUploadSubmit() {
...@@ -902,7 +909,7 @@ export default function ResourceBookingPage() { ...@@ -902,7 +909,7 @@ export default function ResourceBookingPage() {
} }
async function handleSaveDetail() { async function handleSaveDetail() {
if (!activeMaterial) { if (!activeMaterial || saving) {
return; return;
} }
...@@ -917,10 +924,10 @@ export default function ResourceBookingPage() { ...@@ -917,10 +924,10 @@ export default function ResourceBookingPage() {
try { try {
const updated = await updateMaterialRecord(activeMaterial, detailForm); const updated = await updateMaterialRecord(activeMaterial, detailForm);
setNotice("材料信息已更新。");
await reloadMaterials(); await reloadMaterials();
setActiveMaterialId(updated.id || activeMaterial.id); setActiveMaterialId(updated.id || activeMaterial.id);
setIsDetailEditing(false); setIsDetailEditing(false);
setNotice("详情已保存。");
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || "材料信息保存失败,请稍后重试。"); setError(saveError?.message || "材料信息保存失败,请稍后重试。");
} finally { } finally {
...@@ -1226,13 +1233,6 @@ export default function ResourceBookingPage() { ...@@ -1226,13 +1233,6 @@ export default function ResourceBookingPage() {
</Tabs> </Tabs>
</div> </div>
{error ? (
<Alert className="mt-4" status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</div> </div>
<Surface className="rounded-3xl p-6" variant="default"> <Surface className="rounded-3xl p-6" variant="default">
...@@ -1448,6 +1448,18 @@ export default function ResourceBookingPage() { ...@@ -1448,6 +1448,18 @@ export default function ResourceBookingPage() {
<Spinner size="sm" /> <Spinner size="sm" />
<span className="text-sm text-muted">正在加载材料归档工作台...</span> <span className="text-sm text-muted">正在加载材料归档工作台...</span>
</div> </div>
) : loadFailed ? (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>材料列表加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={() => setReloadKey((key) => key + 1)}>
重新加载
</Button>
</EmptyState.Content>
</EmptyState>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
......
import useFeedbackState from "@/hooks/useFeedbackState";
import { UserContext } from "@/wrapper/Auth"; import { UserContext } from "@/wrapper/Auth";
import { import {
DataTable, DataTable,
...@@ -22,7 +23,6 @@ import { ...@@ -22,7 +23,6 @@ import {
} from "@/design-system/chartPalette"; } from "@/design-system/chartPalette";
import XinyuanEChart from "@/components/XinyuanEChart"; import XinyuanEChart from "@/components/XinyuanEChart";
import { import {
Alert,
Button, Button,
Card, Card,
Chip, Chip,
...@@ -440,13 +440,15 @@ export default function ResourceStatPage() { ...@@ -440,13 +440,15 @@ export default function ResourceStatPage() {
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
const [dashboard, setDashboard] = useState(() => buildMaterialDashboard([])); const [dashboard, setDashboard] = useState(() => buildMaterialDashboard([]));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useFeedbackState("error");
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setError("");
const result = await loadMaterialRecords({}); const result = await loadMaterialRecords({});
if (cancelled) { if (cancelled) {
...@@ -462,7 +464,7 @@ export default function ResourceStatPage() { ...@@ -462,7 +464,7 @@ export default function ResourceStatPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [reloadKey, setError]);
const welcomeLabel = useMemo(() => { const welcomeLabel = useMemo(() => {
return ( return (
...@@ -495,6 +497,22 @@ export default function ResourceStatPage() { ...@@ -495,6 +497,22 @@ export default function ResourceStatPage() {
dashboard.totalCount, dashboard.totalCount,
], ],
); );
if (!loading && error) {
return (
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>工作台加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={() => setReloadKey((key) => key + 1)}>
重新加载
</Button>
</EmptyState.Content>
</EmptyState>
);
}
return ( return (
<div className="rc-page-stack"> <div className="rc-page-stack">
<Panel className="p-5 sm:p-6"> <Panel className="p-5 sm:p-6">
...@@ -523,14 +541,6 @@ export default function ResourceStatPage() { ...@@ -523,14 +541,6 @@ export default function ResourceStatPage() {
</div> </div>
</div> </div>
{error ? (
<Alert className="mt-6" status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{loading ? ( {loading ? (
<div className="mt-6 flex min-h-20 items-center justify-center gap-2" role="status"> <div className="mt-6 flex min-h-20 items-center justify-center gap-2" role="status">
<Spinner size="sm" /> <Spinner size="sm" />
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
@import "./design-system/theme.css"; @import "./design-system/theme.css";
@import "./design-system/tokens.css"; @import "./design-system/tokens.css";
@import "./design-system/shell.css"; @import "./design-system/shell.css";
@import "./design-system/feedback.css";
@theme { @theme {
--font-inter: --font-inter:
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService'; import { getSystemInfo } from '@/services/DataService';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
...@@ -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('warning');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -54,7 +55,7 @@ export default function SiteWrapper() { ...@@ -54,7 +55,7 @@ export default function SiteWrapper() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [setLoadError]);
if (loading) { if (loading) {
return ( return (
......
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