Commit 0c1be1bb authored by drigle's avatar drigle

feat: 统一操作反馈并完善详情保存提示

新增全局 Toast 容器、反馈服务和状态 Hook,统一各页面操作反馈及加载失败重试。同步材料来源应用名称、业务流程和设计文档,并更新应用版本。
parent fb868f94
...@@ -21,6 +21,14 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排 ...@@ -21,6 +21,14 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排
页面 `className` 仅承担 flex、grid、宽度、间距、对齐、滚动和响应式。背景、颜色、描边、圆角、阴影及交互状态由官方 variant、compound API 和上述共享样式负责。 页面 `className` 仅承担 flex、grid、宽度、间距、对齐、滚动和响应式。背景、颜色、描边、圆角、阴影及交互状态由官方 variant、compound API 和上述共享样式负责。
## 操作反馈浮层
接口成功、失败及降级警告统一使用应用根部 `AppFeedback` 的 HeroUI `Toast.Provider`;登录、独立流程页和 AppShell 共用一个容器。最多显示 3 条,成功默认 4 秒、错误/警告默认 8 秒后消失,关闭不改变请求结果或表单草稿。
同步问题整改应用 `bfc5862` 的关闭按钮:组合官方 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`)和 `AppIcons.Close`,通过 `slot="close"` 接入关闭行为。按钮在正文右侧参与布局,前排图标始终可见,后排堆叠提示隐藏关闭按钮;不设置额外绝对位置、边框或正文右侧占位,沿用官方 Hover、焦点和主题样式。
`src/tailwind.css` 负责安全区位置、层级 300、14px 圆角、局部进入动画和后排按钮可见性,保证提示高于业务弹窗和移动侧栏。保留独立 `feedbackQueue` 的同步更新,不触发整页 View Transition,关闭提示不会误点弹窗遮罩。详情保存成功反馈使用 `useFeedbackState('success')`,随编辑会话和记录切换清理。
## 品牌与图表色板 ## 品牌与图表色板
- 鑫元品牌主色固定为 `#DA241A`。HeroUI v3 的 `--accent`、`--accent-hover`、`--focus` 以及共享 `--rc-brand-primary` 均不得被页面或深色主题改写为其他色相;primary 色阶仅作为兼容别名维护。 - 鑫元品牌主色固定为 `#DA241A`。HeroUI v3 的 `--accent`、`--accent-hover`、`--focus` 以及共享 `--rc-brand-primary` 均不得被页面或深色主题改写为其他色相;primary 色阶仅作为兼容别名维护。
......
{ {
"name": "integrity_practice_management_new", "name": "integrity_practice_management_new",
"version": "1.0.9", "version": "1.0.11",
"build": "abc", "build": "abc",
"description": "廉洁从业管理", "description": "廉洁从业管理",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
import '@/global.less'; import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { createElement } from 'react';
import AppFeedback from '@/components/AppFeedback';
import { APPLICATION_BASE_PATH } from '@/config/application'; import { APPLICATION_BASE_PATH } from '@/config/application';
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
...@@ -26,4 +28,8 @@ export async function getInitialState() { ...@@ -26,4 +28,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/feedbackService';
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>{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';
...@@ -24,7 +23,6 @@ export default function AppShell({ children, notificationCount, notificationPath ...@@ -24,7 +23,6 @@ export default function AppShell({ children, notificationCount, notificationPath
<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>
); );
} }
...@@ -6,12 +6,12 @@ import { ...@@ -6,12 +6,12 @@ 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, MagnifyingGlassIcon } from '@/components/AppIcons'; import useFeedbackState from '@/hooks/useFeedbackState';
import { MagnifyingGlassIcon } 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,
...@@ -313,7 +313,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -313,7 +313,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;
...@@ -444,13 +444,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -444,13 +444,6 @@ export default function ApplicationSwitcherDialog({
offset={12} offset={12}
size={24} size={24}
> >
{status === 'error' && errorMessage ? (
<Alert className="mb-4" 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 XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import useFeedbackState from '@/hooks/useFeedbackState';
import { queryRecords } from '@/services/DataService'; import { queryRecords } from '@/services/DataService';
import { Label } from '@heroui/react'; import { Label } from '@heroui/react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
...@@ -304,7 +305,7 @@ export default function ReferenceSearchSelect({ ...@@ -304,7 +305,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();
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const lastSearchRequestId = useRef(0); const lastSearchRequestId = useRef(0);
......
...@@ -470,9 +470,16 @@ export default function RecordWorkbench({ ...@@ -470,9 +470,16 @@ export default function RecordWorkbench({
</header> </header>
) : null} ) : null}
{states.warning ? <Alert status="warning"><Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content></Alert> : null} {states.warning ? <Alert status="warning"><Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content></Alert> : null}
{states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-list-data-stack rc-data-table-sticky relative min-w-0" ref={gridWrapRef}> <div className="rc-list-data-stack rc-data-table-sticky relative min-w-0" ref={gridWrapRef}>
{accessDenied ? ( {!states.loading && states.error ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>业务数据加载失败</EmptyState.Title>
<EmptyState.Description>请重新加载,详细错误见操作提示。</EmptyState.Description>
</EmptyState.Header>
{onQuery ? <EmptyState.Content><Button variant="secondary" onPress={onQuery}>重新加载</Button></EmptyState.Content> : null}
</EmptyState>
) : accessDenied ? (
<DataGridEmpty title="当前账号没有可访问的数据视图" /> <DataGridEmpty title="当前账号没有可访问的数据视图" />
) : ( ) : (
<DataGrid <DataGrid
...@@ -495,7 +502,7 @@ export default function RecordWorkbench({ ...@@ -495,7 +502,7 @@ export default function RecordWorkbench({
)} )}
{states.loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null} {states.loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null}
</div> </div>
{!accessDenied ? <WorkbenchPagination pagination={pagination} /> : null} {!accessDenied && !states.error ? <WorkbenchPagination pagination={pagination} /> : null}
</section> </section>
<ActionBar aria-label="批量操作" isOpen={!accessDenied && selectedIds.length > 0}> <ActionBar aria-label="批量操作" isOpen={!accessDenied && selectedIds.length > 0}>
......
import toast from '@/services/feedbackService';
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 does not clear request failure or validation state.
export default function useFeedbackState(tone = 'error') {
const [message, setMessage] = useState('');
const toastKeyRef = useRef(null);
const isMountedRef = useRef(true);
const updateMessage = useCallback((nextMessage) => {
if (!isMountedRef.current) return;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
setMessage(nextMessage);
if (!nextMessage) return;
const { title, variant, timeout } = FEEDBACK_OPTIONS[tone] || FEEDBACK_OPTIONS.error;
// A repeated attempt must show feedback even when the error text is unchanged.
toastKeyRef.current = toast(title, { description: nextMessage, variant, timeout });
}, [tone]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
};
}, []);
return [message, updateMessage];
}
...@@ -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 useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi'; import { history, useSearchParams } from 'umi';
...@@ -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();
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 { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import useFeedbackState from '@/hooks/useFeedbackState';
import { DataGrid } from '@heroui-pro/react/data-grid'; import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Button, Spinner, Surface, Tooltip } from '@heroui/react'; import { Alert, Button, Spinner, Surface, Tooltip } from '@heroui/react';
...@@ -152,7 +153,7 @@ export default function ReferenceRecordsTable({ ...@@ -152,7 +153,7 @@ export default function ReferenceRecordsTable({
const [views, setViews] = useState([]); const [views, setViews] = useState([]);
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();
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
const gridWrapRef = useRef(null); const gridWrapRef = useRef(null);
...@@ -222,7 +223,7 @@ export default function ReferenceRecordsTable({ ...@@ -222,7 +223,7 @@ export default function ReferenceRecordsTable({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [pageSize, populatedRecords, referenceIds, subjectName]); }, [pageSize, populatedRecords, referenceIds, subjectName, setError]);
const columns = useMemo(() => buildColumns(subject, records, views), [records, subject, views]); const columns = useMemo(() => buildColumns(subject, records, views), [records, subject, views]);
const isSingleColumn = columns.length === 1; const isSingleColumn = columns.length === 1;
...@@ -311,9 +312,6 @@ export default function ReferenceRecordsTable({ ...@@ -311,9 +312,6 @@ export default function ReferenceRecordsTable({
</Button> </Button>
) : null} ) : null}
</div> </div>
{error ? (
<Alert status="warning"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>
) : null}
{configurationMissing ? ( {configurationMissing ? (
<Alert status="warning"><Alert.Content><Alert.Description>引用字段缺少目标 Subject 配置,暂不能加载关联明细。</Alert.Description></Alert.Content></Alert> <Alert status="warning"><Alert.Content><Alert.Description>引用字段缺少目标 Subject 配置,暂不能加载关联明细。</Alert.Description></Alert.Content></Alert>
) : null} ) : null}
...@@ -333,7 +331,7 @@ export default function ReferenceRecordsTable({ ...@@ -333,7 +331,7 @@ export default function ReferenceRecordsTable({
) : ( ) : (
<EmptyState size="sm"> <EmptyState size="sm">
<EmptyState.Header> <EmptyState.Header>
<EmptyState.Title>{referenceIds.length === 0 ? '暂无关联记录' : '暂无可展示的关联明细'}</EmptyState.Title> <EmptyState.Title>{error ? '关联记录加载失败,请重新打开详情' : referenceIds.length === 0 ? '暂无关联记录' : '暂无可展示的关联明细'}</EmptyState.Title>
</EmptyState.Header> </EmptyState.Header>
</EmptyState> </EmptyState>
)} )}
......
import { ChevronDown, Close, Search } from '@/components/AppIcons'; import { ChevronDown, Close, Search } from '@/components/AppIcons';
import { queryRecords } from '@/services/DataService'; import { queryRecords } from '@/services/DataService';
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
getReferenceSubjectName, getReferenceSubjectName,
normalizeRecordId, normalizeRecordId,
...@@ -33,7 +34,7 @@ export default function ReferenceSearchSelect({ fieldDef, value, onChange, place ...@@ -33,7 +34,7 @@ export default function ReferenceSearchSelect({ fieldDef, value, onChange, place
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState();
const subjectName = getReferenceSubjectName(fieldDef); const subjectName = getReferenceSubjectName(fieldDef);
const multiple = Boolean(fieldDef?.multiple); const multiple = Boolean(fieldDef?.multiple);
const selectedValues = useMemo(() => toArray(value).filter(Boolean), [value]); const selectedValues = useMemo(() => toArray(value).filter(Boolean), [value]);
...@@ -76,7 +77,7 @@ export default function ReferenceSearchSelect({ fieldDef, value, onChange, place ...@@ -76,7 +77,7 @@ export default function ReferenceSearchSelect({ fieldDef, value, onChange, place
loadOptions(); loadOptions();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [keyword, subjectName]); }, [keyword, subjectName, setError]);
const menuOptions = useMemo( const menuOptions = useMemo(
() => uniqueRecords([ () => uniqueRecords([
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
} from '@heroui/react'; } from '@heroui/react';
import FormDatePicker from '@/components/FormDatePicker'; import FormDatePicker from '@/components/FormDatePicker';
import FileUploadField from '@/components/FileUploadField'; import FileUploadField from '@/components/FileUploadField';
import useFeedbackState from '@/hooks/useFeedbackState';
import { queryUsers, uploadFile } from '@/services/DataService'; import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect'; import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
...@@ -167,6 +168,7 @@ function FieldValue({ value, fieldDef }) { ...@@ -167,6 +168,7 @@ 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 [error, setError] = useFeedbackState();
const selectedIds = useMemo( const selectedIds = useMemo(
() => toArray(value).map((item) => item?._id || item).filter(Boolean), () => toArray(value).map((item) => item?._id || item).filter(Boolean),
[value], [value],
...@@ -176,6 +178,7 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -176,6 +178,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
let cancelled = false; let cancelled = false;
const loadOptions = async () => { const loadOptions = async () => {
setError('');
const filter = keyword.trim() const filter = keyword.trim()
? { ? {
$or: [ $or: [
...@@ -193,6 +196,7 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -193,6 +196,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
setOptions([]); setOptions([]);
setError(error?.message || '用户列表加载失败,请重新搜索。');
} }
} }
}; };
...@@ -202,7 +206,7 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -202,7 +206,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) {
...@@ -232,6 +236,7 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -232,6 +236,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
<XinyuanAutocomplete <XinyuanAutocomplete
{...selectionProps} {...selectionProps}
aria-label="用户" aria-label="用户"
errorText={error}
emptyText="暂无用户" emptyText="暂无用户"
inputValue={keyword} inputValue={keyword}
isClearable isClearable
...@@ -318,6 +323,7 @@ function ControlledFieldEditor({ ...@@ -318,6 +323,7 @@ function ControlledFieldEditor({
useAuditMaterialCenter, useAuditMaterialCenter,
}) { }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fieldType = fieldDef?.type || 'text'; const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType); const isDateField = isDateFieldType(fieldType);
...@@ -337,7 +343,7 @@ function ControlledFieldEditor({ ...@@ -337,7 +343,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);
...@@ -448,7 +454,6 @@ function ControlledFieldEditor({ ...@@ -448,7 +454,6 @@ function ControlledFieldEditor({
description="上传该字段的附件。" description="上传该字段的附件。"
triggerLabel="选择附件" triggerLabel="选择附件"
/> />
<FieldError>{error}</FieldError>
</Card.Content> </Card.Content>
</Card> </Card>
); );
...@@ -552,6 +557,7 @@ export default function TailwindEditableField({ ...@@ -552,6 +557,7 @@ export default function TailwindEditableField({
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
useEffect(() => { useEffect(() => {
...@@ -608,7 +614,7 @@ export default function TailwindEditableField({ ...@@ -608,7 +614,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 || '保存失败,请稍后重试。');
} }
}; };
...@@ -641,7 +647,7 @@ export default function TailwindEditableField({ ...@@ -641,7 +647,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);
} }
...@@ -662,7 +668,7 @@ export default function TailwindEditableField({ ...@@ -662,7 +668,7 @@ export default function TailwindEditableField({
} }
} catch (saveError) { } catch (saveError) {
setDraft(draft); setDraft(draft);
setError(saveError?.message || '删除附件失败,请稍后重试。'); setRequestError(saveError?.message || '删除附件失败,请稍后重试。');
} }
}; };
......
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
getSubjectView, getSubjectView,
getSubjectViewDetail, getSubjectViewDetail,
...@@ -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();
const requestIdRef = useRef(0); const requestIdRef = useRef(0);
useEffect(() => { useEffect(() => {
...@@ -146,7 +147,7 @@ export function useSubjectRecord( ...@@ -146,7 +147,7 @@ export function useSubjectRecord(
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [recordName, refreshKey, subjectName]); }, [recordName, refreshKey, subjectName, setError]);
const handleFieldsUpdate = async (changes = []) => { const handleFieldsUpdate = async (changes = []) => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) { if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
......
import FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined'; import FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined';
import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined'; import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined';
import { Close } from '@/components/AppIcons'; import { Close } from '@/components/AppIcons';
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Button, Modal, Spinner, Tabs, Tooltip } from '@heroui/react'; import { Alert, Button, Modal, Spinner, Tabs, Tooltip } from '@heroui/react';
import React, { import React, {
useCallback, useCallback,
...@@ -76,7 +78,8 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -76,7 +78,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();
const [, setSaveSuccess] = useFeedbackState('success');
const editSessionRef = useRef(0); const editSessionRef = useRef(0);
const initialModeAppliedRef = useRef(''); const initialModeAppliedRef = useRef('');
const pendingLocalMaterialsRef = useRef({}); const pendingLocalMaterialsRef = useRef({});
...@@ -110,6 +113,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -110,6 +113,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
initialModeAppliedRef.current = ''; initialModeAppliedRef.current = '';
editSessionRef.current += 1; editSessionRef.current += 1;
...@@ -232,6 +236,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -232,6 +236,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
}; };
...@@ -248,6 +253,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -248,6 +253,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
setIsEditing(true); setIsEditing(true);
}; };
...@@ -332,6 +338,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -332,6 +338,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)
...@@ -363,6 +370,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -363,6 +370,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 {
...@@ -457,18 +465,15 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -457,18 +465,15 @@ 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.Content><Button variant="secondary" onPress={() => setRefreshKey((value) => value + 1)}>重新加载</Button></EmptyState.Content>
</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 { Reload, Info } from '@/components/AppIcons'; import { Reload, Info } from '@/components/AppIcons';
import { DataTable, DataTableText, DataTableTextPair } from '@/components/DataTable'; import { DataTable, DataTableText, DataTableTextPair } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { Alert, AlertDialog, Button, Card, Chip, Spinner, TextArea, Toast, Tooltip, toast } from '@heroui/react'; import useFeedbackState from '@/hooks/useFeedbackState';
import toast from '@/services/feedbackService';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { AlertDialog, Button, Card, Chip, Spinner, TextArea, Tooltip } from '@heroui/react';
import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'umi'; import { useSearchParams } from 'umi';
import IntegrityPracticeMaterialField from './components/IntegrityPracticeMaterialField'; import IntegrityPracticeMaterialField from './components/IntegrityPracticeMaterialField';
...@@ -27,7 +30,7 @@ export default function SelfInspectionFlowPage() { ...@@ -27,7 +30,7 @@ export default function SelfInspectionFlowPage() {
const [flow, setFlow] = useState(null); const [flow, setFlow] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState();
const [changes, setChanges] = useState({}); const [changes, setChanges] = useState({});
const [validation, setValidation] = useState({}); const [validation, setValidation] = useState({});
const [confirmAction, setConfirmAction] = useState(null); const [confirmAction, setConfirmAction] = useState(null);
...@@ -40,6 +43,7 @@ export default function SelfInspectionFlowPage() { ...@@ -40,6 +43,7 @@ export default function SelfInspectionFlowPage() {
const reload = useCallback(async () => { const reload = useCallback(async () => {
const version = ++requestVersion.current; const version = ++requestVersion.current;
setLoading(true); setLoading(true);
setError('');
try { try {
const result = await loadSelfInspectionFlow(messageName, currentUser); const result = await loadSelfInspectionFlow(messageName, currentUser);
if (version !== requestVersion.current) return; if (version !== requestVersion.current) return;
...@@ -56,7 +60,7 @@ export default function SelfInspectionFlowPage() { ...@@ -56,7 +60,7 @@ export default function SelfInspectionFlowPage() {
} finally { } finally {
if (version === requestVersion.current) setLoading(false); if (version === requestVersion.current) setLoading(false);
} }
}, [messageName, currentUser?._id]); }, [messageName, currentUser?._id, setError]);
useEffect(() => { useEffect(() => {
setError(''); setError('');
...@@ -187,7 +191,15 @@ export default function SelfInspectionFlowPage() { ...@@ -187,7 +191,15 @@ export default function SelfInspectionFlowPage() {
if (hasChanges) setConfirmAction('refresh'); else { setError(''); reload(); } if (hasChanges) setConfirmAction('refresh'); else { setError(''); reload(); }
}}><Reload size={16} /></Button><Tooltip.Content>刷新流程</Tooltip.Content></Tooltip> }}><Reload size={16} /></Button><Tooltip.Content>刷新流程</Tooltip.Content></Tooltip>
</Card.Content></Card> </Card.Content></Card>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null} {!loading && !flow ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>自查底稿加载失败</EmptyState.Title>
<EmptyState.Description>请重新加载,详细错误见操作提示。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content><Button variant="secondary" onPress={reload}>重新加载</Button></EmptyState.Content>
</EmptyState>
) : null}
{loading ? <div className="grid min-h-56 place-items-center" role="status" aria-label="正在加载自查底稿"><Spinner /></div> : flow ? ( {loading ? <div className="grid min-h-56 place-items-center" role="status" aria-label="正在加载自查底稿"><Spinner /></div> : flow ? (
<section className="flex min-w-0 flex-col gap-4"> <section className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
...@@ -210,7 +222,6 @@ export default function SelfInspectionFlowPage() { ...@@ -210,7 +222,6 @@ export default function SelfInspectionFlowPage() {
}}>确认</Button></AlertDialog.Footer> }}>确认</Button></AlertDialog.Footer>
</AlertDialog.Dialog></AlertDialog.Container></AlertDialog.Backdrop> </AlertDialog.Dialog></AlertDialog.Container></AlertDialog.Backdrop>
</AlertDialog> </AlertDialog>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</main> </main>
); );
} }
import RecordWorkbench from '@/components/record-workbench'; import RecordWorkbench from '@/components/record-workbench';
import useFeedbackState from '@/hooks/useFeedbackState';
import toast from '@/services/feedbackService';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { Archive, Close, Delete, Download, Edit, Plus, Reload, Send } from '@/components/AppIcons'; import { Archive, Close, Delete, Download, Edit, Plus, Reload, Send } from '@/components/AppIcons';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { Alert, AlertDialog, Button, Input, Label, Modal, TextArea, TextField, Toast, toast } from '@heroui/react'; import { AlertDialog, Button, Input, Label, Modal, TextArea, TextField } from '@heroui/react';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'umi'; import { useLocation } from 'umi';
import UserAssignmentDialog from './UserAssignmentDialog'; import UserAssignmentDialog from './UserAssignmentDialog';
...@@ -82,7 +84,7 @@ export default function SubjectWorkbench() { ...@@ -82,7 +84,7 @@ export default function SubjectWorkbench() {
const paginationEnabled = gridConfig.pagination !== false; const paginationEnabled = gridConfig.pagination !== false;
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [displayConfig, setDisplayConfig] = useState({}); const [displayConfig, setDisplayConfig] = useState({});
const [displayConfigError, setDisplayConfigError] = useState(''); const [, setDisplayConfigError] = useFeedbackState('warning');
const [views, setViews] = useState(EMPTY_ARRAY); const [views, setViews] = useState(EMPTY_ARRAY);
const [activeViewName, setActiveViewName] = useState(''); const [activeViewName, setActiveViewName] = useState('');
const [records, setRecords] = useState(EMPTY_ARRAY); const [records, setRecords] = useState(EMPTY_ARRAY);
...@@ -93,13 +95,14 @@ export default function SubjectWorkbench() { ...@@ -93,13 +95,14 @@ export default function SubjectWorkbench() {
const [pagination, setPagination] = useState(null); const [pagination, setPagination] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState();
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [resourceRefreshKey, setResourceRefreshKey] = useState(0);
const [assignmentState, setAssignmentState] = useState(null); const [assignmentState, setAssignmentState] = useState(null);
const [confirmationState, setConfirmationState] = useState(null); const [confirmationState, setConfirmationState] = useState(null);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [createValues, setCreateValues] = useState({}); const [createValues, setCreateValues] = useState({});
const [createError, setCreateError] = useState(''); const [, setCreateError] = useFeedbackState();
const activeView = useMemo( const activeView = useMemo(
() => views.find((view) => view.name === activeViewName) || views[0] || null, () => views.find((view) => view.name === activeViewName) || views[0] || null,
...@@ -120,6 +123,8 @@ export default function SubjectWorkbench() { ...@@ -120,6 +123,8 @@ export default function SubjectWorkbench() {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError(''); setError('');
setSubject(null);
setViews(EMPTY_ARRAY);
setDisplayConfig({}); setDisplayConfig({});
setDisplayConfigError(''); setDisplayConfigError('');
setRecords(EMPTY_ARRAY); setRecords(EMPTY_ARRAY);
...@@ -147,7 +152,7 @@ export default function SubjectWorkbench() { ...@@ -147,7 +152,7 @@ export default function SubjectWorkbench() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [subjectName]); }, [subjectName, resourceRefreshKey, setError, setDisplayConfigError]);
useEffect(() => { useEffect(() => {
if (!subjectName || !activeView) return undefined; if (!subjectName || !activeView) return undefined;
...@@ -181,7 +186,7 @@ export default function SubjectWorkbench() { ...@@ -181,7 +186,7 @@ export default function SubjectWorkbench() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [subjectName, activeView, page, pageSize, filters, filterSchema, paginationEnabled, refreshKey]); }, [subjectName, activeView, page, pageSize, filters, filterSchema, paginationEnabled, refreshKey, setError]);
useEffect(() => { useEffect(() => {
if (!showCreate) setCreateValues(createFields.reduce((values, field) => ({ ...values, [field.fieldDef.name]: restrictionDefaults.metadata?.[field.fieldDef.name] ?? emptyValueForField(field.fieldDef) }), {})); if (!showCreate) setCreateValues(createFields.reduce((values, field) => ({ ...values, [field.fieldDef.name]: restrictionDefaults.metadata?.[field.fieldDef.name] ?? emptyValueForField(field.fieldDef) }), {}));
...@@ -193,6 +198,7 @@ export default function SubjectWorkbench() { ...@@ -193,6 +198,7 @@ export default function SubjectWorkbench() {
setActionLoading(true); setCreateError(''); setActionLoading(true); setCreateError('');
try { try {
await createRecord(buildIntegrityCreatePayload(subjectName, createValues, createFields, restrictionDefaults, { hideTitle: hideCreateTitle })); await createRecord(buildIntegrityCreatePayload(subjectName, createValues, createFields, restrictionDefaults, { hideTitle: hideCreateTitle }));
toast.success('记录已创建', { timeout: 4000 });
setShowCreate(false); setRefreshKey((value) => value + 1); setShowCreate(false); setRefreshKey((value) => value + 1);
} catch (error) { setCreateError(error?.message || '新增记录失败,请稍后重试。'); } } catch (error) { setCreateError(error?.message || '新增记录失败,请稍后重试。'); }
finally { setActionLoading(false); } finally { setActionLoading(false); }
...@@ -214,7 +220,7 @@ export default function SubjectWorkbench() { ...@@ -214,7 +220,7 @@ export default function SubjectWorkbench() {
setRefreshKey((value) => value + 1); setRefreshKey((value) => value + 1);
} }
} catch (actionError) { } catch (actionError) {
toast.danger(actionError?.message || '操作失败,请稍后重试', { timeout: 4500 }); toast.danger(actionError?.message || '操作失败,请稍后重试', { timeout: 8000 });
} finally { } finally {
setActionLoading(false); setActionLoading(false);
} }
...@@ -309,8 +315,6 @@ export default function SubjectWorkbench() { ...@@ -309,8 +315,6 @@ export default function SubjectWorkbench() {
return ( return (
<div> <div>
<Toast.Provider placement="top" />
<RecordWorkbench <RecordWorkbench
accessDenied={!loading && !hasAccessibleView} accessDenied={!loading && !hasAccessibleView}
views={views.map((view) => ({ ...view }))} views={views.map((view) => ({ ...view }))}
...@@ -332,7 +336,8 @@ export default function SubjectWorkbench() { ...@@ -332,7 +336,8 @@ export default function SubjectWorkbench() {
setPage(0); setPage(0);
}} }}
onQuery={() => { onQuery={() => {
setRefreshKey((value) => value + 1); if (!subject) setResourceRefreshKey((value) => value + 1);
else setRefreshKey((value) => value + 1);
}} }}
title={subjectConfig.title || subject?.title || subjectName} title={subjectConfig.title || subject?.title || subjectName}
listTitle={subjectConfig.listTitle} listTitle={subjectConfig.listTitle}
...@@ -351,7 +356,6 @@ export default function SubjectWorkbench() { ...@@ -351,7 +356,6 @@ export default function SubjectWorkbench() {
states={{ states={{
loading, loading,
error, error,
warning: displayConfigError,
loadingTitle: '正在加载业务数据', loadingTitle: '正在加载业务数据',
loadingDescription: '正在读取对象结构、视图配置和记录数据。', loadingDescription: '正在读取对象结构、视图配置和记录数据。',
}} }}
...@@ -416,13 +420,6 @@ export default function SubjectWorkbench() { ...@@ -416,13 +420,6 @@ export default function SubjectWorkbench() {
<Modal.Heading>新增记录</Modal.Heading> <Modal.Heading>新增记录</Modal.Heading>
</Modal.Header> </Modal.Header>
<Modal.Body className="min-w-0"> <Modal.Body className="min-w-0">
{createError ? (
<Alert className="mb-5" status="danger">
<Alert.Content>
<Alert.Description className="whitespace-pre-line">{createError}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-5 md:grid-cols-2"> <div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-5 md:grid-cols-2">
{createFields.map((field) => { {createFields.map((field) => {
const name = field.fieldDef.name; const name = field.fieldDef.name;
......
import { Close } from '@/components/AppIcons'; import { Close } from '@/components/AppIcons';
import { queryUsers } from '@/services/DataService'; import { queryUsers } from '@/services/DataService';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Chip, ComboBox, Input, Label, ListBox, Modal, Spinner, Tooltip } from '@heroui/react'; import { Button, Chip, ComboBox, Input, Label, ListBox, Modal, Spinner, Tooltip } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
...@@ -9,25 +10,30 @@ function UserMultiSelect({ label, value, onChange }) { ...@@ -9,25 +10,30 @@ function UserMultiSelect({ label, value, onChange }) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [options, setOptions] = useState(EMPTY_ARRAY); const [options, setOptions] = useState(EMPTY_ARRAY);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState();
const selectedIds = useMemo(() => new Set(value.map((user) => user._id)), [value]); const selectedIds = useMemo(() => new Set(value.map((user) => user._id)), [value]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const timer = window.setTimeout(async () => { const timer = window.setTimeout(async () => {
setLoading(true); setLoading(true);
setError('');
try { try {
const text = query.trim(); const text = query.trim();
const filter = text ? { $or: [{ username: { $regex: text } }, { display_name: { $regex: text } }] } : {}; const filter = text ? { $or: [{ username: { $regex: text } }, { display_name: { $regex: text } }] } : {};
const result = await queryUsers(0, 20, filter); const result = await queryUsers(0, 20, filter);
if (!cancelled) setOptions(result?.users || EMPTY_ARRAY); if (!cancelled) setOptions(result?.users || EMPTY_ARRAY);
} catch { } catch (requestError) {
if (!cancelled) setOptions(EMPTY_ARRAY); if (!cancelled) {
setOptions(EMPTY_ARRAY);
setError(requestError?.message || `${label}列表加载失败,请重新搜索。`);
}
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
}, 180); }, 180);
return () => { cancelled = true; window.clearTimeout(timer); }; return () => { cancelled = true; window.clearTimeout(timer); };
}, [query]); }, [query, label, setError]);
const availableOptions = options.filter((user) => !selectedIds.has(user._id)); const availableOptions = options.filter((user) => !selectedIds.has(user._id));
return ( return (
...@@ -50,7 +56,7 @@ function UserMultiSelect({ label, value, onChange }) { ...@@ -50,7 +56,7 @@ function UserMultiSelect({ label, value, onChange }) {
<ComboBox.Trigger aria-label={`打开${label}列表`} /> <ComboBox.Trigger aria-label={`打开${label}列表`} />
</ComboBox.InputGroup> </ComboBox.InputGroup>
<ComboBox.Popover placement="bottom start"> <ComboBox.Popover placement="bottom start">
<ListBox aria-label={`${label}候选人`} items={availableOptions} renderEmptyState={() => <div className="grid min-h-20 place-items-center p-4 text-sm text-muted">{loading ? '正在搜索...' : '没有匹配的用户'}</div>}> <ListBox aria-label={`${label}候选人`} items={availableOptions} renderEmptyState={() => <div className="grid min-h-20 place-items-center p-4 text-sm text-muted">{loading ? '正在搜索...' : error ? '加载失败,请重新搜索' : '没有匹配的用户'}</div>}>
{(user) => <ListBox.Item id={user._id} textValue={user.display_name || user.username}><span className="block min-w-0"><span className="block truncate font-medium text-foreground">{user.display_name || user.username}</span><span className="block truncate text-xs text-muted">{user.username}</span></span><ListBox.ItemIndicator /></ListBox.Item>} {(user) => <ListBox.Item id={user._id} textValue={user.display_name || user.username}><span className="block min-w-0"><span className="block truncate font-medium text-foreground">{user.display_name || user.username}</span><span className="block truncate text-xs text-muted">{user.username}</span></span><ListBox.ItemIndicator /></ListBox.Item>}
</ListBox> </ListBox>
{loading ? <div className="grid place-items-center p-2"><Spinner size="sm" /></div> : null} {loading ? <div className="grid place-items-center p-2"><Spinner size="sm" /></div> : null}
......
import { import { Delete } from '@/components/AppIcons';
ArrowPathIcon, import useFeedbackState from '@/hooks/useFeedbackState';
Delete,
} from '@/components/AppIcons';
import FileUploadField from '@/components/FileUploadField'; import FileUploadField from '@/components/FileUploadField';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { import {
...@@ -9,7 +7,6 @@ import { ...@@ -9,7 +7,6 @@ import {
uploadIntegrityPracticeFile, uploadIntegrityPracticeFile,
} from '../integrityPracticeMaterialService'; } from '../integrityPracticeMaterialService';
import { import {
Alert,
Button, Button,
Label, Label,
Tabs, Tabs,
...@@ -61,10 +58,10 @@ export default function IntegrityPracticeMaterialField({ ...@@ -61,10 +58,10 @@ export default function IntegrityPracticeMaterialField({
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
const [selectedMaterialId, setSelectedMaterialId] = useState(null); const [selectedMaterialId, setSelectedMaterialId] = useState(null);
const [materialsLoading, setMaterialsLoading] = useState(false); const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState(''); const [materialsError, setMaterialsError] = useFeedbackState();
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();
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)),
...@@ -113,7 +110,7 @@ export default function IntegrityPracticeMaterialField({ ...@@ -113,7 +110,7 @@ export default function IntegrityPracticeMaterialField({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [activeTab, debouncedKeyword, retryKey]); }, [activeTab, debouncedKeyword, retryKey, setMaterialsError]);
const materialOptions = useMemo( const materialOptions = useMemo(
() => materials.map((material) => ({ () => materials.map((material) => ({
...@@ -336,15 +333,9 @@ export default function IntegrityPracticeMaterialField({ ...@@ -336,15 +333,9 @@ export default function IntegrityPracticeMaterialField({
/> />
{materialsError ? ( {materialsError ? (
<Alert status="danger"> <Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>
<Alert.Content> 重新加载材料
<Alert.Description>{materialsError}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}>
<ArrowPathIcon size={14} />
重试
</Button> </Button>
</Alert>
) : null} ) : null}
</div> </div>
</Tabs.Panel> </Tabs.Panel>
...@@ -360,11 +351,6 @@ export default function IntegrityPracticeMaterialField({ ...@@ -360,11 +351,6 @@ export default function IntegrityPracticeMaterialField({
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>
......
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
异常处理: 异常处理:
- Subject、视图或记录加载失败时,使用 HeroUI Alert/EmptyState 显示错误或空态,不使用猜测字段兜底。 - Subject、视图或记录加载失败时,顶部 Toast 显示具体错误,列表显示加载失败 EmptyState 与“重新加载”入口,不使用猜测字段兜底;关闭或自动消失提示不会清除加载失败状态。
- 没有可访问 `grid` 视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道。 - 没有可访问 `grid` 视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道。
- 配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。 - 配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
...@@ -215,5 +215,15 @@ Grid 视图配置示例(列举的是配置片段,修改时应保留完整原 ...@@ -215,5 +215,15 @@ Grid 视图配置示例(列举的是配置片段,修改时应保留完整原
- 所有运行时可编辑 `file` 字段按 `multiple` 提供“材料中心 / 本地上传”。材料中心查询 `am_material` 最近 20 条并远程搜索材料标题、文件名。 - 所有运行时可编辑 `file` 字段按 `multiple` 提供“材料中心 / 本地上传”。材料中心查询 `am_material` 最近 20 条并远程搜索材料标题、文件名。
- 选择材料只保存 `{name,type,size,url}` 快照;单值替换,多值追加并按 URL 去重。清空附件不删除材料主档。 - 选择材料只保存 `{name,type,size,url}` 快照;单值替换,多值追加并按 URL 去重。清空附件不删除材料主档。
- 本地文件先调用 `/upload` 写入详情草稿;取消编辑不建档。点击保存后,对仍保留的本地文件逐个创建 `am_material`,`source_app = integrity_practice_management`,全部成功后才更新业务 Record。 - 本地文件先调用 `/upload` 写入详情草稿;取消编辑不建档。点击保存后,对仍保留的本地文件逐个创建 `am_material`,`metadata.source_app` 取统一应用配置 `src/config/application.js` 的 `APPLICATION_DISPLAY_NAME`(当前为“廉洁从业管理”),全部成功后才更新业务 Record。详情编辑与自查流程保存均使用此规则。
- 同一编辑会话记录已建档文件;材料部分成功或业务更新失败后重试只处理未建档项,避免重复材料。 - 同一编辑会话记录已建档文件;材料部分成功或业务更新失败后重试只处理未建档项,避免重复材料。
## 11. 操作反馈与异常状态
- 登录、应用切换、业务列表、新增/编辑详情、自查填写与审核、通知中心、材料查询/上传及远程引用/人员搜索共用应用根部的 HeroUI Toast 容器,最多展示 3 条提示。提示固定在视口顶部中央,层级高于弹窗和移动端侧栏,不占用页面布局,也不随列表或弹窗正文滚动。
- 保存、新增、删除、归档、导出及通知操作成功后显示短暂成功提示,约 3–4 秒自动消失;接口失败或配置回退显示错误/警告提示,约 8 秒自动消失,悬停或键盘聚焦暂停计时。当前提示右侧的关闭图标始终可见,使用 HeroUI 小号透明图标按钮,通过官方 `close` slot 支持点击与键盘关闭,移动端同样可直接关闭;后排堆叠提示隐藏关闭按钮,避免误关闭。相同错误在再次请求失败时重新提示。
- 提示关闭只影响展示,不改变请求结果、权限、记录状态或表单草稿。新增/编辑失败保留当前草稿,自查流程保存失败保留未保存修改;字段必填、JSON 校验和记录锁定等需要修正或了解的字段状态继续在对应位置展示。
- 详情弹窗点击“保存”后,只有待建档材料及 `PUT /record/:name` 全部成功,才退出编辑并显示“详情已保存。”成功提示;弹窗继续展示已保存内容。重新编辑、再次保存、取消编辑、关闭弹窗或切换记录时清理上一条成功提示;无字段修改、校验未通过或正在保存时不提交、不显示成功提示。任一接口失败保留当前草稿并显示错误,重试沿用第 10 节的材料建档规则。
- 列表、通知、自查流程及详情首次加载失败时保留失败空态和重试入口;材料查询失败时保留“重新加载材料”按钮,引用/人员候选列表保留失败状态并允许重新搜索。应用配置或应用列表回退警告也通过 Toast 展示,不再使用常驻接口反馈横条。
- Toast 使用 HeroUI 的可访问通知区域与语义状态图标,长错误文案换行并限制高度;请求与写入继续使用第 8 节的接口、原 Subject 字段及 `metadata` 协议。
- 提示使用独立 HeroUI 队列和局部进入动画,不触发整页 View Transition 快照;提示出现后立即点击“关闭提示”也只关闭该提示,不会误触弹窗遮罩。
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService'; import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
export const AUDIT_MATERIAL_SUBJECT = 'am_material'; export const AUDIT_MATERIAL_SUBJECT = 'am_material';
export const INTEGRITY_PRACTICE_SOURCE_APP = 'integrity_practice_management'; export const INTEGRITY_PRACTICE_SOURCE_APP = APPLICATION_DISPLAY_NAME;
const MATERIAL_PAGE_SIZE = 20; const MATERIAL_PAGE_SIZE = 20;
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons'; import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import useFeedbackState from '@/hooks/useFeedbackState';
import toast from '@/services/feedbackService';
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 +12,6 @@ import { ...@@ -11,7 +12,6 @@ import {
Spinner, Spinner,
Pagination, Pagination,
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';
...@@ -183,7 +183,7 @@ export default function NotificationListPage() { ...@@ -183,7 +183,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0); const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState();
const [busyKey, setBusyKey] = useState(''); const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -225,7 +225,7 @@ export default function NotificationListPage() { ...@@ -225,7 +225,7 @@ export default function NotificationListPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [category, keyword, page, readStatus, reloadKey]); }, [category, keyword, page, readStatus, reloadKey, setError]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -252,7 +252,7 @@ export default function NotificationListPage() { ...@@ -252,7 +252,7 @@ export default function NotificationListPage() {
return true; return true;
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', { toast.danger(operationError?.message || '操作失败,请稍后重试', {
timeout: 4000, timeout: 8000,
}); });
return false; return false;
} finally { } finally {
...@@ -305,7 +305,7 @@ export default function NotificationListPage() { ...@@ -305,7 +305,7 @@ export default function NotificationListPage() {
refresh(); refresh();
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { toast.danger(operationError?.message || '无法打开通知关联流程', {
timeout: 4000, timeout: 8000,
}); });
} finally { } finally {
setBusyKey(''); setBusyKey('');
...@@ -392,14 +392,13 @@ export default function NotificationListPage() { ...@@ -392,14 +392,13 @@ export default function NotificationListPage() {
</div> </div>
{error ? ( {error ? (
<Alert status="danger"> <EmptyState className="min-h-64" size="md">
<Alert.Content> <EmptyState.Header>
<Alert.Description>{error}</Alert.Description> <EmptyState.Title>通知列表加载失败</EmptyState.Title>
</Alert.Content> <EmptyState.Description>请重新加载通知,详细错误见操作提示。</EmptyState.Description>
<Button size="sm" variant="ghost" onPress={refresh}> </EmptyState.Header>
重试 <EmptyState.Content><Button variant="secondary" onPress={refresh}>重试</Button></EmptyState.Content>
</Button> </EmptyState>
</Alert>
) : null} ) : null}
{loading && notifications.length === 0 ? ( {loading && notifications.length === 0 ? (
......
import { ToastQueue } from '@heroui/react';
// Update only the Toast region: document-wide view transitions temporarily hide
// live hit targets and can turn a click on "关闭提示" into a backdrop click.
export const feedbackQueue = new ToastQueue({
maxVisibleToasts: 3,
wrapUpdate: (update) => update(),
});
function toast(title, { timeout = 4000, onClose, ...content } = {}) {
return feedbackQueue.add({ title, ...content }, { timeout, onClose });
}
toast.close = (key) => feedbackQueue.close(key);
toast.success = (title, options) => toast(title, { variant: 'success', timeout: 4000, ...options });
toast.danger = (title, options) => toast(title, { variant: 'danger', timeout: 8000, ...options });
export default toast;
...@@ -32,6 +32,32 @@ html[data-theme="dark"] { ...@@ -32,6 +32,32 @@ html[data-theme="dark"] {
color-scheme: dark !important; color-scheme: dark !important;
} }
/* Operation feedback stays above dialogs, outside page scroll containers. */
.app-feedback-region.toast-region {
z-index: 300;
top: max(1rem, env(safe-area-inset-top));
}
.app-feedback-toast.toast {
border-radius: 14px;
animation: app-feedback-enter 180ms ease-out;
}
@keyframes app-feedback-enter {
from { transform: translateY(-8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.app-feedback-toast.toast { animation: none; }
}
/* Only the front toast exposes a close button, including on touch screens. */
.app-feedback-toast:not([data-frontmost="true"]) .app-feedback-close {
visibility: hidden;
pointer-events: none;
}
body { body {
margin: 0; margin: 0;
font-family: var(--rc-font-sans); font-family: var(--rc-font-sans);
......
import { getSystemInfo } from '@/services/DataService'; import { getSystemInfo } from '@/services/DataService';
import useFeedbackState from '@/hooks/useFeedbackState';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import localeData from 'dayjs/plugin/localeData'; import localeData from 'dayjs/plugin/localeData';
...@@ -18,7 +19,7 @@ export default function SiteWrapper() { ...@@ -18,7 +19,7 @@ export default function SiteWrapper() {
const [siteInfo, setSiteInfo] = useState(null); const [siteInfo, setSiteInfo] = useState(null);
const [metadata, setMetadata] = useState(null); const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useFeedbackState();
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