Commit a34ec141 authored by drigle's avatar drigle

feat: unify operation feedback and failure retries

parent 22d96165
...@@ -31,6 +31,14 @@ ...@@ -31,6 +31,14 @@
- 共享组件只能组合 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 秒后消失,关闭仅改变提示展示,不修改请求结果、失败状态或表单草稿。
同步问题整改提交 `bfc5862`,`src/design-system/feedback.css` 仅调整 Toast 层级、安全区位置及后排提示关闭按钮的可见性,浮层层级 300,高于业务 Modal 和移动侧栏。关闭按钮直接组合 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`),通过官方 `slot="close"` 接入 Toast 关闭行为,使用 `AppIcons.Close`,位于内容右侧并随正文参与布局。前排关闭图标始终可见,使用官方透明按钮及默认 Hover/焦点样式;不再放大带边框的 Toast 默认关闭按钮,不设置额外的绝对位置和正文右侧占位。长文继续在 Toast 内容中换行和滚动。颜色、边框、圆角、阴影与 Hover 继续使用 HeroUI 默认主题,不在页面 CSS 或 `tokens.css` 中覆盖。
所有反馈来源使用 `feedbackQueue` 的官方 `ToastQueue`;通过官方 `wrapUpdate` 即时更新队列,避免浏览器 View Transition 快照在提示进出期间拦截点击并误关下方弹窗。保留 HeroUI/React Aria 的计时、悬停/聚焦暂停、关闭、Portal 和键盘交互。
## 2. 主题权威 ## 2. 主题权威
- `src/design-system/theme.css` 是浅色、暗色、品牌色、语义色、圆角和字体的唯一主题权威,以 HeroUI 官方主题工具导出值为基础,并在文件内固化批准的 L1/L2/Overlay 映射。它必须与 `/Users/liuchenlong/Desktop/theme.css` 及共享 Skill 基线逐字节一致;升级 HeroUI 时重新导出基础值、复核三项语义映射并整体批准新基线。 - `src/design-system/theme.css` 是浅色、暗色、品牌色、语义色、圆角和字体的唯一主题权威,以 HeroUI 官方主题工具导出值为基础,并在文件内固化批准的 L1/L2/Overlay 映射。它必须与 `/Users/liuchenlong/Desktop/theme.css` 及共享 Skill 基线逐字节一致;升级 HeroUI 时重新导出基础值、复核三项语义映射并整体批准新基线。
......
{ {
"name": "risk_manager_new", "name": "risk_manager_new",
"version": "1.0.2", "version": "1.0.4",
"build": "abc", "build": "abc",
"description": "风险管理应用", "description": "风险管理应用",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
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 { Button, Toast } from '@heroui/react';
import { Close } from '@/components/AppIcons';
import { feedbackQueue } from '@/services/feedbackQueue';
export default function AppFeedback({ children }) {
return (
<>
{children}
<Toast.Provider
aria-label="操作提示"
className="app-feedback-region"
maxVisibleToasts={3}
placement="top"
queue={feedbackQueue}
width="min(30rem, calc(100vw - 2rem))"
>
{({ toast }) => (
<Toast className="app-feedback-toast gap-2" toast={toast} variant={toast.content.variant}>
<Toast.Indicator variant={toast.content.variant} />
<Toast.Content className="min-w-0">
<Toast.Title className="max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words">{toast.content.title}</Toast.Title>
{toast.content.description ? (
<Toast.Description className="max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words text-foreground">
{toast.content.description}
</Toast.Description>
) : null}
{toast.content.actionProps ? <Toast.ActionButton {...toast.content.actionProps} /> : null}
</Toast.Content>
<Button
aria-label="关闭提示"
className="app-feedback-close shrink-0 self-start"
isIconOnly
size="sm"
slot="close"
variant="ghost"
>
<Close size={16} />
</Button>
</Toast>
)}
</Toast.Provider>
</>
);
}
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>
); );
} }
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
TextField, TextField,
} from '@heroui/react'; } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
DataTable, DataTable,
DataTableText, DataTableText,
...@@ -117,7 +118,8 @@ function useFormDefinition(instance) { ...@@ -117,7 +118,8 @@ function useFormDefinition(instance) {
const identifier = getFormIdentifier(instance?.form); const identifier = getFormIdentifier(instance?.form);
const [definition, setDefinition] = useState(null); const [definition, setDefinition] = 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);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -152,9 +154,9 @@ function useFormDefinition(instance) { ...@@ -152,9 +154,9 @@ function useFormDefinition(instance) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [identifier]); }, [identifier, retryKey, setError]);
return { definition, loading, error }; return { definition, loading, error, retry: () => setRetryKey((key) => key + 1) };
} }
function FormDataTable({ instance, definition, loading, rowActions = null }) { function FormDataTable({ instance, definition, loading, rowActions = null }) {
...@@ -240,17 +242,20 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) { ...@@ -240,17 +242,20 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) {
const [values, setValues] = useState({}); const [values, setValues] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
useEffect(() => { useEffect(() => {
setRequestError('');
if (isOpen) { if (isOpen) {
setValues(row && typeof row === 'object' ? row : {}); setValues(row && typeof row === 'object' ? row : {});
setError(''); setError('');
} }
}, [isOpen, row]); }, [isOpen, row, setRequestError]);
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
setError(''); setError('');
setRequestError('');
try { try {
const prepared = await prepareValuesForSubmit(definition, values); const prepared = await prepareValuesForSubmit(definition, values);
if (prepared.errors.length > 0) { if (prepared.errors.length > 0) {
...@@ -260,7 +265,7 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) { ...@@ -260,7 +265,7 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) {
onSave(prepared.metadata); onSave(prepared.metadata);
onClose(); onClose();
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '表单行保存失败'); setRequestError(saveError?.message || '表单行保存失败');
} finally { } finally {
setSaving(false); setSaving(false);
} }
...@@ -336,7 +341,7 @@ function FormInstance({ ...@@ -336,7 +341,7 @@ function FormInstance({
onChange, onChange,
onRequestDelete, onRequestDelete,
}) { }) {
const { definition, loading, error } = useFormDefinition(instance); const { definition, loading, error, retry } = useFormDefinition(instance);
const [editingRowIndex, setEditingRowIndex] = useState(null); const [editingRowIndex, setEditingRowIndex] = useState(null);
const formIdentifier = getFormIdentifier(instance.form); const formIdentifier = getFormIdentifier(instance.form);
const formOptions = forms.map((form) => ({ const formOptions = forms.map((form) => ({
...@@ -423,9 +428,12 @@ function FormInstance({ ...@@ -423,9 +428,12 @@ function FormInstance({
</EmptyState.Media> </EmptyState.Media>
<EmptyState.Title>表单模板不可用</EmptyState.Title> <EmptyState.Title>表单模板不可用</EmptyState.Title>
<EmptyState.Description> <EmptyState.Description>
{error}{canEdit ? ' 请重新选择可用的表单模板。' : ''} 详细错误见操作提示,请重新加载或选择可用的表单模板。
</EmptyState.Description> </EmptyState.Description>
</EmptyState.Header> </EmptyState.Header>
<EmptyState.Footer>
<Button size="sm" variant="secondary" onPress={retry}>重新加载</Button>
</EmptyState.Footer>
</EmptyState> </EmptyState>
</EmptyStateSurface> </EmptyStateSurface>
) : formIdentifier ? ( ) : formIdentifier ? (
...@@ -492,7 +500,7 @@ function FormInstance({ ...@@ -492,7 +500,7 @@ function FormInstance({
} }
function ReadonlyFormInstance({ instance }) { function ReadonlyFormInstance({ instance }) {
const { definition, loading, error } = useFormDefinition(instance); const { definition, loading, error, retry } = useFormDefinition(instance);
return ( return (
<Disclosure defaultExpanded> <Disclosure defaultExpanded>
...@@ -516,8 +524,11 @@ function ReadonlyFormInstance({ instance }) { ...@@ -516,8 +524,11 @@ function ReadonlyFormInstance({ instance }) {
<ExclamationTriangleIcon aria-hidden="true" /> <ExclamationTriangleIcon aria-hidden="true" />
</EmptyState.Media> </EmptyState.Media>
<EmptyState.Title>表单模板不可用</EmptyState.Title> <EmptyState.Title>表单模板不可用</EmptyState.Title>
<EmptyState.Description>{error}</EmptyState.Description> <EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header> </EmptyState.Header>
<EmptyState.Footer>
<Button size="sm" variant="secondary" onPress={retry}>重新加载</Button>
</EmptyState.Footer>
</EmptyState> </EmptyState>
</EmptyStateSurface> </EmptyStateSurface>
) : ( ) : (
...@@ -546,7 +557,8 @@ export default function DynamicFormValueEditor({ ...@@ -546,7 +557,8 @@ export default function DynamicFormValueEditor({
const instances = useMemo(() => normalizeInstances(value, multiple), [value, multiple]); const instances = useMemo(() => normalizeInstances(value, multiple), [value, multiple]);
const [forms, setForms] = useState([]); const [forms, setForms] = useState([]);
const [formsLoading, setFormsLoading] = useState(false); const [formsLoading, setFormsLoading] = useState(false);
const [formsError, setFormsError] = useState(''); const [formsError, setFormsError] = useFeedbackState('error');
const [formsRetryKey, setFormsRetryKey] = useState(0);
const [activeGroup, setActiveGroup] = useState(DEFAULT_GROUP); const [activeGroup, setActiveGroup] = useState(DEFAULT_GROUP);
const [newGroupName, setNewGroupName] = useState(''); const [newGroupName, setNewGroupName] = useState('');
const [isGroupDialogOpen, setGroupDialogOpen] = useState(false); const [isGroupDialogOpen, setGroupDialogOpen] = useState(false);
...@@ -581,7 +593,7 @@ export default function DynamicFormValueEditor({ ...@@ -581,7 +593,7 @@ export default function DynamicFormValueEditor({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [canEdit]); }, [canEdit, formsRetryKey, setFormsError]);
const groups = useMemo(() => { const groups = useMemo(() => {
const configured = Array.from(new Set( const configured = Array.from(new Set(
...@@ -770,9 +782,15 @@ export default function DynamicFormValueEditor({ ...@@ -770,9 +782,15 @@ export default function DynamicFormValueEditor({
<Disclosure.Body> <Disclosure.Body>
<div className="space-y-4"> <div className="space-y-4">
{formsError ? ( {formsError ? (
<Alert status="danger"> <EmptyState size="sm">
<Alert.Content><Alert.Description>{formsError}</Alert.Description></Alert.Content> <EmptyState.Header>
</Alert> <EmptyState.Title>表单模板列表加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button variant="secondary" onPress={() => setFormsRetryKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
) : null} ) : null}
{multiple ? ( {multiple ? (
......
import clsx from 'clsx'; import clsx from 'clsx';
import { Alert, Button, Checkbox, Input, Label, Surface, TextArea, TextField } from '@heroui/react'; import { Alert, Button, Checkbox, Input, Label, Surface, TextArea, TextField } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import useFeedbackState from '@/hooks/useFeedbackState';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect'; import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import FileUploadField from '@/components/FileUploadField'; import FileUploadField from '@/components/FileUploadField';
import FormDatePicker from '@/components/FormDatePicker'; import FormDatePicker from '@/components/FormDatePicker';
...@@ -191,7 +193,7 @@ function JsonInput({ value, onChange, placeholder }) { ...@@ -191,7 +193,7 @@ function JsonInput({ value, onChange, placeholder }) {
function FileInput({ fieldDef, value, onChange }) { function FileInput({ fieldDef, value, onChange }) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const handleUpload = async (files) => { const handleUpload = async (files) => {
const file = files?.[0]; const file = files?.[0];
...@@ -240,23 +242,26 @@ function FileInput({ fieldDef, value, onChange }) { ...@@ -240,23 +242,26 @@ function FileInput({ fieldDef, value, onChange }) {
triggerLabel="选择附件" triggerLabel="选择附件"
/> />
{uploading ? <div className="text-sm text-muted">上传中...</div> : null} {uploading ? <div className="text-sm text-muted">上传中...</div> : null}
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
</div> </div>
); );
} }
function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
const [nestedSubject, setNestedSubject] = useState(null); const [nestedSubject, setNestedSubject] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
const subjectName = getReferenceSubjectName(fieldDef); const subjectName = getReferenceSubjectName(fieldDef);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (!subjectName || depth >= MAX_SUBJECT_DEPTH) { if (!subjectName || depth >= MAX_SUBJECT_DEPTH) {
setNestedSubject(null); setNestedSubject(null);
setError('');
return undefined; return undefined;
} }
setNestedSubject(null);
setError('');
loadNestedSubjectDefinition(subjectName) loadNestedSubjectDefinition(subjectName)
.then((result) => { .then((result) => {
if (!cancelled) { if (!cancelled) {
...@@ -273,7 +278,7 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { ...@@ -273,7 +278,7 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [subjectName, depth]); }, [subjectName, depth, retryKey, setError]);
if (!subjectName || depth >= MAX_SUBJECT_DEPTH) { if (!subjectName || depth >= MAX_SUBJECT_DEPTH) {
const fieldLabel = fieldDef?.label || fieldDef?.name || '字段'; const fieldLabel = fieldDef?.label || fieldDef?.name || '字段';
...@@ -287,7 +292,17 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { ...@@ -287,7 +292,17 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
} }
if (error) { if (error) {
return <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>; return (
<EmptyState size="sm">
<EmptyState.Header>
<EmptyState.Title>子对象结构加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
);
} }
if (!nestedSubject) { if (!nestedSubject) {
...@@ -593,6 +608,7 @@ export default function DynamicRecordDialog({ ...@@ -593,6 +608,7 @@ export default function DynamicRecordDialog({
const [values, setValues] = useState({}); const [values, setValues] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
const editing = Boolean(record); const editing = Boolean(record);
const hideTitle = shouldHideViewTitleColumn(view); const hideTitle = shouldHideViewTitleColumn(view);
const dialogColumns = useMemo( const dialogColumns = useMemo(
...@@ -626,6 +642,7 @@ export default function DynamicRecordDialog({ ...@@ -626,6 +642,7 @@ export default function DynamicRecordDialog({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setRequestError('');
return; return;
} }
setTitle(record?.title || record?.metadata?.project_name || ( setTitle(record?.title || record?.metadata?.project_name || (
...@@ -633,11 +650,13 @@ export default function DynamicRecordDialog({ ...@@ -633,11 +650,13 @@ export default function DynamicRecordDialog({
)); ));
setValues(initialValues); setValues(initialValues);
setError(''); setError('');
}, [open, record, initialValues, editing, restrictionDefaults]); setRequestError('');
}, [open, record, initialValues, editing, restrictionDefaults, setRequestError]);
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true); setSaving(true);
setError(''); setError('');
setRequestError('');
try { try {
const prepared = await prepareValuesForSubmit(subject, values); const prepared = await prepareValuesForSubmit(subject, values);
const validationErrors = [...prepared.errors]; const validationErrors = [...prepared.errors];
...@@ -673,7 +692,7 @@ export default function DynamicRecordDialog({ ...@@ -673,7 +692,7 @@ export default function DynamicRecordDialog({
await onSaved?.(); await onSaved?.();
onClose?.(); onClose?.();
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试'); setRequestError(saveError?.message || '保存失败,请稍后重试');
} finally { } finally {
setSaving(false); setSaving(false);
} }
......
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 { AlertDialog, Button } from '@heroui/react'; import { AlertDialog, Button } from '@heroui/react';
import useFeedbackState from '@/hooks/useFeedbackState';
import RecordWorkbench from '@/components/record-workbench'; import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { import {
...@@ -146,10 +147,11 @@ export default function SubjectRecordWorkbench({ ...@@ -146,10 +147,11 @@ export default function SubjectRecordWorkbench({
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 [success, setSuccess] = useState(''); const [, setSuccess] = useFeedbackState('success');
const [loadFailed, setLoadFailed] = useState(false);
const [activeViewName, setActiveViewName] = useState(''); const [activeViewName, setActiveViewName] = useState('');
const [filters, setFilters] = useState({}); const [filters, setFilters] = useState({});
const [pendingFilters, setPendingFilters] = useState({}); const [pendingFilters, setPendingFilters] = useState({});
...@@ -167,8 +169,9 @@ export default function SubjectRecordWorkbench({ ...@@ -167,8 +169,9 @@ export default function SubjectRecordWorkbench({
const requestId = requestSequence.current + 1; const requestId = requestSequence.current + 1;
requestSequence.current = requestId; requestSequence.current = requestId;
setLoading(true); setLoading(true);
setError(''); setLoadFailed(false);
if (!keepFeedback) { if (!keepFeedback) {
setError('');
setSuccess(''); setSuccess('');
} }
...@@ -189,6 +192,7 @@ export default function SubjectRecordWorkbench({ ...@@ -189,6 +192,7 @@ export default function SubjectRecordWorkbench({
setTableDisplayConfigError(resources.displayConfigError || ''); setTableDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) { } catch (requestError) {
if (requestId !== requestSequence.current) return; if (requestId !== requestSequence.current) return;
setLoadFailed(true);
setRecords([]); setRecords([]);
setTotalRecords(0); setTotalRecords(0);
setSubject(null); setSubject(null);
...@@ -201,7 +205,7 @@ export default function SubjectRecordWorkbench({ ...@@ -201,7 +205,7 @@ export default function SubjectRecordWorkbench({
setLoading(false); setLoading(false);
} }
} }
}, [activeViewName, filters, hiddenViewNames, page, pageSize, query, subjectName]); }, [activeViewName, filters, hiddenViewNames, page, pageSize, query, subjectName, setError, setSuccess, setTableDisplayConfigError]);
useEffect(() => { useEffect(() => {
reload(); reload();
...@@ -278,7 +282,7 @@ export default function SubjectRecordWorkbench({ ...@@ -278,7 +282,7 @@ export default function SubjectRecordWorkbench({
setSuccess, setSuccess,
subject, subject,
subjectName, subjectName,
}), [reload, subject, subjectName]); }), [reload, subject, subjectName, setError, setSuccess]);
const wrapAction = (action) => ({ const wrapAction = (action) => ({
...action, ...action,
...@@ -296,7 +300,8 @@ export default function SubjectRecordWorkbench({ ...@@ -296,7 +300,8 @@ export default function SubjectRecordWorkbench({
setSuccess(''); setSuccess('');
try { try {
if (onClone) { if (onClone) {
await onClone({ record, reload, setError, setSuccess, subjectName }); const result = await onClone({ record, reload, setError, setSuccess, subjectName });
if (result === false) return;
} else { } else {
await cloneRecord(record.name || record._id); await cloneRecord(record.name || record._id);
} }
...@@ -318,7 +323,7 @@ export default function SubjectRecordWorkbench({ ...@@ -318,7 +323,7 @@ export default function SubjectRecordWorkbench({
setError(''); setError('');
try { try {
if (onDelete) { if (onDelete) {
await onDelete({ const result = await onDelete({
records: deleteTargets, records: deleteTargets,
selectedRecords: deleteTargets, selectedRecords: deleteTargets,
reload, reload,
...@@ -326,6 +331,7 @@ export default function SubjectRecordWorkbench({ ...@@ -326,6 +331,7 @@ export default function SubjectRecordWorkbench({
setSuccess, setSuccess,
subjectName, subjectName,
}); });
if (result === false) return;
} else { } else {
await batchRemoveRecords({ subject: subjectName, filter: { _id: { $in: ids } } }); await batchRemoveRecords({ subject: subjectName, filter: { _id: { $in: ids } } });
} }
...@@ -471,10 +477,9 @@ export default function SubjectRecordWorkbench({ ...@@ -471,10 +477,9 @@ export default function SubjectRecordWorkbench({
), ),
} : null} } : null}
states={{ states={{
warning: tableDisplayConfigError,
loading, loading,
error, loadFailed,
success, onRetry: () => reload(),
loadingTitle: `正在加载${title}`, loadingTitle: `正在加载${title}`,
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。', loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: hasAccessibleView ? '当前视图暂无数据' : '暂无可访问的视图', emptyTitle: hasAccessibleView ? '当前视图暂无数据' : '暂无可访问的视图',
......
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}
......
...@@ -26,11 +26,11 @@ import FilterSelect from '@/components/FilterSelect'; ...@@ -26,11 +26,11 @@ import FilterSelect from '@/components/FilterSelect';
import FormDatePicker from '@/components/FormDatePicker'; import FormDatePicker from '@/components/FormDatePicker';
import ReferenceSearchSelect from '@/pages/RecordDetailDrawer/components/ReferenceSearchSelect'; import ReferenceSearchSelect from '@/pages/RecordDetailDrawer/components/ReferenceSearchSelect';
import UserSearchSelect from '@/components/UserSearchSelect'; import UserSearchSelect from '@/components/UserSearchSelect';
import { EmptyState } from '@heroui-pro/react/empty-state';
import TableFieldValue from '@/components/TableFieldValue'; import TableFieldValue from '@/components/TableFieldValue';
import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay'; import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView'; import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView';
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Chip, Chip,
...@@ -43,7 +43,6 @@ import { ...@@ -43,7 +43,6 @@ import {
ToggleButton, ToggleButton,
Tooltip, Tooltip,
buttonVariants, buttonVariants,
toast,
} from '@heroui/react'; } from '@heroui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
...@@ -554,13 +553,8 @@ export default function RecordWorkbench({ ...@@ -554,13 +553,8 @@ 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;
useEffect(() => {
if (states?.success) {
toast.success(states.success, { timeout: 4000 });
}
}, [states?.success]);
const columnGroupMeta = useMemo( const columnGroupMeta = useMemo(
() => buildColumnGroupMeta(columnGroups, columns), () => buildColumnGroupMeta(columnGroups, columns),
[columnGroups, columns], [columnGroups, columns],
...@@ -1196,19 +1190,19 @@ export default function RecordWorkbench({ ...@@ -1196,19 +1190,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>
) : null} <EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
{states?.warning ? ( <EmptyState.Footer>
<Alert status="warning"> <Button variant="secondary" onPress={states.onRetry || onQuery}>重新加载</Button>
<Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content> </EmptyState.Footer>
</Alert> </EmptyState>
) : null} ) : null}
<div ref={tableScrollRef} className={tableContentClass}> <div ref={tableScrollRef} className={!loading && loadFailed ? 'hidden' : tableContentClass}>
<DataTable <DataTable
ariaLabel={title || '业务数据列表'} ariaLabel={title || '业务数据列表'}
className={stickyTable ? 'rc-data-table-sticky' : ''} className={stickyTable ? 'rc-data-table-sticky' : ''}
......
/* Keep operation feedback above Modal and the mobile sidebar, outside page scrolling. */
.app-feedback-region.toast-region {
z-index: 300;
top: max(1rem, env(safe-area-inset-top));
}
/* Only the front toast is interactive; its ghost close button stays visible without hover. */
.app-feedback-toast:not([data-frontmost="true"]) .app-feedback-close {
visibility: hidden;
pointer-events: none;
}
import { feedbackQueue } from '@/services/feedbackQueue';
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) feedbackQueue.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 = feedbackQueue.add(
{ title, description: nextMessage, variant },
{ timeout },
);
}, [tone]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (toastKeyRef.current) feedbackQueue.close(toastKeyRef.current);
toastKeyRef.current = null;
};
}, []);
return [message, updateMessage];
}
import { loadRecord } from '@/services/DataService'; import { loadRecord } from '@/services/DataService';
import { Alert, Button, Spinner, Surface } from '@heroui/react'; import { Button, Spinner, Surface } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
...@@ -16,10 +18,13 @@ export default function CompareRecordField() { ...@@ -16,10 +18,13 @@ export default function CompareRecordField() {
const [oldRecord, setOldRecord] = useState(null); const [oldRecord, setOldRecord] = useState(null);
const [newRecord, setNewRecord] = useState(null); const [newRecord, setNewRecord] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true);
setError('');
(async () => { (async () => {
if (recordNames.length < 2 || !field) { if (recordNames.length < 2 || !field) {
setError('缺少需要比较的记录或字段'); setError('缺少需要比较的记录或字段');
...@@ -39,7 +44,7 @@ export default function CompareRecordField() { ...@@ -39,7 +44,7 @@ export default function CompareRecordField() {
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [field, recordNames]); }, [field, recordNames, retryKey, setError]);
const readField = (record) => record?.metadata?.[field] ?? record?.[field]; const readField = (record) => record?.metadata?.[field] ?? record?.[field];
...@@ -58,7 +63,17 @@ export default function CompareRecordField() { ...@@ -58,7 +63,17 @@ export default function CompareRecordField() {
</div> </div>
<Button variant="secondary" onPress={() => history.back()}>返回</Button> <Button variant="secondary" onPress={() => history.back()}>返回</Button>
</div> </div>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null} {error ? (
<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={() => setRetryKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
) : null}
{!error ? ( {!error ? (
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
{[{ label: '旧版本', record: oldRecord }, { label: '新版本', record: newRecord }].map(({ label, record }) => ( {[{ label: '旧版本', record: oldRecord }, { label: '新版本', record: newRecord }].map(({ label, record }) => (
......
...@@ -7,7 +7,8 @@ import { ...@@ -7,7 +7,8 @@ import {
import authService from '@/services/authService'; import authService from '@/services/authService';
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 useFeedbackState from '@/hooks/useFeedbackState';
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';
...@@ -55,7 +56,7 @@ export default function LoginPage() { ...@@ -55,7 +56,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 () => {
...@@ -205,14 +206,6 @@ export default function LoginPage() { ...@@ -205,14 +206,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 useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
...@@ -191,7 +193,8 @@ function NestedSubjectEditor({ ...@@ -191,7 +193,8 @@ function NestedSubjectEditor({
const multiple = Boolean(fieldDef?.multiple); const multiple = Boolean(fieldDef?.multiple);
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 [draft, setDraft] = useState(() => const [draft, setDraft] = useState(() =>
multiple ? (Array.isArray(value) ? value : []) : value || { metadata: {} }, multiple ? (Array.isArray(value) ? value : []) : value || { metadata: {} },
); );
...@@ -244,7 +247,7 @@ function NestedSubjectEditor({ ...@@ -244,7 +247,7 @@ function NestedSubjectEditor({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [multiple, subjectName]); }, [multiple, subjectName, retryKey, setError]);
const handleNestedChange = (childPath, nextValue, itemIndex) => { const handleNestedChange = (childPath, nextValue, itemIndex) => {
const currentItem = multiple ? draft[itemIndex] || { metadata: {} } : draft; const currentItem = multiple ? draft[itemIndex] || { metadata: {} } : draft;
...@@ -296,7 +299,17 @@ function NestedSubjectEditor({ ...@@ -296,7 +299,17 @@ function NestedSubjectEditor({
if (error || !subject) { if (error || !subject) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<FieldError>{error || '子对象字段定义不可用。'}</FieldError> <EmptyState size="sm">
<EmptyState.Header>
<EmptyState.Title>子对象字段定义不可用</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请检查 Subject 配置或重新加载。</EmptyState.Description>
</EmptyState.Header>
{subjectName ? (
<EmptyState.Footer>
<Button variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Footer>
) : null}
</EmptyState>
<FieldValue value={value} fieldDef={fieldDef} /> <FieldValue value={value} fieldDef={fieldDef} />
</div> </div>
); );
...@@ -461,6 +474,8 @@ function FieldValue({ value, fieldDef }) { ...@@ -461,6 +474,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('error');
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],
...@@ -468,6 +483,8 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -468,6 +483,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()
...@@ -487,7 +504,10 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -487,7 +504,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);
} }
}; };
...@@ -496,7 +516,7 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -496,7 +516,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) {
...@@ -527,6 +547,8 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -527,6 +547,8 @@ function UserSelector({ value, onChange, multiple, variant }) {
{...selectionProps} {...selectionProps}
aria-label="用户" aria-label="用户"
emptyText="暂无用户" emptyText="暂无用户"
errorText={error}
isLoading={loading}
inputValue={keyword} inputValue={keyword}
isClearable={selectedIds.length > 0} isClearable={selectedIds.length > 0}
options={autocompleteOptions} options={autocompleteOptions}
...@@ -608,6 +630,7 @@ function ControlledFieldEditor({ ...@@ -608,6 +630,7 @@ function ControlledFieldEditor({
nestedDepth = 0, nestedDepth = 0,
}) { }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
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);
...@@ -620,6 +643,7 @@ function ControlledFieldEditor({ ...@@ -620,6 +643,7 @@ function ControlledFieldEditor({
setUploading(true); setUploading(true);
setError(''); setError('');
setRequestError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId); onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
try { try {
...@@ -627,7 +651,7 @@ function ControlledFieldEditor({ ...@@ -627,7 +651,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);
...@@ -863,6 +887,7 @@ export default function TailwindEditableField({ ...@@ -863,6 +887,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('error');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
useEffect(() => { useEffect(() => {
...@@ -906,6 +931,7 @@ export default function TailwindEditableField({ ...@@ -906,6 +931,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 {
...@@ -920,7 +946,7 @@ export default function TailwindEditableField({ ...@@ -920,7 +946,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 || '保存失败,请稍后重试。');
} }
}; };
...@@ -933,6 +959,7 @@ export default function TailwindEditableField({ ...@@ -933,6 +959,7 @@ export default function TailwindEditableField({
const cancel = () => { const cancel = () => {
setDraft(value); setDraft(value);
setError(''); setError('');
setRequestError('');
setEditing(false); setEditing(false);
}; };
...@@ -944,6 +971,7 @@ export default function TailwindEditableField({ ...@@ -944,6 +971,7 @@ export default function TailwindEditableField({
setUploading(true); setUploading(true);
setError(''); setError('');
setRequestError('');
try { try {
const uploaded = await uploadFile(file); const uploaded = await uploadFile(file);
...@@ -953,7 +981,7 @@ export default function TailwindEditableField({ ...@@ -953,7 +981,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);
} }
...@@ -961,6 +989,7 @@ export default function TailwindEditableField({ ...@@ -961,6 +989,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)
...@@ -974,7 +1003,7 @@ export default function TailwindEditableField({ ...@@ -974,7 +1003,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, {
...@@ -84,7 +86,8 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -84,7 +86,8 @@ 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 [, setSaveSuccess] = useFeedbackState('success');
const editSessionRef = useRef(0); const editSessionRef = useRef(0);
const pendingLocalMaterialsRef = useRef({}); const pendingLocalMaterialsRef = useRef({});
...@@ -117,9 +120,10 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -117,9 +120,10 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
editSessionRef.current += 1; editSessionRef.current += 1;
}, [open, recordName]); }, [open, recordName, setSaveError, setSaveSuccess]);
useEffect(() => { useEffect(() => {
if (!isEditing) { if (!isEditing) {
...@@ -229,6 +233,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -229,6 +233,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
}; };
...@@ -245,6 +250,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -245,6 +250,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
setIsEditing(true); setIsEditing(true);
}; };
...@@ -358,6 +364,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -358,6 +364,7 @@ const RecordDetailDrawer = (props, ref) => {
setIsSaving(true); setIsSaving(true);
setSaveError(''); setSaveError('');
setSaveSuccess('');
try { try {
const pendingMaterials = Object.entries(pendingLocalMaterialsRef.current) const pendingMaterials = Object.entries(pendingLocalMaterialsRef.current)
...@@ -389,6 +396,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -389,6 +396,7 @@ const RecordDetailDrawer = (props, ref) => {
setValidationErrors({}); setValidationErrors({});
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
onDataChange?.(true); onDataChange?.(true);
setSaveSuccess('详情已保存。');
} catch (saveFailure) { } catch (saveFailure) {
setSaveError(saveFailure?.message || '保存失败,请稍后重试。'); setSaveError(saveFailure?.message || '保存失败,请稍后重试。');
} finally { } finally {
...@@ -485,18 +493,17 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -485,18 +493,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 TableFieldValue from '@/components/TableFieldValue'; import TableFieldValue from '@/components/TableFieldValue';
import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay'; import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import { getFieldValue } from '@/utils/subjectView'; import { getFieldValue } from '@/utils/subjectView';
...@@ -20,9 +21,10 @@ export default function ProblemRectificationArchivePage() { ...@@ -20,9 +21,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: '',
}); });
...@@ -33,6 +35,7 @@ export default function ProblemRectificationArchivePage() { ...@@ -33,6 +35,7 @@ export default function ProblemRectificationArchivePage() {
const reloadList = useCallback(async () => { const reloadList = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError(''); setError('');
try { try {
...@@ -43,6 +46,7 @@ export default function ProblemRectificationArchivePage() { ...@@ -43,6 +46,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([]);
...@@ -172,9 +176,9 @@ export default function ProblemRectificationArchivePage() { ...@@ -172,9 +176,9 @@ export default function ProblemRectificationArchivePage() {
), ),
}} }}
states={{ states={{
warning: tableDisplayConfigError,
loading, loading,
error, loadFailed,
onRetry: reloadList,
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> <ArrowPathIcon size={14} />
<Alert.Description>{materialsError}</Alert.Description> 重新加载材料
</Alert.Content> </Button>
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}>
<ArrowPathIcon size={14} />
重试
</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 {
createResolvedXinyuanChartColorMap, createResolvedXinyuanChartColorMap,
getXinyuanChartColor, getXinyuanChartColor,
...@@ -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,
...@@ -459,24 +459,28 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' } ...@@ -459,24 +459,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('error');
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 || [];
...@@ -588,14 +592,6 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' } ...@@ -588,14 +592,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" />
...@@ -603,15 +599,27 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' } ...@@ -603,15 +599,27 @@ 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}
{!loading && !dashboardData.isEmpty && activeScope.isEmpty ? ( {!loading && !dashboardData.error && !dashboardData.isEmpty && activeScope.isEmpty ? (
<EmptyBlock title={`${yearLabel}暂无问题整改数据`} /> <EmptyBlock title={`${yearLabel}暂无问题整改数据`} />
) : null} ) : null}
{!loading && !dashboardData.isEmpty && !activeScope.isEmpty ? ( {!loading && !dashboardData.error && !dashboardData.isEmpty && !activeScope.isEmpty ? (
<> <>
{!isBusinessDashboard ? ( {!isBusinessDashboard ? (
<> <>
......
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';
...@@ -48,7 +49,6 @@ import { ...@@ -48,7 +49,6 @@ import {
import clsx from 'clsx'; import clsx from 'clsx';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@heroui/react';
import { import {
formatFieldValue, formatFieldValue,
getFieldNameFromPath, getFieldNameFromPath,
...@@ -793,10 +793,11 @@ export default function ProblemRectificationListPage() { ...@@ -793,10 +793,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({});
...@@ -814,7 +815,7 @@ export default function ProblemRectificationListPage() { ...@@ -814,7 +815,7 @@ export default function ProblemRectificationListPage() {
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({
...@@ -838,6 +839,7 @@ export default function ProblemRectificationListPage() { ...@@ -838,6 +839,7 @@ export default function ProblemRectificationListPage() {
const reloadResources = useCallback(async () => { const reloadResources = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError(''); setError('');
setSuccessMessage(''); setSuccessMessage('');
...@@ -848,6 +850,7 @@ export default function ProblemRectificationListPage() { ...@@ -848,6 +850,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([]);
...@@ -863,12 +866,6 @@ export default function ProblemRectificationListPage() { ...@@ -863,12 +866,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(
...@@ -1009,6 +1006,7 @@ export default function ProblemRectificationListPage() { ...@@ -1009,6 +1006,7 @@ export default function ProblemRectificationListPage() {
const nextFilters = overrides.filters ?? filters; const nextFilters = overrides.filters ?? filters;
setRecordsLoading(true); setRecordsLoading(true);
setLoadFailed(false);
setError(''); setError('');
if (!overrides.keepSuccessMessage) { if (!overrides.keepSuccessMessage) {
setSuccessMessage(''); setSuccessMessage('');
...@@ -1049,6 +1047,7 @@ export default function ProblemRectificationListPage() { ...@@ -1049,6 +1047,7 @@ export default function ProblemRectificationListPage() {
setPage(nextPagination.totalPages - 1); setPage(nextPagination.totalPages - 1);
} }
} catch (requestError) { } catch (requestError) {
setLoadFailed(true);
setRecords([]); setRecords([]);
setGridViewCounts({}); setGridViewCounts({});
setPagination({ setPagination({
...@@ -1958,9 +1957,8 @@ export default function ProblemRectificationListPage() { ...@@ -1958,9 +1957,8 @@ export default function ProblemRectificationListPage() {
} : null} } : null}
states={{ states={{
loading: loading || recordsLoading, loading: loading || recordsLoading,
error, loadFailed,
warning: tableDisplayConfigError, onRetry: () => subject && activeGridView ? reloadList() : reloadResources(),
success: '',
loadingTitle: '正在加载问题整改列表', loadingTitle: '正在加载问题整改列表',
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。', loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: activeGridView ? '当前视图暂无数据' : '暂无可访问的视图', emptyTitle: activeGridView ? '当前视图暂无数据' : '暂无可访问的视图',
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons'; import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable'; import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Card, Card,
...@@ -11,7 +11,6 @@ import { ...@@ -11,7 +11,6 @@ import {
SearchField, SearchField,
Spinner, Spinner,
Tabs, Tabs,
toast,
} from '@heroui/react'; } from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react'; import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
...@@ -150,7 +149,9 @@ export default function NotificationListPage() { ...@@ -150,7 +149,9 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0); const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [, setOperationError] = useFeedbackState('error');
const [, setSuccess] = useFeedbackState('success');
const [busyKey, setBusyKey] = useState(''); const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -212,21 +213,21 @@ export default function NotificationListPage() { ...@@ -212,21 +213,21 @@ export default function NotificationListPage() {
const runItemOperation = useCallback( const runItemOperation = useCallback(
async (key, operation, successMessage) => { async (key, operation, successMessage) => {
setBusyKey(key); setBusyKey(key);
setOperationError('');
setSuccess('');
try { try {
await operation(); await operation();
toast.success(successMessage, { timeout: 3000 }); setSuccess(successMessage);
refresh(); refresh();
return true; return true;
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', { setOperationError(operationError?.message || '操作失败,请稍后重试');
timeout: 4000,
});
return false; return false;
} finally { } finally {
setBusyKey(''); setBusyKey('');
} }
}, },
[refresh], [refresh, setOperationError, setSuccess],
); );
const handleMarkRead = useCallback( const handleMarkRead = useCallback(
...@@ -260,6 +261,7 @@ export default function NotificationListPage() { ...@@ -260,6 +261,7 @@ export default function NotificationListPage() {
const handleOpenTarget = useCallback( const handleOpenTarget = useCallback(
async (notification, target) => { async (notification, target) => {
setBusyKey(notification.name); setBusyKey(notification.name);
setOperationError('');
try { try {
if (!isNotificationRead(notification, userInfo?._id)) { if (!isNotificationRead(notification, userInfo?._id)) {
await markNotificationRead(notification.name); await markNotificationRead(notification.name);
...@@ -271,14 +273,12 @@ export default function NotificationListPage() { ...@@ -271,14 +273,12 @@ export default function NotificationListPage() {
} }
refresh(); refresh();
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { setOperationError(operationError?.message || '无法打开通知关联流程');
timeout: 4000,
});
} finally { } finally {
setBusyKey(''); setBusyKey('');
} }
}, },
[refresh, userInfo?._id], [refresh, userInfo?._id, setOperationError],
); );
return ( return (
...@@ -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 ? (
......
...@@ -144,6 +144,16 @@ ...@@ -144,6 +144,16 @@
--- ---
### 操作提示与失败重试
- 功能入口:登录、站点配置、应用切换、问题清单、归档批次、驾驶舱/整改分析、详情编辑、证明材料查询与上传、整改/延期流程表格、旧外链流程页和通知中心;与风险业务入口共用应用根部的 `AppFeedback`。
- 查询、保存、复制、删除、锁定/解锁、推送、上传和审批的接口成功/失败统一显示 HeroUI Toast,配置和应用列表降级使用 warning。官方 Portal 在视口顶部呈现,层级高于业务 Modal、确认框和移动侧栏;弹窗打开或页面滚动时可看到并关闭,不抢走表单焦点。
- 成功默认 4 秒、错误/警告默认 8 秒后消失,悬停或键盘聚焦暂停计时;当前提示右侧的关闭图标始终可见,使用 HeroUI 默认小号透明按钮,点击或键盘激活均通过 Toast 的 `close` slot 关闭。后排堆叠提示不显示关闭按钮,避免误关闭。相同错误再次发生时重新弹出,同一来源的新提示替换旧提示,清空反馈或离开来源组件时清理对应提示。
- 关闭提示不修改请求结果、草稿、附件、选中记录、`metadata`、`is_locked` 或审批状态,也不重新执行写入。必填/JSON 校验、锁定说明与删除确认说明保留在对应位置。
- 问题详情弹窗点击“保存”后,只有本次待建档材料和 `updateRecord` 全部成功,才退出编辑并弹出“详情已保存”成功提示,弹窗保持打开并展示已保存内容;任一接口失败仅显示错误并保留草稿。重新开始编辑、再次保存、取消编辑、关闭弹窗或切换记录时清理上一条成功提示;无字段修改、校验未通过或正在保存时不提交、不弹出成功提示。
- 问题清单、归档批次、驾驶舱/整改分析、详情、通知和流程页读取失败时保留失败空状态与“重新加载/重试”;详细原因由浮层展示,不误显示“暂无数据”。重试只读取当前页面资源;问题清单保留当前视图、筛选与页码,通知保留分类、已读状态、关键词与页码。
- 人员、关联记录和材料查询失败时弹出提示,选择器保留字段级加载错误;关联明细和材料保留读取重试入口。详情保存或附件上传失败保留当前编辑内容,可修正后再次提交;批量附件部分失败保留已成功的附件并提示失败项。
## 4. 核心业务对象 ## 4. 核心业务对象
### 4.1 主对象 ### 4.1 主对象
......
import { Alert, Button, Modal, Pagination, Spinner } from '@heroui/react'; import { Alert, Button, Modal, Pagination, Spinner } from '@heroui/react';
import { DataGrid } from '@heroui-pro/react'; import { DataGrid } from '@heroui-pro/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { exportImportIssues } from '@/services/riskImportPreflight'; import { exportImportIssues } from '@/services/riskImportPreflight';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
...@@ -63,12 +64,16 @@ export default function ImportPreflightDialog({ ...@@ -63,12 +64,16 @@ export default function ImportPreflightDialog({
</div> </div>
) : null} ) : null}
{error ? ( {error ? (
<Alert status="danger"> <EmptyState size="sm">
<Alert.Content> <EmptyState.Header>
<Alert.Title>操作未完成</Alert.Title> <EmptyState.Title>操作未完成</EmptyState.Title>
<Alert.Description>{error}</Alert.Description> <EmptyState.Description>
</Alert.Content> {report
</Alert> ? '详细错误见操作提示。请先核对已写入数据,再重新选择文件;本次结果不能重复确认导入。'
: '详细错误见操作提示。请重新选择文件进行预检。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
) : null} ) : null}
{report ? ( {report ? (
<> <>
......
...@@ -2,8 +2,9 @@ import SubjectRecordWorkbench from '@/components/SubjectRecordWorkbench'; ...@@ -2,8 +2,9 @@ import SubjectRecordWorkbench from '@/components/SubjectRecordWorkbench';
import { ArrowDownTrayIcon } from '@/components/AppIcons'; import { ArrowDownTrayIcon } from '@/components/AppIcons';
import { ACTION_LABELS, RISK_MANAGEMENT_SUBJECTS } from '@/config/riskManagementConfig'; import { ACTION_LABELS, RISK_MANAGEMENT_SUBJECTS } from '@/config/riskManagementConfig';
import RiskManagerService from '@/services/RiskManagerService'; import RiskManagerService from '@/services/RiskManagerService';
import useFeedbackState from '@/hooks/useFeedbackState';
import ImportPreflightDialog from './ImportPreflightDialog'; import ImportPreflightDialog from './ImportPreflightDialog';
import { Button, Tooltip, toast } from '@heroui/react'; import { Button, Tooltip } from '@heroui/react';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
function selectedActionRecords(context = {}) { function selectedActionRecords(context = {}) {
...@@ -21,6 +22,9 @@ function RiskManagementListPage({ subjectName }) { ...@@ -21,6 +22,9 @@ function RiskManagementListPage({ subjectName }) {
const importSessionRef = useRef(null); const importSessionRef = useRef(null);
const [importState, setImportState] = useState(null); const [importState, setImportState] = useState(null);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [, setRequestError] = useFeedbackState('error');
const [, setSuccess] = useFeedbackState('success');
const [, setWarning] = useFeedbackState('warning');
const runAction = useCallback(async (actionKey, context = {}, file) => { const runAction = useCallback(async (actionKey, context = {}, file) => {
const methodName = ACTION_LABELS[actionKey]; const methodName = ACTION_LABELS[actionKey];
...@@ -28,12 +32,15 @@ function RiskManagementListPage({ subjectName }) { ...@@ -28,12 +32,15 @@ function RiskManagementListPage({ subjectName }) {
const records = selectedActionRecords(context); const records = selectedActionRecords(context);
if (['copy', 'delete'].includes(actionKey) && records.length === 0) { if (['copy', 'delete'].includes(actionKey) && records.length === 0) {
toast.warning('请选择要处理的记录'); setWarning('请选择要处理的记录');
return; return false;
} }
setActionLoading(true); setActionLoading(true);
context.setError?.(''); context.setError?.('');
setRequestError('');
setSuccess('');
setWarning('');
try { try {
const payload = { const payload = {
records, records,
...@@ -44,16 +51,19 @@ function RiskManagementListPage({ subjectName }) { ...@@ -44,16 +51,19 @@ function RiskManagementListPage({ subjectName }) {
payload.record = records[0]; payload.record = records[0];
} }
await service[methodName](payload); await service[methodName](payload);
toast.success('操作已完成'); if (!['copy', 'delete'].includes(actionKey)) {
await context.reload?.({ keepFeedback: true }); setSuccess('操作已完成');
await context.reload?.({ keepFeedback: true });
}
return true;
} catch (actionError) { } catch (actionError) {
const message = actionError?.message || '操作失败,请稍后重试'; const message = actionError?.message || '操作失败,请稍后重试';
context.setError?.(message); setRequestError(message);
toast.danger(message); return false;
} finally { } finally {
setActionLoading(false); setActionLoading(false);
} }
}, [service, subjectName]); }, [service, subjectName, setRequestError, setSuccess, setWarning]);
const runConfiguredAction = useCallback((actionKey, context) => { const runConfiguredAction = useCallback((actionKey, context) => {
if (actionKey.startsWith('import-')) { if (actionKey.startsWith('import-')) {
...@@ -68,26 +78,31 @@ function RiskManagementListPage({ subjectName }) { ...@@ -68,26 +78,31 @@ function RiskManagementListPage({ subjectName }) {
importSessionRef.current = session; importSessionRef.current = session;
setActionLoading(true); setActionLoading(true);
session.context?.setError?.(''); session.context?.setError?.('');
setRequestError('');
setSuccess('');
setImportState({ isChecking: true, report: null, error: '' }); setImportState({ isChecking: true, report: null, error: '' });
try { try {
const report = await service.preflightImport({ file, actionKey: session.actionKey }); const report = await service.preflightImport({ file, actionKey: session.actionKey });
setImportState({ isChecking: false, report, error: '' }); setImportState({ isChecking: false, report, error: '' });
} catch (error) { } catch (error) {
setImportState({ isChecking: false, report: null, error: error?.message || '预检失败,请稍后重试' }); const message = error?.message || '预检失败,请稍后重试';
setImportState({ isChecking: false, report: null, error: message });
setRequestError(message);
} finally { } finally {
setActionLoading(false); setActionLoading(false);
} }
}, [service]); }, [service, setRequestError, setSuccess]);
const confirmImport = useCallback(async () => { const confirmImport = useCallback(async () => {
if (!importState?.report?.canImport || actionLoading || importState.error) return; if (!importState?.report?.canImport || actionLoading || importState.error) return;
const context = importSessionRef.current?.context || {}; const context = importSessionRef.current?.context || {};
setActionLoading(true); setActionLoading(true);
setRequestError('');
setImportState((state) => ({ ...state, isImporting: true })); setImportState((state) => ({ ...state, isImporting: true }));
try { try {
const result = await service.executePreparedImport(importState.report); const result = await service.executePreparedImport(importState.report);
setImportState(null); setImportState(null);
toast.success(`成功导入 ${result.count} 行数据`); setSuccess(`成功导入 ${result.count} 行数据`);
try { try {
await context.reload?.({ keepFeedback: true }); await context.reload?.({ keepFeedback: true });
} catch (reloadError) { } catch (reloadError) {
...@@ -96,7 +111,7 @@ function RiskManagementListPage({ subjectName }) { ...@@ -96,7 +111,7 @@ function RiskManagementListPage({ subjectName }) {
} catch (error) { } catch (error) {
const message = error?.message || '导入失败,请核对系统数据后重试'; const message = error?.message || '导入失败,请核对系统数据后重试';
setImportState((state) => ({ ...state, isImporting: false, error: message })); setImportState((state) => ({ ...state, isImporting: false, error: message }));
context.setError?.(message); setRequestError(message);
try { try {
await context.reload?.({ keepFeedback: true }); await context.reload?.({ keepFeedback: true });
} catch { } catch {
...@@ -105,7 +120,7 @@ function RiskManagementListPage({ subjectName }) { ...@@ -105,7 +120,7 @@ function RiskManagementListPage({ subjectName }) {
} finally { } finally {
setActionLoading(false); setActionLoading(false);
} }
}, [actionLoading, importState, service]); }, [actionLoading, importState, service, setRequestError, setSuccess]);
const mapAction = useCallback((action) => ({ const mapAction = useCallback((action) => ({
...action, ...action,
......
...@@ -25,7 +25,7 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也 ...@@ -25,7 +25,7 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
1. 页面加载 Subject、Grid View 和当前页记录;查询使用 `POST /record/query`,分页总数直接 1. 页面加载 Subject、Grid View 和当前页记录;查询使用 `POST /record/query`,分页总数直接
使用查询响应中的 `total_records`(兼容 `total_count`、`total` 等响应字段),不再为当前视图 使用查询响应中的 `total_records`(兼容 `total_count`、`total` 等响应字段),不再为当前视图
额外调用 `POST /record/count`。请求期间显示 loading,失败时在列表上方显示可读错误。 额外调用 `POST /record/count`。请求期间显示 loading,失败时通过操作提示展示原因,列表保留加载失败空状态和“重新加载”。
2. Grid View 的 `settings.columns` 决定表格列;应用配置中标记为 `filterView` 的视图继续 2. Grid View 的 `settings.columns` 决定表格列;应用配置中标记为 `filterView` 的视图继续
隐藏,不出现在列表 Tab 中;视图授权和 `settings.restrictions`、`settings.filters.conditions` 隐藏,不出现在列表 Tab 中;视图授权和 `settings.restrictions`、`settings.filters.conditions`
会在查询时转换为 Record API 的 `filter`。可编辑筛选字段映射为 HeroUI 输入、选择、引用 会在查询时转换为 Record API 的 `filter`。可编辑筛选字段映射为 HeroUI 输入、选择、引用
...@@ -81,7 +81,7 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也 ...@@ -81,7 +81,7 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
- 基金初始化逐行检查“基金名称”,所有目标必须存在且唯一;允许同一文件包含多只基金。预检全通过后按目标基金分组导入 `indicator_library`,将新记录 `_id` 合并到各基金的 `contract_terms`,保留已有指标并避免同批多行覆盖。 - 基金初始化逐行检查“基金名称”,所有目标必须存在且唯一;允许同一文件包含多只基金。预检全通过后按目标基金分组导入 `indicator_library`,将新记录 `_id` 合并到各基金的 `contract_terms`,保留已有指标并避免同批多行覆盖。
- 模板关联指标逐行检查“模板标题 + 模板类别”,整份文件必须属于同一个模板及类别,不能仅使用第一行定位后忽略其余行。确认后导入指标,并沿用原规则更新目标模板的 `associated_indicators`;导出反向生成含模板信息的 Excel。 - 模板关联指标逐行检查“模板标题 + 模板类别”,整份文件必须属于同一个模板及类别,不能仅使用第一行定位后忽略其余行。确认后导入指标,并沿用原规则更新目标模板的 `associated_indicators`;导出反向生成含模板信息的 Excel。
- 托管清单逐行检查“公司名称 + 基金类型”,整批数据必须属于同一家公司和基金类型,且能定位唯一现有托管人记录。基金类型优先从“类别”读取,兼容旧文件仅使用“类型”的格式;当“类型”用于基金类型时,不误写到关联关系的管理类型字段。新版文件可同时使用“类别(公募/专户)”和“类型(管理人/托管人)”两列,列名分别保持“类别”和“类型”。按 `relation_related_party` 定义校验后创建关联关系,将新 `_id[]` 合并到公司的 `related_parties`。 - 托管清单逐行检查“公司名称 + 基金类型”,整批数据必须属于同一家公司和基金类型,且能定位唯一现有托管人记录。基金类型优先从“类别”读取,兼容旧文件仅使用“类型”的格式;当“类型”用于基金类型时,不误写到关联关系的管理类型字段。新版文件可同时使用“类别(公募/专户)”和“类型(管理人/托管人)”两列,列名分别保持“类别”和“类型”。按 `relation_related_party` 定义校验后创建关联关系,将新 `_id[]` 合并到公司的 `related_parties`。
- 确认后先重新读取目标记录,确保目标仍可用;追加关联以确认时的最新引用列表为基础,避免覆盖预检等待期间其他用户新增的关联。正式写入检查接口返回的 ID 数量与待写行数是否一致,再更新关联。记录写入或关联更新失败时停止后续目标组,在弹窗和工作台保留具体 Excel 行号、失败阶段、服务端原因和已完整完成的行数,并刷新列表供核对;不把部分完成显示为成功,不提供同一结果直接重复确认,以免重复导入。服务端写入可能已部分执行,需核对系统数据后修正或清理再重试;预检不代表多接口事务回滚。 - 确认后先重新读取目标记录,确保目标仍可用;追加关联以确认时的最新引用列表为基础,避免覆盖预检等待期间其他用户新增的关联。正式写入检查接口返回的 ID 数量与待写行数是否一致,再更新关联。记录写入或关联更新失败时停止后续目标组,通过操作提示展示具体 Excel 行号、失败阶段、服务端原因和已完整完成的行数;弹窗保留“操作未完成”和不能重复确认的状态,并刷新列表供核对。不把部分完成显示为成功,不提供同一结果直接重复确认,以免重复导入。服务端写入可能已部分执行,需核对系统数据后修正或清理再重试;预检不代表多接口事务回滚。
## 5. 行操作与异常处理 ## 5. 行操作与异常处理
...@@ -91,6 +91,7 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也 ...@@ -91,6 +91,7 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
- 所有异步动作都会禁用对应按钮并显示 loading;失败不会吞掉服务端错误,会通过 Toast - 所有异步动作都会禁用对应按钮并显示 loading;失败不会吞掉服务端错误,会通过 Toast
给出原因和恢复方向。删除动作先经过 HeroUI 确认弹窗;历史版本比较会过滤 removed 给出原因和恢复方向。删除动作先经过 HeroUI 确认弹窗;历史版本比较会过滤 removed
引用并要求至少两条可用版本。 引用并要求至少两条可用版本。
- 复制和删除的业务回调返回失败时,工作台保留原操作状态,不再追加成功提示或关闭删除确认框;每次操作仅由对应反馈来源展示结果。
- 引用记录缺失、历史版本不足、导入文件为空或目标公司/模版不存在时,在写入前提示用户 - 引用记录缺失、历史版本不足、导入文件为空或目标公司/模版不存在时,在写入前提示用户
检查数据后重试;写入阶段的接口失败会保留服务端错误,避免静默掩盖部分成功结果。 检查数据后重试;写入阶段的接口失败会保留服务端错误,避免静默掩盖部分成功结果。
...@@ -105,4 +106,15 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也 ...@@ -105,4 +106,15 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
- 引用、选择、子对象与 form 字段适用上述规则。子对象优先读取 `titleField`(支持 metadata 子字段),然后使用已有 `title/name/_id`,无标题时显示“第 N 条”;`{form,data}` 按 `data` 条目计数。空字符串、null 和 undefined 不计数,数字 `0` 保留。 - 引用、选择、子对象与 form 字段适用上述规则。子对象优先读取 `titleField`(支持 metadata 子字段),然后使用已有 `title/name/_id`,无标题时显示“第 N 条”;`{form,data}` 按 `data` 条目计数。空字符串、null 和 undefined 不计数,数字 `0` 保留。
- 例如基金产品的 `metadata.contract_terms`、投监表模版的 `metadata.associated_indicators` 可配置 `displayMode: "count"`;基金产品的 `metadata.investment_scope` 可配置 `displayMode: "labels"`。字段仍以 Subject 定义为准。 - 例如基金产品的 `metadata.contract_terms`、投监表模版的 `metadata.associated_indicators` 可配置 `displayMode: "count"`;基金产品的 `metadata.investment_scope` 可配置 `displayMode: "labels"`。字段仍以 Subject 定义为准。
- 未配置或配置非法时保留原展示;普通文本、数字、日期、布尔、人员和附件不受影响。首列仍可点击打开详情,行操作入口保持可用。计数只使用当前字段已有数据,不额外查询关联总数,不修改 metadata、筛选、排序、分页、导出或审批规则。 - 未配置或配置非法时保留原展示;普通文本、数字、日期、布尔、人员和附件不受影响。首列仍可点击打开详情,行操作入口保持可用。计数只使用当前字段已有数据,不额外查询关联总数,不修改 metadata、筛选、排序、分页、导出或审批规则。
- 加载配置期间沿用列表 loading;应用配置失败时显示警告并继续使用视图配置和列表数据。共享工作台按 Subject 缓存配置,刷新页面重新读取;记录查询失败仍显示列表错误。 - 加载配置期间沿用列表 loading;应用配置失败时弹出警告并继续使用视图配置和列表数据。共享工作台按 Subject 缓存配置,刷新页面重新读取;记录查询失败保留失败空状态与读取重试入口。
## 7. 操作提示与失败重试
- 功能入口:登录、站点配置、应用切换、六个风险业务列表、详情、新建/编辑、子对象与动态表单、附件、导入预检、历史投监表版本比较和通知中心,以及保留的问题整改兼容路由。
- 查询、保存、复制、删除、导入、导出、创建历史版本和通知操作的成功/失败统一显示 HeroUI Toast。根部统一挂载,官方 Portal 在视口顶部呈现,层级高于 Modal、确认框和移动侧栏;弹窗打开或页面滚动时也可看到并关闭,不抢走表单焦点。
- 成功提示默认 4 秒、错误与警告默认 8 秒自动消失,悬停或键盘聚焦暂停计时;当前提示右侧的关闭图标始终可见,使用 HeroUI 默认小号透明按钮,点击或键盘激活均通过 Toast 的 `close` slot 关闭。后排堆叠提示不显示关闭按钮,避免误关闭。相同错误再次发生时重新弹出,同一来源的新提示替换旧提示;清空反馈或离开来源组件时清理该来源提示。
- 提示使用官方 ToastQueue 即时更新,避免提示进出动画期间的点击被浏览器快照拦截并误关下方弹窗;鼠标和键盘关闭都只移除提示,弹窗保持打开。
- 提示关闭仅影响展示,不把失败改为成功,不清空草稿、选中记录、附件或导入预检结果,也不发起写入或修改 `metadata`。必填/JSON 校验、锁定说明、删除确认与导入预检错误明细继续保留在对应位置。
- 六个风险业务列表共用的详情弹窗点击“保存”后,只有 `updateRecord` 成功才退出编辑并弹出“详情已保存”成功提示,弹窗保持打开并展示已保存内容;接口失败仅显示错误并保留草稿。重新开始编辑、再次保存、取消编辑、关闭弹窗或切换记录时清理上一条成功提示;无字段修改、校验未通过或正在保存时不提交、不弹出成功提示。兼容问题详情的待建档材料必须全部创建成功后再更新记录。
- 列表、详情、子对象结构、动态表单模板、历史版本比较和通知加载失败时保留失败空状态及“重新加载/重试”;详细原因由浮层展示,避免误显示“暂无数据”。重试只读取资源,列表沿用当前 Subject、Grid View、关键词/字段筛选与分页,历史比较保留 URL 中的记录和字段,通知保留分类、已读状态、关键词与分页。
- 导入预检的读取接口失败时保留弹窗并允许重新选择文件;正式写入失败时保留失败状态并禁用本次结果的“确认导入”。关闭提示不能重新启用该按钮,也不能清除已完成行数所对应的服务端数据。列表刷新不清除本次导入错误提示;附件上传或详情保存失败保留草稿,可修正后再次提交。
import { ToastQueue } from '@heroui/react';
// View-transition snapshots can intercept a close press and dismiss the Modal below.
// Apply queue updates immediately so the official live Toast remains interactive.
export const feedbackQueue = new ToastQueue({
maxVisibleToasts: 3,
wrapUpdate: (update) => update(),
});
...@@ -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