Commit 513706c5 authored by drigle's avatar drigle

fix: make operation feedback visible and dismissible

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