Commit a34ec141 authored by drigle's avatar drigle

feat: unify operation feedback and failure retries

parent 22d96165
......@@ -31,6 +31,14 @@
- 共享组件只能组合 HeroUI/HeroUI Pro 的官方 compound API,不得复刻旧组件 props。
- 官方组件暂不覆盖的场景应优先组合 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. 主题权威
- `src/design-system/theme.css` 是浅色、暗色、品牌色、语义色、圆角和字体的唯一主题权威,以 HeroUI 官方主题工具导出值为基础,并在文件内固化批准的 L1/L2/Overlay 映射。它必须与 `/Users/liuchenlong/Desktop/theme.css` 及共享 Skill 基线逐字节一致;升级 HeroUI 时重新导出基础值、复核三项语义映射并整体批准新基线。
......
{
"name": "risk_manager_new",
"version": "1.0.2",
"version": "1.0.4",
"build": "abc",
"description": "风险管理应用",
"date": "2025-07-14 19:27:20",
......
import '@/tailwind.css';
import { defineApp } from 'umi';
import { createElement } from 'react';
import AppFeedback from '@/components/AppFeedback';
import {
APPLICATION_BASE_PATH,
APPLICATION_NAME,
......@@ -40,4 +42,8 @@ export async function getInitialState() {
};
}
export default defineApp({});
export function rootContainer(container) {
return createElement(AppFeedback, null, container);
}
export default defineApp({ rootContainer });
import { 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 { Toast } from '@heroui/react';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
......@@ -32,7 +31,6 @@ export default function AppShell({
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
......@@ -11,6 +11,7 @@ import {
TextField,
} from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import useFeedbackState from '@/hooks/useFeedbackState';
import {
DataTable,
DataTableText,
......@@ -117,7 +118,8 @@ function useFormDefinition(instance) {
const identifier = getFormIdentifier(instance?.form);
const [definition, setDefinition] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
let cancelled = false;
......@@ -152,9 +154,9 @@ function useFormDefinition(instance) {
return () => {
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 }) {
......@@ -240,17 +242,20 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) {
const [values, setValues] = useState({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
useEffect(() => {
setRequestError('');
if (isOpen) {
setValues(row && typeof row === 'object' ? row : {});
setError('');
}
}, [isOpen, row]);
}, [isOpen, row, setRequestError]);
const handleSave = async () => {
setSaving(true);
setError('');
setRequestError('');
try {
const prepared = await prepareValuesForSubmit(definition, values);
if (prepared.errors.length > 0) {
......@@ -260,7 +265,7 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) {
onSave(prepared.metadata);
onClose();
} catch (saveError) {
setError(saveError?.message || '表单行保存失败');
setRequestError(saveError?.message || '表单行保存失败');
} finally {
setSaving(false);
}
......@@ -336,7 +341,7 @@ function FormInstance({
onChange,
onRequestDelete,
}) {
const { definition, loading, error } = useFormDefinition(instance);
const { definition, loading, error, retry } = useFormDefinition(instance);
const [editingRowIndex, setEditingRowIndex] = useState(null);
const formIdentifier = getFormIdentifier(instance.form);
const formOptions = forms.map((form) => ({
......@@ -423,9 +428,12 @@ function FormInstance({
</EmptyState.Media>
<EmptyState.Title>表单模板不可用</EmptyState.Title>
<EmptyState.Description>
{error}{canEdit ? ' 请重新选择可用的表单模板。' : ''}
详细错误见操作提示,请重新加载或选择可用的表单模板。
</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button size="sm" variant="secondary" onPress={retry}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
</EmptyStateSurface>
) : formIdentifier ? (
......@@ -492,7 +500,7 @@ function FormInstance({
}
function ReadonlyFormInstance({ instance }) {
const { definition, loading, error } = useFormDefinition(instance);
const { definition, loading, error, retry } = useFormDefinition(instance);
return (
<Disclosure defaultExpanded>
......@@ -516,8 +524,11 @@ function ReadonlyFormInstance({ instance }) {
<ExclamationTriangleIcon aria-hidden="true" />
</EmptyState.Media>
<EmptyState.Title>表单模板不可用</EmptyState.Title>
<EmptyState.Description>{error}</EmptyState.Description>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button size="sm" variant="secondary" onPress={retry}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
</EmptyStateSurface>
) : (
......@@ -546,7 +557,8 @@ export default function DynamicFormValueEditor({
const instances = useMemo(() => normalizeInstances(value, multiple), [value, multiple]);
const [forms, setForms] = useState([]);
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 [newGroupName, setNewGroupName] = useState('');
const [isGroupDialogOpen, setGroupDialogOpen] = useState(false);
......@@ -581,7 +593,7 @@ export default function DynamicFormValueEditor({
return () => {
cancelled = true;
};
}, [canEdit]);
}, [canEdit, formsRetryKey, setFormsError]);
const groups = useMemo(() => {
const configured = Array.from(new Set(
......@@ -770,9 +782,15 @@ export default function DynamicFormValueEditor({
<Disclosure.Body>
<div className="space-y-4">
{formsError ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{formsError}</Alert.Description></Alert.Content>
</Alert>
<EmptyState size="sm">
<EmptyState.Header>
<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}
{multiple ? (
......
import clsx from 'clsx';
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 FileUploadField from '@/components/FileUploadField';
import FormDatePicker from '@/components/FormDatePicker';
......@@ -191,7 +193,7 @@ function JsonInput({ value, onChange, placeholder }) {
function FileInput({ fieldDef, value, onChange }) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const handleUpload = async (files) => {
const file = files?.[0];
......@@ -240,23 +242,26 @@ function FileInput({ fieldDef, value, onChange }) {
triggerLabel="选择附件"
/>
{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>
);
}
function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
const [nestedSubject, setNestedSubject] = useState(null);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
const subjectName = getReferenceSubjectName(fieldDef);
useEffect(() => {
let cancelled = false;
if (!subjectName || depth >= MAX_SUBJECT_DEPTH) {
setNestedSubject(null);
setError('');
return undefined;
}
setNestedSubject(null);
setError('');
loadNestedSubjectDefinition(subjectName)
.then((result) => {
if (!cancelled) {
......@@ -273,7 +278,7 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return () => {
cancelled = true;
};
}, [subjectName, depth]);
}, [subjectName, depth, retryKey, setError]);
if (!subjectName || depth >= MAX_SUBJECT_DEPTH) {
const fieldLabel = fieldDef?.label || fieldDef?.name || '字段';
......@@ -287,7 +292,17 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
}
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) {
......@@ -593,6 +608,7 @@ export default function DynamicRecordDialog({
const [values, setValues] = useState({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
const editing = Boolean(record);
const hideTitle = shouldHideViewTitleColumn(view);
const dialogColumns = useMemo(
......@@ -626,6 +642,7 @@ export default function DynamicRecordDialog({
useEffect(() => {
if (!open) {
setRequestError('');
return;
}
setTitle(record?.title || record?.metadata?.project_name || (
......@@ -633,11 +650,13 @@ export default function DynamicRecordDialog({
));
setValues(initialValues);
setError('');
}, [open, record, initialValues, editing, restrictionDefaults]);
setRequestError('');
}, [open, record, initialValues, editing, restrictionDefaults, setRequestError]);
const handleSubmit = async () => {
setSaving(true);
setError('');
setRequestError('');
try {
const prepared = await prepareValuesForSubmit(subject, values);
const validationErrors = [...prepared.errors];
......@@ -673,7 +692,7 @@ export default function DynamicRecordDialog({
await onSaved?.();
onClose?.();
} catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试');
setRequestError(saveError?.message || '保存失败,请稍后重试');
} finally {
setSaving(false);
}
......
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { queryRecords } from '@/services/DataService';
import { Button, Label } from '@heroui/react';
......@@ -334,7 +335,7 @@ export default function ReferenceSearchSelect({
const [options, setOptions] = useState([]);
const [selectedById, setSelectedById] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [active, setActive] = useState(false);
const lastSearchRequestId = useRef(0);
......
import { AlertDialog, Button } from '@heroui/react';
import useFeedbackState from '@/hooks/useFeedbackState';
import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import {
......@@ -146,10 +147,11 @@ export default function SubjectRecordWorkbench({
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [tableDisplayConfig, setTableDisplayConfig] = useState({});
const [tableDisplayConfigError, setTableDisplayConfigError] = useState('');
const [, setTableDisplayConfigError] = useFeedbackState('warning');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [, setError] = useFeedbackState('error');
const [, setSuccess] = useFeedbackState('success');
const [loadFailed, setLoadFailed] = useState(false);
const [activeViewName, setActiveViewName] = useState('');
const [filters, setFilters] = useState({});
const [pendingFilters, setPendingFilters] = useState({});
......@@ -167,8 +169,9 @@ export default function SubjectRecordWorkbench({
const requestId = requestSequence.current + 1;
requestSequence.current = requestId;
setLoading(true);
setError('');
setLoadFailed(false);
if (!keepFeedback) {
setError('');
setSuccess('');
}
......@@ -189,6 +192,7 @@ export default function SubjectRecordWorkbench({
setTableDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) {
if (requestId !== requestSequence.current) return;
setLoadFailed(true);
setRecords([]);
setTotalRecords(0);
setSubject(null);
......@@ -201,7 +205,7 @@ export default function SubjectRecordWorkbench({
setLoading(false);
}
}
}, [activeViewName, filters, hiddenViewNames, page, pageSize, query, subjectName]);
}, [activeViewName, filters, hiddenViewNames, page, pageSize, query, subjectName, setError, setSuccess, setTableDisplayConfigError]);
useEffect(() => {
reload();
......@@ -278,7 +282,7 @@ export default function SubjectRecordWorkbench({
setSuccess,
subject,
subjectName,
}), [reload, subject, subjectName]);
}), [reload, subject, subjectName, setError, setSuccess]);
const wrapAction = (action) => ({
...action,
......@@ -296,7 +300,8 @@ export default function SubjectRecordWorkbench({
setSuccess('');
try {
if (onClone) {
await onClone({ record, reload, setError, setSuccess, subjectName });
const result = await onClone({ record, reload, setError, setSuccess, subjectName });
if (result === false) return;
} else {
await cloneRecord(record.name || record._id);
}
......@@ -318,7 +323,7 @@ export default function SubjectRecordWorkbench({
setError('');
try {
if (onDelete) {
await onDelete({
const result = await onDelete({
records: deleteTargets,
selectedRecords: deleteTargets,
reload,
......@@ -326,6 +331,7 @@ export default function SubjectRecordWorkbench({
setSuccess,
subjectName,
});
if (result === false) return;
} else {
await batchRemoveRecords({ subject: subjectName, filter: { _id: { $in: ids } } });
}
......@@ -471,10 +477,9 @@ export default function SubjectRecordWorkbench({
),
} : null}
states={{
warning: tableDisplayConfigError,
loading,
error,
success,
loadFailed,
onRetry: () => reload(),
loadingTitle: `正在加载${title}`,
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: hasAccessibleView ? '当前视图暂无数据' : '暂无可访问的视图',
......
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Label } from '@heroui/react';
import { loadUser, queryUsers } from '@/services/DataService';
import { normalizeUserLabel, toArray } from '@/utils/subjectView';
......@@ -21,11 +22,13 @@ export default function UserSearchSelect({
const [users, setUsers] = useState([]);
const [profiles, setProfiles] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState();
const selectedIds = useMemo(() => toArray(value).map(getUserId).filter(Boolean), [value]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
const text = keyword.trim();
queryUsers(0, 30, text ? {
$or: [
......@@ -42,14 +45,17 @@ export default function UserSearchSelect({
...Object.fromEntries(nextUsers.map((user) => [getUserId(user), user])),
}));
})
.catch(() => {
if (!cancelled) setUsers([]);
.catch((requestError) => {
if (!cancelled) {
setUsers([]);
setError(requestError?.message || '用户列表加载失败,请重新搜索。');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [keyword]);
}, [keyword, setError]);
useEffect(() => {
const missing = selectedIds.filter((id) => !profiles[id]);
......@@ -82,6 +88,7 @@ export default function UserSearchSelect({
<XinyuanAutocomplete
aria-label={ariaLabel}
inputValue={keyword}
errorText={error}
isDisabled={disabled}
isClearable={selectedIds.length > 0}
isLoading={loading}
......
......@@ -26,11 +26,11 @@ import FilterSelect from '@/components/FilterSelect';
import FormDatePicker from '@/components/FormDatePicker';
import ReferenceSearchSelect from '@/pages/RecordDetailDrawer/components/ReferenceSearchSelect';
import UserSearchSelect from '@/components/UserSearchSelect';
import { EmptyState } from '@heroui-pro/react/empty-state';
import TableFieldValue from '@/components/TableFieldValue';
import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView';
import {
Alert,
Avatar,
Button,
Chip,
......@@ -43,7 +43,6 @@ import {
ToggleButton,
Tooltip,
buttonVariants,
toast,
} from '@heroui/react';
import clsx from 'clsx';
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
......@@ -554,13 +553,8 @@ export default function RecordWorkbench({
const [expandedRowIds, setExpandedRowIds] = useState({});
const filterFieldVariant = 'secondary';
const loading = Boolean(states?.loading);
const error = states?.error || '';
const loadFailed = Boolean(states?.loadFailed);
const totalRecords = pagination?.totalRecords ?? records.length;
useEffect(() => {
if (states?.success) {
toast.success(states.success, { timeout: 4000 });
}
}, [states?.success]);
const columnGroupMeta = useMemo(
() => buildColumnGroupMeta(columnGroups, columns),
[columnGroups, columns],
......@@ -1196,19 +1190,19 @@ export default function RecordWorkbench({
) : null}
</div>
{error ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
</Alert>
) : null}
{states?.warning ? (
<Alert status="warning">
<Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content>
</Alert>
{!loading && loadFailed ? (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>{title || '列表'}加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button variant="secondary" onPress={states.onRetry || onQuery}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
) : null}
<div ref={tableScrollRef} className={tableContentClass}>
<div ref={tableScrollRef} className={!loading && loadFailed ? 'hidden' : tableContentClass}>
<DataTable
ariaLabel={title || '业务数据列表'}
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 { 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 { history } from 'umi';
......@@ -16,10 +18,13 @@ export default function CompareRecordField() {
const [oldRecord, setOldRecord] = useState(null);
const [newRecord, setNewRecord] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
(async () => {
if (recordNames.length < 2 || !field) {
setError('缺少需要比较的记录或字段');
......@@ -39,7 +44,7 @@ export default function CompareRecordField() {
}
})();
return () => { cancelled = true; };
}, [field, recordNames]);
}, [field, recordNames, retryKey, setError]);
const readField = (record) => record?.metadata?.[field] ?? record?.[field];
......@@ -58,7 +63,17 @@ export default function CompareRecordField() {
</div>
<Button variant="secondary" onPress={() => history.back()}>返回</Button>
</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 ? (
<div className="grid gap-4 lg:grid-cols-2">
{[{ label: '旧版本', record: oldRecord }, { label: '新版本', record: newRecord }].map(({ label, record }) => (
......
......@@ -7,7 +7,8 @@ import {
import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
import { Button, Card, Input, Label, TextField } from '@heroui/react';
import useFeedbackState from '@/hooks/useFeedbackState';
import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi';
......@@ -55,7 +56,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => {
......@@ -205,14 +206,6 @@ export default function LoginPage() {
</div>
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
className="login-submit-button"
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react';
import { DataTable, DataTableEmptyState } from '@/components/DataTable';
import { Alert, Spinner } from '@heroui/react';
import { Alert, Button, Spinner } from '@heroui/react';
import { loadSubject, queryRecords } from '@/services/DataService';
import {
formatDateText,
......@@ -131,7 +132,8 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
const [subject, setSubject] = useState(null);
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
let cancelled = false;
......@@ -195,7 +197,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return () => {
cancelled = true;
};
}, [pageSize, populatedRecords, referenceIds, subjectName]);
}, [pageSize, populatedRecords, referenceIds, subjectName, retryKey, setError]);
if (referenceIds.length === 0) {
return (
......@@ -232,15 +234,13 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return (
<div className="space-y-2">
{error ? (
<Alert status="warning">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
<Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>
重新加载关联明细
</Button>
) : null}
<ReferenceRecordsFrame action={action} count={records.length}>
{records.length === 0 ? (
<DataTableEmptyState title="暂无可展示的关联明细" />
<DataTableEmptyState title={error ? '关联明细加载失败,详细错误见操作提示' : '暂无可展示的关联明细'} />
) : (
<DataTable
ariaLabel="关联记录"
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { useEffect, useMemo, useState } from 'react';
import clsx from 'clsx';
import {
......@@ -191,7 +193,8 @@ function NestedSubjectEditor({
const multiple = Boolean(fieldDef?.multiple);
const [subject, setSubject] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
const [draft, setDraft] = useState(() =>
multiple ? (Array.isArray(value) ? value : []) : value || { metadata: {} },
);
......@@ -244,7 +247,7 @@ function NestedSubjectEditor({
return () => {
cancelled = true;
};
}, [multiple, subjectName]);
}, [multiple, subjectName, retryKey, setError]);
const handleNestedChange = (childPath, nextValue, itemIndex) => {
const currentItem = multiple ? draft[itemIndex] || { metadata: {} } : draft;
......@@ -296,7 +299,17 @@ function NestedSubjectEditor({
if (error || !subject) {
return (
<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} />
</div>
);
......@@ -461,6 +474,8 @@ function FieldValue({ value, fieldDef }) {
function UserSelector({ value, onChange, multiple, variant }) {
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState('error');
const selectedIds = useMemo(
() => toArray(value).map((item) => item?._id || item).filter(Boolean),
[value],
......@@ -468,6 +483,8 @@ function UserSelector({ value, onChange, multiple, variant }) {
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
const loadOptions = async () => {
const filter = keyword.trim()
......@@ -487,7 +504,10 @@ function UserSelector({ value, onChange, multiple, variant }) {
} catch (error) {
if (!cancelled) {
setOptions([]);
setError(error?.message || '用户列表加载失败,请重新搜索。');
}
} finally {
if (!cancelled) setLoading(false);
}
};
......@@ -496,7 +516,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
return () => {
cancelled = true;
};
}, [keyword]);
}, [keyword, setError]);
const handleSelectionChange = (keys) => {
if (!multiple) {
......@@ -527,6 +547,8 @@ function UserSelector({ value, onChange, multiple, variant }) {
{...selectionProps}
aria-label="用户"
emptyText="暂无用户"
errorText={error}
isLoading={loading}
inputValue={keyword}
isClearable={selectedIds.length > 0}
options={autocompleteOptions}
......@@ -608,6 +630,7 @@ function ControlledFieldEditor({
nestedDepth = 0,
}) {
const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
const [uploading, setUploading] = useState(false);
const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType);
......@@ -620,6 +643,7 @@ function ControlledFieldEditor({
setUploading(true);
setError('');
setRequestError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
try {
......@@ -627,7 +651,7 @@ function ControlledFieldEditor({
const fileValue = uploaded?.file || uploaded?.data || uploaded;
onChange?.(fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue);
} catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。');
setRequestError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
......@@ -863,6 +887,7 @@ export default function TailwindEditableField({
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
const [uploading, setUploading] = useState(false);
useEffect(() => {
......@@ -906,6 +931,7 @@ export default function TailwindEditableField({
const save = async (nextValue = draft) => {
setError('');
setRequestError('');
if (fieldType === 'object' && typeof nextValue === 'string' && nextValue.trim()) {
try {
......@@ -920,7 +946,7 @@ export default function TailwindEditableField({
await onSave(asSaveValue(nextValue, fieldDef));
setEditing(false);
} catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。');
setRequestError(saveError?.message || '保存失败,请稍后重试。');
}
};
......@@ -933,6 +959,7 @@ export default function TailwindEditableField({
const cancel = () => {
setDraft(value);
setError('');
setRequestError('');
setEditing(false);
};
......@@ -944,6 +971,7 @@ export default function TailwindEditableField({
setUploading(true);
setError('');
setRequestError('');
try {
const uploaded = await uploadFile(file);
......@@ -953,7 +981,7 @@ export default function TailwindEditableField({
await onSave(nextValue);
setEditing(false);
} catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。');
setRequestError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
}
......@@ -961,6 +989,7 @@ export default function TailwindEditableField({
const handleRemoveFile = async (indexToRemove) => {
setError('');
setRequestError('');
const nextValue = fieldDef?.multiple
? toArray(draft).filter((_, index) => index !== indexToRemove)
......@@ -974,7 +1003,7 @@ export default function TailwindEditableField({
}
} catch (saveError) {
setDraft(draft);
setError(saveError?.message || '删除附件失败,请稍后重试。');
setRequestError(saveError?.message || '删除附件失败,请稍后重试。');
}
};
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useRef, useState } from 'react';
import {
getSubjectView,
......@@ -75,7 +76,7 @@ export function useSubjectRecord(
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const requestIdRef = useRef(0);
useEffect(() => {
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons';
import { Alert, Button, Modal, Spinner, Tabs, Tooltip } from '@heroui/react';
import React, {
......@@ -84,7 +86,8 @@ const RecordDetailDrawer = (props, ref) => {
const [draftRecord, setDraftRecord] = useState(null);
const [dirtyFields, setDirtyFields] = useState({});
const [validationErrors, setValidationErrors] = useState({});
const [saveError, setSaveError] = useState('');
const [, setSaveError] = useFeedbackState('error');
const [, setSaveSuccess] = useFeedbackState('success');
const editSessionRef = useRef(0);
const pendingLocalMaterialsRef = useRef({});
......@@ -117,9 +120,10 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {};
editSessionRef.current += 1;
}, [open, recordName]);
}, [open, recordName, setSaveError, setSaveSuccess]);
useEffect(() => {
if (!isEditing) {
......@@ -229,6 +233,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {};
};
......@@ -245,6 +250,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {};
setIsEditing(true);
};
......@@ -358,6 +364,7 @@ const RecordDetailDrawer = (props, ref) => {
setIsSaving(true);
setSaveError('');
setSaveSuccess('');
try {
const pendingMaterials = Object.entries(pendingLocalMaterialsRef.current)
......@@ -389,6 +396,7 @@ const RecordDetailDrawer = (props, ref) => {
setValidationErrors({});
pendingLocalMaterialsRef.current = {};
onDataChange?.(true);
setSaveSuccess('详情已保存。');
} catch (saveFailure) {
setSaveError(saveFailure?.message || '保存失败,请稍后重试。');
} finally {
......@@ -485,18 +493,17 @@ const RecordDetailDrawer = (props, ref) => {
<Spinner size="sm" />
</div>
) : error ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
</Alert>
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>详情加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button variant="secondary" onPress={() => setRefreshKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
) : (
<div>
{saveError ? (
<Alert className="mb-4" status="danger">
<Alert.Content>
<Alert.Description>{saveError}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{isRecordLocked ? (
<Alert status="warning">
<Alert.Content>
......
import useFeedbackState from '@/hooks/useFeedbackState';
import xinyuanLogo from '@/assets/xy-logo-red.png';
import {
APPLICATION_DISPLAY_NAME,
......@@ -5,12 +6,11 @@ import {
} from '@/config/application';
import { getApplications } from '@/services/DataService';
import { EmptyStateSurface } from '@/components/DataTable';
import { ExclamationTriangleIcon, Search as SearchIcon } from '@/components/AppIcons';
import { Search as SearchIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Chip,
Modal,
......@@ -285,7 +285,7 @@ export default function ApplicationSwitcherDialog({
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
......@@ -319,6 +319,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => {
if (!open) {
setApplicationSearch('');
setErrorMessage('');
return;
}
......@@ -398,13 +399,6 @@ export default function ApplicationSwitcherDialog({
</Modal.Header>
<Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}>
<ScrollShadow className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1" hideScrollBar={false} offset={12} size={24}>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? (
......
import useFeedbackState from '@/hooks/useFeedbackState';
import TableFieldValue from '@/components/TableFieldValue';
import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import { getFieldValue } from '@/utils/subjectView';
......@@ -20,9 +21,10 @@ export default function ProblemRectificationArchivePage() {
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [tableDisplayConfig, setTableDisplayConfig] = useState({});
const [tableDisplayConfigError, setTableDisplayConfigError] = useState('');
const [, setTableDisplayConfigError] = useFeedbackState('warning');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [filters, setFilters] = useState({
keyword: '',
});
......@@ -33,6 +35,7 @@ export default function ProblemRectificationArchivePage() {
const reloadList = useCallback(async () => {
setLoading(true);
setLoadFailed(false);
setError('');
try {
......@@ -43,6 +46,7 @@ export default function ProblemRectificationArchivePage() {
setTableDisplayConfig(resources.displayConfig || {});
setTableDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) {
setLoadFailed(true);
setRecords([]);
setSubject(null);
setViews([]);
......@@ -172,9 +176,9 @@ export default function ProblemRectificationArchivePage() {
),
}}
states={{
warning: tableDisplayConfigError,
loading,
error,
loadFailed,
onRetry: reloadList,
loadingTitle: '正在加载归档批次',
loadingDescription: '正在读取归档对象、视图配置和批次记录,请稍候。',
emptyTitle: '暂无归档批次',
......
import useFeedbackState from '@/hooks/useFeedbackState';
import {
ArrowPathIcon,
Delete,
......@@ -9,7 +10,6 @@ import {
uploadProblemRectificationFile,
} from '../problemRectificationMaterialService';
import {
Alert,
Button,
Label,
Tabs,
......@@ -60,10 +60,10 @@ export default function ProblemRectificationMaterialField({
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [materials, setMaterials] = useState([]);
const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState('');
const [materialsError, setMaterialsError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const [, setUploadError] = useFeedbackState('error');
const [localFileKeys, setLocalFileKeys] = useState(() => new Set());
const fileValues = useMemo(
() => (multiple ? toFileArray(value) : toFileArray(value).slice(0, 1)),
......@@ -322,15 +322,10 @@ export default function ProblemRectificationMaterialField({
/>
{materialsError ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{materialsError}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}>
<ArrowPathIcon size={14} />
重试
</Button>
</Alert>
<Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>
<ArrowPathIcon size={14} />
重新加载材料
</Button>
) : null}
</div>
</Tabs.Panel>
......@@ -346,11 +341,6 @@ export default function ProblemRectificationMaterialField({
triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={handleLocalUpload}
/>
{uploadError ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{uploadError}</Alert.Description></Alert.Content>
</Alert>
) : null}
</div>
</Tabs.Panel>
</Tabs>
......
import { DataTable } from '@/components/DataTable';
import useFeedbackState from '@/hooks/useFeedbackState';
import {
createResolvedXinyuanChartColorMap,
getXinyuanChartColor,
......@@ -17,7 +18,6 @@ import {
ExclamationTriangleIcon,
} from '@/components/AppIcons';
import {
Alert,
Button,
Card,
Chip,
......@@ -459,24 +459,28 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
isEmpty: false,
});
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
const [, setError] = useFeedbackState('error');
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError('');
const result = await loadProblemRectificationDashboardData();
if (cancelled) {
return;
}
setDashboardData(result);
setError(result.error || '');
setLoading(false);
})();
return () => {
cancelled = true;
};
}, []);
}, [reloadKey, setError]);
const groups = dashboardData.groups || [];
const yearOptions = dashboardData.yearOptions || [];
......@@ -588,14 +592,6 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
</div>
</div>
{dashboardData.error ? (
<Alert status="warning">
<Alert.Content>
<Alert.Description>{dashboardData.error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{loading ? (
<div className="flex min-h-48 items-center justify-center gap-2" role="status">
<Spinner size="sm" />
......@@ -603,15 +599,27 @@ export function ProblemRectificationDashboardPage({ dashboardMode = 'overview' }
</div>
) : null}
{!loading && dashboardData.isEmpty ? (
{!loading && dashboardData.error ? (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>{dashboardTitle}加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button variant="secondary" onPress={() => setReloadKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Footer>
</EmptyState>
) : null}
{!loading && !dashboardData.error && dashboardData.isEmpty ? (
<EmptyBlock title="暂无问题整改数据" />
) : null}
{!loading && !dashboardData.isEmpty && activeScope.isEmpty ? (
{!loading && !dashboardData.error && !dashboardData.isEmpty && activeScope.isEmpty ? (
<EmptyBlock title={`${yearLabel}暂无问题整改数据`} />
) : null}
{!loading && !dashboardData.isEmpty && !activeScope.isEmpty ? (
{!loading && !dashboardData.error && !dashboardData.isEmpty && !activeScope.isEmpty ? (
<>
{!isBusinessDashboard ? (
<>
......
import useFeedbackState from '@/hooks/useFeedbackState';
import {
ArrowPathIcon,
CalendarDaysIcon,
......@@ -21,7 +22,7 @@ import {
import { normalizeText, toArray } from '@/utils/subjectView';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { Alert, Button, Card, Chip, Spinner, TextArea, Toast, Tooltip, toast } from '@heroui/react';
import { Button, Card, Chip, Spinner, TextArea, Tooltip } from '@heroui/react';
import { useContext, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'umi';
import { UserContext } from '@/wrapper/Auth';
......@@ -416,8 +417,9 @@ export default function ProblemRectificationFlowTablePage() {
const scopedRecordId = searchParams.get('record') || '';
const [loading, setLoading] = useState(true);
const [submittingKey, setSubmittingKey] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [, setSuccess] = useFeedbackState('success');
const [messageRecord, setMessageRecord] = useState(null);
const [records, setRecords] = useState([]);
const [userLabels, setUserLabels] = useState({});
......@@ -463,12 +465,14 @@ export default function ProblemRectificationFlowTablePage() {
const loadFlow = async () => {
if (!messageId) {
setLoadFailed(true);
setError('缺少流程 ID。');
setLoading(false);
return;
}
setLoading(true);
setLoadFailed(false);
setError('');
try {
......@@ -481,6 +485,7 @@ export default function ProblemRectificationFlowTablePage() {
setRecords(relatedRecords.filter(Boolean));
setDrafts({});
} catch (requestError) {
setLoadFailed(true);
setError(requestError?.message || '流程表格加载失败,请稍后重试。');
setMessageRecord(null);
setRecords([]);
......@@ -1778,12 +1783,6 @@ export default function ProblemRectificationFlowTablePage() {
},
];
useEffect(() => {
if (!success) return;
toast.success(success, { timeout: 4000 });
setSuccess('');
}, [success]);
useEffect(() => {
document.title = '整改流程 - 问题整改';
}, []);
......@@ -1835,13 +1834,6 @@ export default function ProblemRectificationFlowTablePage() {
</Card.Content>
</Card>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<section className="rc-list-stack">
<div className="rc-page-toolbar">
<div className="flex min-w-0 items-center gap-3">
......@@ -1861,6 +1853,11 @@ export default function ProblemRectificationFlowTablePage() {
<div className="flex min-h-56 items-center justify-center" role="status" aria-label="正在加载流程表格">
<Spinner size="md" />
</div>
) : loadFailed ? (
<div className="space-y-3">
<DataTableEmptyState title="流程表格加载失败,详细错误见操作提示" />
<Button variant="secondary" onPress={loadFlow}>重新加载</Button>
</div>
) : rows.length === 0 ? (
<DataTableEmptyState title={isDelayFlow ? '当前流程没有可处理的延期申请' : '当前流程没有可处理的整改明细'} />
) : (
......@@ -1877,7 +1874,6 @@ export default function ProblemRectificationFlowTablePage() {
)}
</section>
</div>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</main>
);
}
import useFeedbackState from '@/hooks/useFeedbackState';
import {
CheckCircleIcon,
ClipboardDocumentListIcon,
......@@ -130,13 +131,14 @@ function StatusTag({ value }) {
);
}
function EmptyState({ title, description }) {
function EmptyState({ title, description, action }) {
return (
<ProEmptyState className="min-h-64" size="md">
<ProEmptyState.Header>
<ProEmptyState.Title>{title}</ProEmptyState.Title>
{description ? <ProEmptyState.Description>{description}</ProEmptyState.Description> : null}
</ProEmptyState.Header>
{action ? <ProEmptyState.Footer>{action}</ProEmptyState.Footer> : null}
</ProEmptyState>
);
}
......@@ -163,12 +165,14 @@ export default function ProblemRectificationFormPage() {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [flowData, setFlowData] = useState(null);
const [selectedRecord, setSelectedRecord] = useState(null);
const reload = async () => {
if (!messageId) {
setLoadFailed(true);
setFlowData(null);
setLoading(false);
setError('缺少流程 ID,无法加载整改流程。');
......@@ -176,12 +180,14 @@ export default function ProblemRectificationFormPage() {
}
setLoading(true);
setLoadFailed(false);
setError('');
try {
const result = await loadProblemRectificationFlow(messageId, userInfo);
setFlowData(result);
} catch (requestError) {
setLoadFailed(true);
setFlowData(null);
setError(requestError?.message || '整改流程加载失败,请稍后重试。');
} finally {
......@@ -380,12 +386,8 @@ export default function ProblemRectificationFormPage() {
<div className="grid min-h-64 place-items-center" role="status" aria-label="正在加载整改流程">
<Spinner />
</div>
) : error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : loadFailed ? (
<EmptyState title="整改流程加载失败" description="详细错误见操作提示,请重新加载。" action={<Button variant="secondary" onPress={reload}>重新加载</Button>} />
) : !messageRecord ? (
<EmptyState title="未找到流程数据" description="请确认消息链接中的 id 是否正确,或检查流程主单是否已存在。" />
) : (
......
import useFeedbackState from '@/hooks/useFeedbackState';
import FormDatePicker from '@/components/FormDatePicker';
import ResourceFilterMultiSelect from '@/components/ResourceFilterMultiSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
......@@ -48,7 +49,6 @@ import {
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@heroui/react';
import {
formatFieldValue,
getFieldNameFromPath,
......@@ -793,10 +793,11 @@ export default function ProblemRectificationListPage() {
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [tableDisplayConfig, setTableDisplayConfig] = useState({});
const [tableDisplayConfigError, setTableDisplayConfigError] = useState('');
const [, setTableDisplayConfigError] = useFeedbackState('warning');
const [loading, setLoading] = useState(true);
const [recordsLoading, setRecordsLoading] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [filters, setFilters] = useState({});
const [pendingFilters, setPendingFilters] = useState({});
......@@ -814,7 +815,7 @@ export default function ProblemRectificationListPage() {
const [auditorUsers, setAuditorUsers] = useState([]);
const [departmentAssignments, setDepartmentAssignments] = useState([]);
const [delayDepartmentAssignments, setDelayDepartmentAssignments] = useState([]);
const [successMessage, setSuccessMessage] = useState('');
const [, setSuccessMessage] = useFeedbackState('success');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE);
const [pagination, setPagination] = useState({
......@@ -838,6 +839,7 @@ export default function ProblemRectificationListPage() {
const reloadResources = useCallback(async () => {
setLoading(true);
setLoadFailed(false);
setError('');
setSuccessMessage('');
......@@ -848,6 +850,7 @@ export default function ProblemRectificationListPage() {
setTableDisplayConfig(resources.displayConfig || {});
setTableDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) {
setLoadFailed(true);
setRecords([]);
setSubject(null);
setViews([]);
......@@ -863,12 +866,6 @@ export default function ProblemRectificationListPage() {
reloadResources();
}, [reloadResources]);
useEffect(() => {
if (!successMessage) return;
toast.success(successMessage, { timeout: 4000 });
setSuccessMessage('');
}, [successMessage]);
useEffect(() => {
const userIds = Array.from(
new Set(
......@@ -1009,6 +1006,7 @@ export default function ProblemRectificationListPage() {
const nextFilters = overrides.filters ?? filters;
setRecordsLoading(true);
setLoadFailed(false);
setError('');
if (!overrides.keepSuccessMessage) {
setSuccessMessage('');
......@@ -1049,6 +1047,7 @@ export default function ProblemRectificationListPage() {
setPage(nextPagination.totalPages - 1);
}
} catch (requestError) {
setLoadFailed(true);
setRecords([]);
setGridViewCounts({});
setPagination({
......@@ -1958,9 +1957,8 @@ export default function ProblemRectificationListPage() {
} : null}
states={{
loading: loading || recordsLoading,
error,
warning: tableDisplayConfigError,
success: '',
loadFailed,
onRetry: () => subject && activeGridView ? reloadList() : reloadResources(),
loadingTitle: '正在加载问题整改列表',
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: activeGridView ? '当前视图暂无数据' : '暂无可访问的视图',
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
......@@ -11,7 +11,6 @@ import {
SearchField,
Spinner,
Tabs,
toast,
} from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi';
......@@ -150,7 +149,9 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [, setOperationError] = useFeedbackState('error');
const [, setSuccess] = useFeedbackState('success');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
......@@ -212,21 +213,21 @@ export default function NotificationListPage() {
const runItemOperation = useCallback(
async (key, operation, successMessage) => {
setBusyKey(key);
setOperationError('');
setSuccess('');
try {
await operation();
toast.success(successMessage, { timeout: 3000 });
setSuccess(successMessage);
refresh();
return true;
} catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', {
timeout: 4000,
});
setOperationError(operationError?.message || '操作失败,请稍后重试');
return false;
} finally {
setBusyKey('');
}
},
[refresh],
[refresh, setOperationError, setSuccess],
);
const handleMarkRead = useCallback(
......@@ -260,6 +261,7 @@ export default function NotificationListPage() {
const handleOpenTarget = useCallback(
async (notification, target) => {
setBusyKey(notification.name);
setOperationError('');
try {
if (!isNotificationRead(notification, userInfo?._id)) {
await markNotificationRead(notification.name);
......@@ -271,14 +273,12 @@ export default function NotificationListPage() {
}
refresh();
} catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', {
timeout: 4000,
});
setOperationError(operationError?.message || '无法打开通知关联流程');
} finally {
setBusyKey('');
}
},
[refresh, userInfo?._id],
[refresh, userInfo?._id, setOperationError],
);
return (
......@@ -356,14 +356,15 @@ export default function NotificationListPage() {
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="ghost" onPress={refresh}>
重试
</Button>
</Alert>
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>通知加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Footer>
<Button variant="secondary" onPress={refresh}>重试</Button>
</EmptyState.Footer>
</EmptyState>
) : null}
{loading && notifications.length === 0 ? (
......
......@@ -144,6 +144,16 @@
---
### 操作提示与失败重试
- 功能入口:登录、站点配置、应用切换、问题清单、归档批次、驾驶舱/整改分析、详情编辑、证明材料查询与上传、整改/延期流程表格、旧外链流程页和通知中心;与风险业务入口共用应用根部的 `AppFeedback`。
- 查询、保存、复制、删除、锁定/解锁、推送、上传和审批的接口成功/失败统一显示 HeroUI Toast,配置和应用列表降级使用 warning。官方 Portal 在视口顶部呈现,层级高于业务 Modal、确认框和移动侧栏;弹窗打开或页面滚动时可看到并关闭,不抢走表单焦点。
- 成功默认 4 秒、错误/警告默认 8 秒后消失,悬停或键盘聚焦暂停计时;当前提示右侧的关闭图标始终可见,使用 HeroUI 默认小号透明按钮,点击或键盘激活均通过 Toast 的 `close` slot 关闭。后排堆叠提示不显示关闭按钮,避免误关闭。相同错误再次发生时重新弹出,同一来源的新提示替换旧提示,清空反馈或离开来源组件时清理对应提示。
- 关闭提示不修改请求结果、草稿、附件、选中记录、`metadata`、`is_locked` 或审批状态,也不重新执行写入。必填/JSON 校验、锁定说明与删除确认说明保留在对应位置。
- 问题详情弹窗点击“保存”后,只有本次待建档材料和 `updateRecord` 全部成功,才退出编辑并弹出“详情已保存”成功提示,弹窗保持打开并展示已保存内容;任一接口失败仅显示错误并保留草稿。重新开始编辑、再次保存、取消编辑、关闭弹窗或切换记录时清理上一条成功提示;无字段修改、校验未通过或正在保存时不提交、不弹出成功提示。
- 问题清单、归档批次、驾驶舱/整改分析、详情、通知和流程页读取失败时保留失败空状态与“重新加载/重试”;详细原因由浮层展示,不误显示“暂无数据”。重试只读取当前页面资源;问题清单保留当前视图、筛选与页码,通知保留分类、已读状态、关键词与页码。
- 人员、关联记录和材料查询失败时弹出提示,选择器保留字段级加载错误;关联明细和材料保留读取重试入口。详情保存或附件上传失败保留当前编辑内容,可修正后再次提交;批量附件部分失败保留已成功的附件并提示失败项。
## 4. 核心业务对象
### 4.1 主对象
......
import { Alert, Button, Modal, Pagination, Spinner } from '@heroui/react';
import { DataGrid } from '@heroui-pro/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { exportImportIssues } from '@/services/riskImportPreflight';
import { useEffect, useState } from 'react';
......@@ -63,12 +64,16 @@ export default function ImportPreflightDialog({
</div>
) : null}
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Title>操作未完成</Alert.Title>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
<EmptyState size="sm">
<EmptyState.Header>
<EmptyState.Title>操作未完成</EmptyState.Title>
<EmptyState.Description>
{report
? '详细错误见操作提示。请先核对已写入数据,再重新选择文件;本次结果不能重复确认导入。'
: '详细错误见操作提示。请重新选择文件进行预检。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
) : null}
{report ? (
<>
......
......@@ -2,8 +2,9 @@ import SubjectRecordWorkbench from '@/components/SubjectRecordWorkbench';
import { ArrowDownTrayIcon } from '@/components/AppIcons';
import { ACTION_LABELS, RISK_MANAGEMENT_SUBJECTS } from '@/config/riskManagementConfig';
import RiskManagerService from '@/services/RiskManagerService';
import useFeedbackState from '@/hooks/useFeedbackState';
import ImportPreflightDialog from './ImportPreflightDialog';
import { Button, Tooltip, toast } from '@heroui/react';
import { Button, Tooltip } from '@heroui/react';
import { useCallback, useMemo, useRef, useState } from 'react';
function selectedActionRecords(context = {}) {
......@@ -21,6 +22,9 @@ function RiskManagementListPage({ subjectName }) {
const importSessionRef = useRef(null);
const [importState, setImportState] = useState(null);
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 methodName = ACTION_LABELS[actionKey];
......@@ -28,12 +32,15 @@ function RiskManagementListPage({ subjectName }) {
const records = selectedActionRecords(context);
if (['copy', 'delete'].includes(actionKey) && records.length === 0) {
toast.warning('请选择要处理的记录');
return;
setWarning('请选择要处理的记录');
return false;
}
setActionLoading(true);
context.setError?.('');
setRequestError('');
setSuccess('');
setWarning('');
try {
const payload = {
records,
......@@ -44,16 +51,19 @@ function RiskManagementListPage({ subjectName }) {
payload.record = records[0];
}
await service[methodName](payload);
toast.success('操作已完成');
await context.reload?.({ keepFeedback: true });
if (!['copy', 'delete'].includes(actionKey)) {
setSuccess('操作已完成');
await context.reload?.({ keepFeedback: true });
}
return true;
} catch (actionError) {
const message = actionError?.message || '操作失败,请稍后重试';
context.setError?.(message);
toast.danger(message);
setRequestError(message);
return false;
} finally {
setActionLoading(false);
}
}, [service, subjectName]);
}, [service, subjectName, setRequestError, setSuccess, setWarning]);
const runConfiguredAction = useCallback((actionKey, context) => {
if (actionKey.startsWith('import-')) {
......@@ -68,26 +78,31 @@ function RiskManagementListPage({ subjectName }) {
importSessionRef.current = session;
setActionLoading(true);
session.context?.setError?.('');
setRequestError('');
setSuccess('');
setImportState({ isChecking: true, report: null, error: '' });
try {
const report = await service.preflightImport({ file, actionKey: session.actionKey });
setImportState({ isChecking: false, report, 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 {
setActionLoading(false);
}
}, [service]);
}, [service, setRequestError, setSuccess]);
const confirmImport = useCallback(async () => {
if (!importState?.report?.canImport || actionLoading || importState.error) return;
const context = importSessionRef.current?.context || {};
setActionLoading(true);
setRequestError('');
setImportState((state) => ({ ...state, isImporting: true }));
try {
const result = await service.executePreparedImport(importState.report);
setImportState(null);
toast.success(`成功导入 ${result.count} 行数据`);
setSuccess(`成功导入 ${result.count} 行数据`);
try {
await context.reload?.({ keepFeedback: true });
} catch (reloadError) {
......@@ -96,7 +111,7 @@ function RiskManagementListPage({ subjectName }) {
} catch (error) {
const message = error?.message || '导入失败,请核对系统数据后重试';
setImportState((state) => ({ ...state, isImporting: false, error: message }));
context.setError?.(message);
setRequestError(message);
try {
await context.reload?.({ keepFeedback: true });
} catch {
......@@ -105,7 +120,7 @@ function RiskManagementListPage({ subjectName }) {
} finally {
setActionLoading(false);
}
}, [actionLoading, importState, service]);
}, [actionLoading, importState, service, setRequestError, setSuccess]);
const mapAction = useCallback((action) => ({
...action,
......
......@@ -25,7 +25,7 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
1. 页面加载 Subject、Grid View 和当前页记录;查询使用 `POST /record/query`,分页总数直接
使用查询响应中的 `total_records`(兼容 `total_count`、`total` 等响应字段),不再为当前视图
额外调用 `POST /record/count`。请求期间显示 loading,失败时在列表上方显示可读错误。
额外调用 `POST /record/count`。请求期间显示 loading,失败时通过操作提示展示原因,列表保留加载失败空状态和“重新加载”。
2. Grid View 的 `settings.columns` 决定表格列;应用配置中标记为 `filterView` 的视图继续
隐藏,不出现在列表 Tab 中;视图授权和 `settings.restrictions`、`settings.filters.conditions`
会在查询时转换为 Record API 的 `filter`。可编辑筛选字段映射为 HeroUI 输入、选择、引用
......@@ -81,7 +81,7 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
- 基金初始化逐行检查“基金名称”,所有目标必须存在且唯一;允许同一文件包含多只基金。预检全通过后按目标基金分组导入 `indicator_library`,将新记录 `_id` 合并到各基金的 `contract_terms`,保留已有指标并避免同批多行覆盖。
- 模板关联指标逐行检查“模板标题 + 模板类别”,整份文件必须属于同一个模板及类别,不能仅使用第一行定位后忽略其余行。确认后导入指标,并沿用原规则更新目标模板的 `associated_indicators`;导出反向生成含模板信息的 Excel。
- 托管清单逐行检查“公司名称 + 基金类型”,整批数据必须属于同一家公司和基金类型,且能定位唯一现有托管人记录。基金类型优先从“类别”读取,兼容旧文件仅使用“类型”的格式;当“类型”用于基金类型时,不误写到关联关系的管理类型字段。新版文件可同时使用“类别(公募/专户)”和“类型(管理人/托管人)”两列,列名分别保持“类别”和“类型”。按 `relation_related_party` 定义校验后创建关联关系,将新 `_id[]` 合并到公司的 `related_parties`。
- 确认后先重新读取目标记录,确保目标仍可用;追加关联以确认时的最新引用列表为基础,避免覆盖预检等待期间其他用户新增的关联。正式写入检查接口返回的 ID 数量与待写行数是否一致,再更新关联。记录写入或关联更新失败时停止后续目标组,在弹窗和工作台保留具体 Excel 行号、失败阶段、服务端原因和已完整完成的行数,并刷新列表供核对;不把部分完成显示为成功,不提供同一结果直接重复确认,以免重复导入。服务端写入可能已部分执行,需核对系统数据后修正或清理再重试;预检不代表多接口事务回滚。
- 确认后先重新读取目标记录,确保目标仍可用;追加关联以确认时的最新引用列表为基础,避免覆盖预检等待期间其他用户新增的关联。正式写入检查接口返回的 ID 数量与待写行数是否一致,再更新关联。记录写入或关联更新失败时停止后续目标组,通过操作提示展示具体 Excel 行号、失败阶段、服务端原因和已完整完成的行数;弹窗保留“操作未完成”和不能重复确认的状态,并刷新列表供核对。不把部分完成显示为成功,不提供同一结果直接重复确认,以免重复导入。服务端写入可能已部分执行,需核对系统数据后修正或清理再重试;预检不代表多接口事务回滚。
## 5. 行操作与异常处理
......@@ -91,6 +91,7 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
- 所有异步动作都会禁用对应按钮并显示 loading;失败不会吞掉服务端错误,会通过 Toast
给出原因和恢复方向。删除动作先经过 HeroUI 确认弹窗;历史版本比较会过滤 removed
引用并要求至少两条可用版本。
- 复制和删除的业务回调返回失败时,工作台保留原操作状态,不再追加成功提示或关闭删除确认框;每次操作仅由对应反馈来源展示结果。
- 引用记录缺失、历史版本不足、导入文件为空或目标公司/模版不存在时,在写入前提示用户
检查数据后重试;写入阶段的接口失败会保留服务端错误,避免静默掩盖部分成功结果。
......@@ -105,4 +106,15 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
- 引用、选择、子对象与 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、筛选、排序、分页、导出或审批规则。
- 加载配置期间沿用列表 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 @@
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
@import "./design-system/feedback.css";
@source "./*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}";
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
......@@ -20,7 +21,7 @@ export default function SiteWrapper() {
const [siteInfo, setSiteInfo] = useState(null);
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [loadError, setLoadError] = useFeedbackState('error');
useEffect(() => {
let cancelled = false;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment