Commit 513706c5 authored by drigle's avatar drigle

fix: make operation feedback visible and dismissible

parent 3c5cc2ab
......@@ -31,6 +31,12 @@
- 共享组件只能组合 HeroUI/HeroUI Pro 的官方 compound API,不得复刻旧组件 props。
- 官方组件暂不覆盖的场景应优先组合 HeroUI/HeroUI Pro compound API,并在本设计系统中补充经过评审的约定;不得以 Catalyst 或可见原生控件作为降级方案。
### 1.1 操作反馈浮层
接口成功、失败及降级警告统一使用根部 `AppFeedback` 的 HeroUI `Toast.Provider` 和官方 compound 组件;登录、独立流程页和 AppShell 共用一个 Provider,业务页面不再渲染接口反馈横条。成功默认 4 秒、错误/警告默认 8 秒后消失,关闭不改变请求结果或表单草稿。
`src/design-system/feedback.css` 是操作反馈的窄范围交互约定,沿用模型分析应用 `f167d24`:仅调整 Toast 层级、安全区位置、长文布局及关闭按钮的可见性/点击范围。浮层层级 300,高于业务 Modal 和移动侧栏;关闭按钮无需悬停即可操作。颜色、边框、圆角、阴影与 Hover 继续使用 HeroUI 默认主题,不在页面 CSS 或 `tokens.css` 中覆盖。
## 2. 主题权威
- `src/design-system/theme.css` 是浅色、暗色、品牌色、语义色、圆角和字体的唯一主题权威,以 HeroUI 官方主题工具导出值为基础,并在文件内固化批准的 L1/L2/Overlay 映射。Shell 配色以本项目 2026-09-28 调整为准;升级 HeroUI 时重新导出基础值、复核三项语义映射与 Shell Token。
......
import '@/tailwind.css';
import { defineApp } from 'umi';
import { createElement } from 'react';
import AppFeedback from '@/components/AppFeedback';
import {
APPLICATION_BASE_PATH,
APPLICATION_NAME,
......@@ -40,4 +42,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 { Toast } from '@heroui/react';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
......@@ -32,7 +31,6 @@ export default function AppShell({
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { queryRecords } from '@/services/DataService';
import { Button, Label } from '@heroui/react';
......@@ -334,7 +335,7 @@ export default function ReferenceSearchSelect({
const [options, setOptions] = useState([]);
const [selectedById, setSelectedById] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [active, setActive] = useState(false);
const lastSearchRequestId = useRef(0);
......
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Label } from '@heroui/react';
import { loadUser, queryUsers } from '@/services/DataService';
import { normalizeUserLabel, toArray } from '@/utils/subjectView';
......@@ -21,11 +22,13 @@ export default function UserSearchSelect({
const [users, setUsers] = useState([]);
const [profiles, setProfiles] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState();
const selectedIds = useMemo(() => toArray(value).map(getUserId).filter(Boolean), [value]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
const text = keyword.trim();
queryUsers(0, 30, text ? {
$or: [
......@@ -42,14 +45,17 @@ export default function UserSearchSelect({
...Object.fromEntries(nextUsers.map((user) => [getUserId(user), user])),
}));
})
.catch(() => {
if (!cancelled) setUsers([]);
.catch((requestError) => {
if (!cancelled) {
setUsers([]);
setError(requestError?.message || '用户列表加载失败,请重新搜索。');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [keyword]);
}, [keyword, setError]);
useEffect(() => {
const missing = selectedIds.filter((id) => !profiles[id]);
......@@ -82,6 +88,7 @@ export default function UserSearchSelect({
<XinyuanAutocomplete
aria-label={ariaLabel}
inputValue={keyword}
errorText={error}
isDisabled={disabled}
isClearable={selectedIds.length > 0}
isLoading={loading}
......
......@@ -4,6 +4,7 @@ import {
DataTableText,
} from '@/components/DataTable';
import { KPI } from '@heroui-pro/react/kpi';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
TableActionButton,
TableActionGroup,
......@@ -28,7 +29,6 @@ import TableFieldValue from '@/components/TableFieldValue';
import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView';
import { formatTableFieldValue, getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import {
Alert,
Button,
Disclosure,
Input,
......@@ -574,7 +574,7 @@ export default function RecordWorkbench({
const [expandedRowIds, setExpandedRowIds] = useState({});
const filterFieldVariant = 'secondary';
const loading = Boolean(states?.loading);
const error = states?.error || '';
const loadFailed = Boolean(states?.loadFailed);
const totalRecords = pagination?.totalRecords ?? records.length;
const presentationColumns = useMemo(
() =>
......@@ -1283,25 +1283,19 @@ export default function RecordWorkbench({
) : null}
</div>
{error ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
</Alert>
{!loading && loadFailed ? (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>{title || '列表'}加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button variant="secondary" onPress={states.onRetry || onQuery}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
) : null}
{states?.warning ? (
<Alert status="warning">
<Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content>
</Alert>
) : null}
{states?.success ? (
<Alert status="success">
<Alert.Content><Alert.Description>{states.success}</Alert.Description></Alert.Content>
</Alert>
) : null}
<div ref={tableScrollRef} className={tableContentClass}>
<div ref={tableScrollRef} className={!loading && loadFailed ? 'hidden' : tableContentClass}>
<DataTable
ariaLabel={title || '业务数据列表'}
columns={dataGridColumns}
......
/* 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));
}
.app-feedback-toast.toast {
gap: 0.5rem;
padding-inline-end: 3.5rem;
}
/* HeroUI hides this button until hover by default; touch and keyboard users need it visible. */
.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;
}
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 xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import {
......@@ -7,7 +8,7 @@ import authService from '@/services/authService';
import { normalizeRedirect } from '@/utils/authRedirect';
import { getCaptcha, userLogin } from '@/services/DataService';
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 { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi';
......@@ -26,7 +27,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => {
......@@ -180,14 +181,6 @@ export default function LoginPage() {
</div>
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
className="login-submit-button"
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react';
import { DataTable, DataTableEmptyState } from '@/components/DataTable';
import { Alert, Spinner } from '@heroui/react';
import { Alert, Button, Spinner } from '@heroui/react';
import { loadSubject, queryRecords } from '@/services/DataService';
import {
formatDateText,
......@@ -131,7 +132,8 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
const [subject, setSubject] = useState(null);
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
let cancelled = false;
......@@ -195,7 +197,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return () => {
cancelled = true;
};
}, [pageSize, populatedRecords, referenceIds, subjectName]);
}, [pageSize, populatedRecords, referenceIds, subjectName, retryKey, setError]);
if (referenceIds.length === 0) {
return (
......@@ -232,15 +234,13 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return (
<div className="space-y-2">
{error ? (
<Alert status="warning">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
<Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>
重新加载关联明细
</Button>
) : null}
<ReferenceRecordsFrame action={action} count={records.length}>
{records.length === 0 ? (
<DataTableEmptyState title="暂无可展示的关联明细" />
<DataTableEmptyState title={error ? '关联明细加载失败,详细错误见操作提示' : '暂无可展示的关联明细'} />
) : (
<DataTable
ariaLabel="关联记录"
......
import { useEffect, useMemo, useState } from 'react';
import useFeedbackState from '@/hooks/useFeedbackState';
import clsx from 'clsx';
import {
Alert,
......@@ -168,6 +169,8 @@ function FieldValue({ value, fieldDef }) {
function UserSelector({ value, onChange, multiple, variant }) {
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState();
const selectedIds = useMemo(
() => toArray(value).map((item) => item?._id || item).filter(Boolean),
[value],
......@@ -175,6 +178,8 @@ function UserSelector({ value, onChange, multiple, variant }) {
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
const loadOptions = async () => {
const filter = keyword.trim()
......@@ -194,7 +199,10 @@ function UserSelector({ value, onChange, multiple, variant }) {
} catch (error) {
if (!cancelled) {
setOptions([]);
setError(error?.message || '用户列表加载失败,请重新搜索。');
}
} finally {
if (!cancelled) setLoading(false);
}
};
......@@ -203,7 +211,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
return () => {
cancelled = true;
};
}, [keyword]);
}, [keyword, setError]);
const handleSelectionChange = (keys) => {
if (!multiple) {
......@@ -234,8 +242,10 @@ function UserSelector({ value, onChange, multiple, variant }) {
{...selectionProps}
aria-label="用户"
emptyText="暂无用户"
errorText={error}
inputValue={keyword}
isClearable={selectedIds.length > 0}
isLoading={loading}
options={autocompleteOptions}
placeholder="请选择用户"
searchPlaceholder="搜索用户名或显示名"
......@@ -321,6 +331,7 @@ function ControlledFieldEditor({
useAuditMaterialCenter,
}) {
const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState();
const [uploading, setUploading] = useState(false);
const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType);
......@@ -333,6 +344,7 @@ function ControlledFieldEditor({
setUploading(true);
setError('');
setRequestError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
try {
......@@ -340,7 +352,7 @@ function ControlledFieldEditor({
const fileValue = uploaded?.file || uploaded?.data || uploaded;
onChange?.(fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue);
} catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。');
setRequestError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
......@@ -560,6 +572,7 @@ export default function TailwindEditableField({
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState();
const [uploading, setUploading] = useState(false);
useEffect(() => {
......@@ -602,6 +615,7 @@ export default function TailwindEditableField({
const save = async (nextValue = draft) => {
setError('');
setRequestError('');
if (fieldType === 'object' && typeof nextValue === 'string' && nextValue.trim()) {
try {
......@@ -616,7 +630,7 @@ export default function TailwindEditableField({
await onSave(asSaveValue(nextValue, fieldDef));
setEditing(false);
} catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。');
setRequestError(saveError?.message || '保存失败,请稍后重试。');
}
};
......@@ -629,6 +643,7 @@ export default function TailwindEditableField({
const cancel = () => {
setDraft(value);
setError('');
setRequestError('');
setEditing(false);
};
......@@ -640,6 +655,7 @@ export default function TailwindEditableField({
setUploading(true);
setError('');
setRequestError('');
try {
const uploaded = await uploadFile(file);
......@@ -649,7 +665,7 @@ export default function TailwindEditableField({
await onSave(nextValue);
setEditing(false);
} catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。');
setRequestError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
}
......@@ -657,6 +673,7 @@ export default function TailwindEditableField({
const handleRemoveFile = async (indexToRemove) => {
setError('');
setRequestError('');
const nextValue = fieldDef?.multiple
? toArray(draft).filter((_, index) => index !== indexToRemove)
......@@ -670,7 +687,7 @@ export default function TailwindEditableField({
}
} catch (saveError) {
setDraft(draft);
setError(saveError?.message || '删除附件失败,请稍后重试。');
setRequestError(saveError?.message || '删除附件失败,请稍后重试。');
}
};
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useRef, useState } from 'react';
import {
getSubjectView,
......@@ -75,7 +76,7 @@ export function useSubjectRecord(
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const requestIdRef = useRef(0);
useEffect(() => {
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons';
import { Alert, Button, Modal, Spinner, Tabs, Tooltip } from '@heroui/react';
import React, {
......@@ -72,7 +74,7 @@ const RecordDetailDrawer = (props, ref) => {
const [draftRecord, setDraftRecord] = useState(null);
const [dirtyFields, setDirtyFields] = useState({});
const [validationErrors, setValidationErrors] = useState({});
const [saveError, setSaveError] = useState('');
const [, setSaveError] = useFeedbackState('error');
const editSessionRef = useRef(0);
const pendingLocalMaterialsRef = useRef({});
......@@ -434,18 +436,17 @@ const RecordDetailDrawer = (props, ref) => {
<Spinner size="sm" />
</div>
) : error ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
</Alert>
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>详情加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button variant="secondary" onPress={() => setRefreshKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
) : (
<div>
{saveError ? (
<Alert className="mb-4" status="danger">
<Alert.Content>
<Alert.Description>{saveError}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{isRecordLocked ? (
<Alert status="warning">
<Alert.Content>
......
import useFeedbackState from '@/hooks/useFeedbackState';
import xinyuanLogo from '@/assets/xy-logo-red.png';
import {
APPLICATION_DISPLAY_NAME,
......@@ -5,12 +6,11 @@ import {
} from '@/config/application';
import { getApplications } from '@/services/DataService';
import { EmptyStateSurface } from '@/components/DataTable';
import { ExclamationTriangleIcon, Search as SearchIcon } from '@/components/AppIcons';
import { Search as SearchIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Chip,
Modal,
......@@ -285,7 +285,7 @@ export default function ApplicationSwitcherDialog({
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
......@@ -319,6 +319,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => {
if (!open) {
setApplicationSearch('');
setErrorMessage('');
return;
}
......@@ -398,13 +399,6 @@ export default function ApplicationSwitcherDialog({
</Modal.Header>
<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}>
{status === 'error' && errorMessage ? (
<Alert 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 ? (
<LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? (
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Link, Modal, Spinner, Surface, Tooltip } from '@heroui/react';
import { Button, Link, Modal, Spinner, Surface, Tooltip } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Close, Eye, Fullscreen, FullscreenExit } from '@/components/AppIcons';
import { formatFieldValue, toArray } from '@/utils/subjectView';
import { loadArchiveSnapshotItemSubject } from './archiveWorkbenchService';
......@@ -36,7 +38,7 @@ export default function ArchiveSnapshotItems({ fieldName, value, recordTitle })
const [isFullscreen, setIsFullscreen] = useState(false);
const [subject, setSubject] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
const snapshot = useMemo(() => {
try {
......@@ -47,7 +49,15 @@ export default function ArchiveSnapshotItems({ fieldName, value, recordTitle })
}, [value]);
useEffect(() => {
if (!isOpen || snapshot.error || snapshot.items.length === 0) return;
if (!isOpen) {
setError('');
return;
}
if (snapshot.error) {
setError(snapshot.error);
return;
}
if (snapshot.items.length === 0) return;
let cancelled = false;
setLoading(true);
setError('');
......@@ -63,7 +73,7 @@ export default function ArchiveSnapshotItems({ fieldName, value, recordTitle })
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [fieldName, isOpen, retryKey, snapshot]);
}, [fieldName, isOpen, retryKey, snapshot, setError]);
if (!snapshot.error && snapshot.items.length === 0) {
return <span className="text-sm text-muted">暂无{configuration.label}</span>;
......@@ -119,16 +129,19 @@ export default function ArchiveSnapshotItems({ fieldName, value, recordTitle })
</Modal.Header>
<Modal.Body>
{snapshot.error || error ? (
<div className="space-y-3">
<Alert status="danger">
<Alert.Content><Alert.Description>{snapshot.error || error}</Alert.Description></Alert.Content>
</Alert>
<EmptyState className="min-h-32" size="md">
<EmptyState.Header>
<EmptyState.Title>{snapshot.error ? '归档快照数据异常' : '归档明细字段加载失败'}</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示。</EmptyState.Description>
</EmptyState.Header>
{!snapshot.error ? (
<Button size="sm" variant="secondary" onPress={() => setRetryKey((current) => current + 1)}>
重试
</Button>
<EmptyState.Footer>
<Button size="sm" variant="secondary" onPress={() => setRetryKey((current) => current + 1)}>
重试
</Button>
</EmptyState.Footer>
) : null}
</div>
</EmptyState>
) : loading || !subject ? (
<div className="flex min-h-32 items-center justify-center" role="status" aria-label="正在加载归档明细字段">
<Spinner size="sm" />
......
import useFeedbackState from '@/hooks/useFeedbackState';
import RecordWorkbench from '@/components/record-workbench';
import TableFieldValue from '@/components/TableFieldValue';
import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
......@@ -21,9 +22,10 @@ export default function ProblemRectificationArchivePage() {
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [tableDisplayConfig, setTableDisplayConfig] = useState({});
const [tableDisplayConfigError, setTableDisplayConfigError] = useState('');
const [, setTableDisplayConfigError] = useFeedbackState('warning');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [filters, setFilters] = useState({
keyword: '',
});
......@@ -34,6 +36,7 @@ export default function ProblemRectificationArchivePage() {
const reloadList = useCallback(async () => {
setLoading(true);
setLoadFailed(false);
setError('');
try {
......@@ -44,6 +47,7 @@ export default function ProblemRectificationArchivePage() {
setTableDisplayConfig(resources.displayConfig || {});
setTableDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) {
setLoadFailed(true);
setRecords([]);
setSubject(null);
setViews([]);
......@@ -193,8 +197,7 @@ export default function ProblemRectificationArchivePage() {
}}
states={{
loading,
error,
warning: tableDisplayConfigError,
loadFailed,
loadingTitle: '正在加载归档批次',
loadingDescription: '正在读取归档对象、视图配置和批次记录,请稍候。',
emptyTitle: '暂无归档批次',
......
import useFeedbackState from '@/hooks/useFeedbackState';
import {
ArrowPathIcon,
Delete,
......@@ -9,7 +10,6 @@ import {
uploadProblemRectificationFile,
} from '../problemRectificationMaterialService';
import {
Alert,
Button,
Label,
Tabs,
......@@ -60,10 +60,10 @@ export default function ProblemRectificationMaterialField({
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [materials, setMaterials] = useState([]);
const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState('');
const [materialsError, setMaterialsError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const [, setUploadError] = useFeedbackState('error');
const [localFileKeys, setLocalFileKeys] = useState(() => new Set());
const fileValues = useMemo(
() => (multiple ? toFileArray(value) : toFileArray(value).slice(0, 1)),
......@@ -322,15 +322,10 @@ export default function ProblemRectificationMaterialField({
/>
{materialsError ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{materialsError}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}>
<ArrowPathIcon size={14} />
重试
</Button>
</Alert>
<Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>
<ArrowPathIcon size={14} />
重新加载材料
</Button>
) : null}
</div>
</Tabs.Panel>
......@@ -346,11 +341,6 @@ export default function ProblemRectificationMaterialField({
triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={handleLocalUpload}
/>
{uploadError ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{uploadError}</Alert.Description></Alert.Content>
</Alert>
) : null}
</div>
</Tabs.Panel>
</Tabs>
......
import { DataTable } from '@/components/DataTable';
import useFeedbackState from '@/hooks/useFeedbackState';
import {
createResolvedXinyuanPieChartColorMap,
getXinyuanPieChartColor,
......@@ -17,7 +18,6 @@ import {
ExclamationTriangleIcon,
} from '@/components/AppIcons';
import {
Alert,
Button,
Card,
Chip,
......@@ -456,24 +456,28 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
isEmpty: false,
});
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
const [, setError] = useFeedbackState();
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError('');
const result = await loadProblemRectificationDashboardData();
if (cancelled) {
return;
}
setDashboardData(result);
setError(result.error || '');
setLoading(false);
})();
return () => {
cancelled = true;
};
}, []);
}, [reloadKey, setError]);
const groups = dashboardData.groups || [];
const yearOptions = dashboardData.yearOptions || [];
......@@ -585,14 +589,6 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
</div>
</div>
{dashboardData.error ? (
<Alert status="warning">
<Alert.Content>
<Alert.Description>{dashboardData.error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{loading ? (
<div className="flex min-h-48 items-center justify-center gap-2" role="status">
<Spinner size="sm" />
......@@ -600,7 +596,19 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
</div>
) : null}
{!loading && dashboardData.isEmpty ? (
{!loading && dashboardData.error ? (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>{dashboardTitle}加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button variant="secondary" onPress={() => setReloadKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
) : null}
{!loading && !dashboardData.error && dashboardData.isEmpty ? (
<EmptyBlock title="暂无问题整改数据" />
) : null}
......
import useFeedbackState from '@/hooks/useFeedbackState';
import {
ArrowPathIcon,
CalendarDaysIcon,
......@@ -21,7 +22,7 @@ import {
import { normalizeText, toArray } from '@/utils/subjectView';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { Alert, Button, Card, Chip, Spinner, TextArea, Toast, Tooltip, toast } from '@heroui/react';
import { Button, Card, Chip, Spinner, TextArea, Tooltip } from '@heroui/react';
import { useContext, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'umi';
import { UserContext } from '@/wrapper/Auth';
......@@ -416,8 +417,9 @@ export default function ProblemRectificationFlowTablePage() {
const scopedRecordId = searchParams.get('record') || '';
const [loading, setLoading] = useState(true);
const [submittingKey, setSubmittingKey] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [, setSuccess] = useFeedbackState('success');
const [messageRecord, setMessageRecord] = useState(null);
const [records, setRecords] = useState([]);
const [userLabels, setUserLabels] = useState({});
......@@ -463,12 +465,14 @@ export default function ProblemRectificationFlowTablePage() {
const loadFlow = async () => {
if (!messageId) {
setLoadFailed(true);
setError('缺少流程 ID。');
setLoading(false);
return;
}
setLoading(true);
setLoadFailed(false);
setError('');
try {
......@@ -481,6 +485,7 @@ export default function ProblemRectificationFlowTablePage() {
setRecords(relatedRecords.filter(Boolean));
setDrafts({});
} catch (requestError) {
setLoadFailed(true);
setError(requestError?.message || '流程表格加载失败,请稍后重试。');
setMessageRecord(null);
setRecords([]);
......@@ -1778,12 +1783,6 @@ export default function ProblemRectificationFlowTablePage() {
},
];
useEffect(() => {
if (!success) return;
toast.success(success, { timeout: 4000 });
setSuccess('');
}, [success]);
useEffect(() => {
document.title = '整改流程 - 问题整改';
}, []);
......@@ -1835,13 +1834,6 @@ export default function ProblemRectificationFlowTablePage() {
</Card.Content>
</Card>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<section className="rc-list-stack">
<div className="rc-page-toolbar">
<div className="flex min-w-0 items-center gap-3">
......@@ -1861,6 +1853,11 @@ export default function ProblemRectificationFlowTablePage() {
<div className="flex min-h-56 items-center justify-center" role="status" aria-label="正在加载流程表格">
<Spinner size="md" />
</div>
) : loadFailed ? (
<div className="space-y-3">
<DataTableEmptyState title="流程表格加载失败,详细错误见操作提示" />
<Button variant="secondary" onPress={loadFlow}>重新加载</Button>
</div>
) : rows.length === 0 ? (
<DataTableEmptyState title={isDelayFlow ? '当前流程没有可处理的延期申请' : '当前流程没有可处理的整改明细'} />
) : (
......@@ -1877,7 +1874,6 @@ export default function ProblemRectificationFlowTablePage() {
)}
</section>
</div>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</main>
);
}
import useFeedbackState from '@/hooks/useFeedbackState';
import {
CheckCircleIcon,
ClipboardDocumentListIcon,
......@@ -130,13 +131,14 @@ function StatusTag({ value }) {
);
}
function EmptyState({ title, description }) {
function EmptyState({ title, description, action }) {
return (
<ProEmptyState className="min-h-64" size="md">
<ProEmptyState.Header>
<ProEmptyState.Title>{title}</ProEmptyState.Title>
{description ? <ProEmptyState.Description>{description}</ProEmptyState.Description> : null}
</ProEmptyState.Header>
{action ? <ProEmptyState.Footer>{action}</ProEmptyState.Footer> : null}
</ProEmptyState>
);
}
......@@ -163,12 +165,14 @@ export default function ProblemRectificationFormPage() {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [flowData, setFlowData] = useState(null);
const [selectedRecord, setSelectedRecord] = useState(null);
const reload = async () => {
if (!messageId) {
setLoadFailed(true);
setFlowData(null);
setLoading(false);
setError('缺少流程 ID,无法加载整改流程。');
......@@ -176,12 +180,14 @@ export default function ProblemRectificationFormPage() {
}
setLoading(true);
setLoadFailed(false);
setError('');
try {
const result = await loadProblemRectificationFlow(messageId, userInfo);
setFlowData(result);
} catch (requestError) {
setLoadFailed(true);
setFlowData(null);
setError(requestError?.message || '整改流程加载失败,请稍后重试。');
} finally {
......@@ -380,12 +386,8 @@ export default function ProblemRectificationFormPage() {
<div className="grid min-h-64 place-items-center" role="status" aria-label="正在加载整改流程">
<Spinner />
</div>
) : error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : loadFailed ? (
<EmptyState title="整改流程加载失败" description="详细错误见操作提示,请重新加载。" action={<Button variant="secondary" onPress={reload}>重新加载</Button>} />
) : !messageRecord ? (
<EmptyState title="未找到流程数据" description="请确认消息链接中的 id 是否正确,或检查流程主单是否已存在。" />
) : (
......
import useFeedbackState from '@/hooks/useFeedbackState';
import FormDatePicker from '@/components/FormDatePicker';
import ResourceFilterMultiSelect from '@/components/ResourceFilterMultiSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
......@@ -49,7 +50,6 @@ import {
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from '@heroui/react';
import {
formatFieldValue,
getFieldNameFromPath,
......@@ -795,10 +795,11 @@ export default function ProblemRectificationListPage() {
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [tableDisplayConfig, setTableDisplayConfig] = useState({});
const [tableDisplayConfigError, setTableDisplayConfigError] = useState('');
const [, setTableDisplayConfigError] = useFeedbackState('warning');
const [loading, setLoading] = useState(true);
const [recordsLoading, setRecordsLoading] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [filters, setFilters] = useState({});
const [pendingFilters, setPendingFilters] = useState({});
......@@ -812,13 +813,13 @@ export default function ProblemRectificationListPage() {
const [cloneTargetRecord, setCloneTargetRecord] = useState(null);
const [deleteTargetRecords, setDeleteTargetRecords] = useState([]);
const [archiveTargetRecords, setArchiveTargetRecords] = useState([]);
const [archiveError, setArchiveError] = useState('');
const [, setArchiveError] = useFeedbackState('error');
const [formValues, setFormValues] = useState({});
const [fillerUsers, setFillerUsers] = useState([]);
const [auditorUsers, setAuditorUsers] = useState([]);
const [departmentAssignments, setDepartmentAssignments] = useState([]);
const [delayDepartmentAssignments, setDelayDepartmentAssignments] = useState([]);
const [successMessage, setSuccessMessage] = useState('');
const [, setSuccessMessage] = useFeedbackState('success');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE);
const [pagination, setPagination] = useState({
......@@ -844,6 +845,7 @@ export default function ProblemRectificationListPage() {
const reloadResources = useCallback(async () => {
setLoading(true);
setLoadFailed(false);
setError('');
setSuccessMessage('');
......@@ -854,6 +856,7 @@ export default function ProblemRectificationListPage() {
setTableDisplayConfig(resources.displayConfig || {});
setTableDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) {
setLoadFailed(true);
setRecords([]);
setSubject(null);
setViews([]);
......@@ -869,12 +872,6 @@ export default function ProblemRectificationListPage() {
reloadResources();
}, [reloadResources]);
useEffect(() => {
if (!successMessage) return;
toast.success(successMessage, { timeout: 4000 });
setSuccessMessage('');
}, [successMessage]);
useEffect(() => {
const userIds = Array.from(
new Set(
......@@ -1022,6 +1019,7 @@ export default function ProblemRectificationListPage() {
const countCache = viewCountCacheRef.current;
setRecordsLoading(true);
setLoadFailed(false);
setError('');
if (!overrides.keepSuccessMessage) {
setSuccessMessage('');
......@@ -1074,6 +1072,7 @@ export default function ProblemRectificationListPage() {
return;
}
setRecords([]);
setLoadFailed(true);
setGridViewCounts({});
setPagination({
page: nextPage,
......@@ -2081,9 +2080,8 @@ export default function ProblemRectificationListPage() {
} : null}
states={{
loading: loading || recordsLoading,
error,
warning: tableDisplayConfigError,
success: '',
loadFailed,
onRetry: reloadResources,
loadingTitle: '正在加载问题整改列表',
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: activeGridView ? '当前视图暂无数据' : '暂无可访问的视图',
......@@ -2686,13 +2684,6 @@ export default function ProblemRectificationListPage() {
</>
}
>
{archiveError ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{archiveError}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Alert status="warning">
<Alert.Content>
<Alert.Description>
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
......@@ -150,7 +150,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
......@@ -214,12 +214,12 @@ export default function NotificationListPage() {
setBusyKey(key);
try {
await operation();
toast.success(successMessage, { timeout: 3000 });
toast.success(successMessage, { timeout: 4000 });
refresh();
return true;
} catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', {
timeout: 4000,
timeout: 8000,
});
return false;
} finally {
......@@ -272,7 +272,7 @@ export default function NotificationListPage() {
refresh();
} catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', {
timeout: 4000,
timeout: 8000,
});
} finally {
setBusyKey('');
......@@ -356,14 +356,15 @@ export default function NotificationListPage() {
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="ghost" onPress={refresh}>
重试
</Button>
</Alert>
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>通知加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button variant="secondary" onPress={refresh}>重试</Button>
</EmptyState.Footer>
</EmptyState>
) : null}
{loading && notifications.length === 0 ? (
......
......@@ -151,6 +151,16 @@
---
### 操作提示与失败重试
- 功能入口:登录、站点配置、应用切换、问题清单及归档批次、驾驶舱/整改分析、详情编辑、证明材料上传与搜索、整改/延期流程表格、旧外链流程页和通知中心。
- 查询、保存、复制、删除、锁定/解锁、推送、归档、上传及审批的接口成功/失败反馈统一显示 HeroUI Toast,应用配置/应用列表降级使用 warning。根部统一挂载,Portal 在视口顶部呈现,层级高于业务 Modal、确认框和移动侧栏;弹窗打开或页面滚动时也可看到并关闭,不抢走表单焦点。
- 成功提示默认 4 秒、错误和警告默认 8 秒后消失,悬停或键盘聚焦暂停计时;关闭按钮始终可见。相同错误再次发生时重新弹出,同一来源的新提示替换旧提示;清空反馈或离开对应页面时清理该来源提示。
- 提示关闭仅影响展示,不把失败改为成功,不清空草稿、选中记录或附件,不发起保存、推送、审批或归档。表单必填/JSON 校验、锁定说明和删除/归档确认说明继续保留在对应控件或确认框中。
- 问题清单、归档批次、驾驶舱/整改分析、详情、通知和流程页加载失败时保留失败空状态及“重新加载/重试”,详细原因由浮层展示,避免误显示“暂无数据”。重试只读取当前页面资源,不重新执行写入操作;通知继续按当前分类、已读状态、关键词和页码重试。
- 人员、关联记录和材料查询失败时弹出提示,选择器保留字段级加载错误;关联明细和材料保留读取重试入口。详情保存或附件上传失败时保留当前编辑内容,可修正后再次提交;批量附件部分失败保留已成功的附件并提示失败项。
- 归档快照字段读取失败时保留明细弹窗和重试入口,历史 JSON 损坏时保留“查看异常数据”及异常空状态,详细原因由操作提示展示。关闭提示不回写 `metadata`、不更改 `is_locked`、审批状态、分页筛选或归档快照。
## 4. 核心业务对象
### 4.1 主对象
......
......@@ -4,6 +4,7 @@
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
@import "./design-system/feedback.css";
@source "./*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}";
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
......@@ -20,7 +21,7 @@ export default function SiteWrapper() {
const [siteInfo, setSiteInfo] = useState(null);
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [loadError, setLoadError] = useFeedbackState('error');
useEffect(() => {
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