Commit a6c529a8 authored by drigle's avatar drigle

fix: unify operation feedback and detail save notifications

parent c4324c3a
...@@ -62,7 +62,11 @@ ...@@ -62,7 +62,11 @@
- Pagination 固定业务默认每页 10 条,不显示页容量选择器。 - Pagination 固定业务默认每页 10 条,不显示页容量选择器。
- 查看/表单 Modal 按统一尺寸阶梯选用宽度;普通详情和控制点详情使用 4xl(约 1088px)HeroUI Modal,字段按当前视图顺序单列纵向排列,普通标量字段直接落在 Dialog 背景上,不叠加同层级 Surface;编辑态控件直接在 Overlay 上使用 secondary。控制点查看与编辑共用同一 Modal,查看态底部使用“编辑”进入编辑态,编辑态使用“取消/保存”并保留现有保存回调。 - 查看/表单 Modal 按统一尺寸阶梯选用宽度;普通详情和控制点详情使用 4xl(约 1088px)HeroUI Modal,字段按当前视图顺序单列纵向排列,普通标量字段直接落在 Dialog 背景上,不叠加同层级 Surface;编辑态控件直接在 Overlay 上使用 secondary。控制点查看与编辑共用同一 Modal,查看态底部使用“编辑”进入编辑态,编辑态使用“取消/保存”并保留现有保存回调。
- 结构化表单字段在查看态使用紧凑摘要显示“表单名称 · N 条记录”;后端返回“(无标题)”“(无标题)”等占位标题时回退到字段名称。不得直接把表单 JSON 序列化为详情正文,记录数量必须使用真实数据计算。 - 结构化表单字段在查看态使用紧凑摘要显示“表单名称 · N 条记录”;后端返回“(无标题)”“(无标题)”等占位标题时回退到字段名称。不得直接把表单 JSON 序列化为详情正文,记录数量必须使用真实数据计算。
- 最终删除确认使用 AlertDialog/Modal + solid danger;例行成功提示使用浮动 Toast。 - 最终删除确认使用 AlertDialog/Modal + solid danger;接口成功、失败与降级警告统一使用应用根部 `AppFeedback` 的 HeroUI Toast,登录页、主布局与独立填报页共享唯一 Provider,不再渲染接口反馈横条。
- 操作提示通过 Portal 挂载到 `body`,避开业务弹窗对页面根节点的 `inert` 限制,在视口顶部显示,层级 300 高于业务 Modal 和移动侧栏,不随页面滚动;成功默认 4 秒、错误/警告默认 8 秒后消失。长错误自动换行并限制正文滚动高度。
- 关闭按钮同步问题整改 `bfc5862`:组合 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`)和 `AppIcons.Close`,通过官方 `slot="close"` 关闭提示,位于正文右侧参与布局。前排图标无需悬停即可操作,后排隐藏关闭按钮;使用官方默认 Hover/焦点样式,不再放大带边框的默认 Toast 关闭按钮,不设置绝对位置或正文右侧额外占位。
- `src/design-system/feedback.css` 仅调整浮层层级、安全区和后排关闭按钮可见性,颜色、圆角、阴影与状态沿用 HeroUI 主题。关闭提示不改变请求失败状态、筛选、页码或表单草稿;加载失败保留可重试的页面状态,必填和 JSON 格式校验保留字段旁反馈。
- 通用记录详情逐字段保存仅在 `updateRecord` 成功后显示“详情已保存。”;失败继续保留编辑草稿。稽核控制点详情沿用父页面的“控制点已保存”反馈,避免重复提示。
## KPI 与图表 ## KPI 与图表
......
...@@ -116,3 +116,20 @@ ...@@ -116,3 +116,20 @@
- 应用切换打开态确认 Pro `ItemCardGroup + ItemCard`、统一鑫元 Logo、分组数量、跨分组去重、搜索过滤和唯一当前态;详情弹窗宽度符合 4xl 级别。 - 应用切换打开态确认 Pro `ItemCardGroup + ItemCard`、统一鑫元 Logo、分组数量、跨分组去重、搜索过滤和唯一当前态;详情弹窗宽度符合 4xl 级别。
- 重新加载后的当前页面控制台为 0 error / 0 warning;登录过程中两次错误验证码仅产生后端拒绝日志,不涉及代码或业务数据变更。 - 重新加载后的当前页面控制台为 0 error / 0 warning;登录过程中两次错误验证码仅产生后端拒绝日志,不涉及代码或业务数据变更。
- 本轮截图保存在 `/tmp/audit-manager-1440.png`、`/tmp/audit-switcher-fixed.png`、`/tmp/controlpoint-modal-1440.png`、`/tmp/audit-1280x720.png`、`/tmp/audit-768x1024.png`、`/tmp/audit-390x844.png` 和 `/tmp/audit-mobile-nav.png`。 - 本轮截图保存在 `/tmp/audit-manager-1440.png`、`/tmp/audit-switcher-fixed.png`、`/tmp/controlpoint-modal-1440.png`、`/tmp/audit-1280x720.png`、`/tmp/audit-768x1024.png`、`/tmp/audit-390x844.png` 和 `/tmp/audit-mobile-nav.png`。
## 2026-10-08 操作反馈回归
- 参考问题整改 `513706c`:根部统一 HeroUI Toast,成功 4 秒、错误/警告 8 秒后消失,关闭按钮始终可见;登录页、主布局和独立填报页共享 Provider。
- `AppFeedback` 通过 Portal 挂到 `body`,避免 Modal 将应用根节点设为 `inert` 后导致提示只能看见、不能点击。浏览器验证关闭提示后 Modal、失败状态和草稿保留;相同错误重试重新显示。
- 使用实际源码组件的独立本地验证页完成 Modal 浮层、44px 关闭按钮、成功自动消失、旧 InlineNotice 入口、375px 长错误换行/正文滚动与暗色主题检查;验证页不连接业务 API、不写业务数据。
- 实际 `/audit_report_fill` 缺少参数场景验证:显示顶部错误提示,关闭后仍保留失败页面和重新加载入口,再次重试重新显示相同错误。浏览器未发现 warning/error 日志。
- `npm run build`、26 个变更 JavaScript 文件语法解析和 `git diff --check` 通过。当前未取得应用的有效真实登录态,登录后项目保存、报告归档与通知 API 操作未逐项实测;不以本地组件验证替代这些验收。
### 2026-10-08 同步 `bfc5862` 的关闭按钮与详情保存反馈
- 关闭按钮改用 HeroUI `Button` 的 `ghost / sm / isIconOnly` 与官方 `slot="close"`,实测尺寸为 32px、透明背景、参与正文右侧布局。前排按钮持续可见,后排隐藏;保留 `body` Portal 与层级 300。
- 通用详情使用实际 `useSubjectRecord`、`TailwindEditableField` 和 `AppFeedback` 源码,在独立本地页面替换 DataService 验证:等待接口响应时无成功提示;响应成功后显示“详情已保存。”并更新快照;响应失败时不显示成功,字段草稿保留且可直接重试。重复成功可再次提示,切换记录后旧请求成功不会覆盖新详情或弹出旧反馈。
- 鼠标点击与 Enter 均可关闭前排提示,关闭后 Modal 和编辑草稿保持不变;三条堆叠提示关闭前排后,下一条显示可操作的关闭按钮。HeroUI 的 Toast View Transition 结束后检查按钮命中,不将过渡快照作为最终交互状态。
- 当前活动入口使用实际 `AuditProcess`、`ControlPointDialog` 与 `auditManagementService`,仅在独立页面替换底层接口与权限 Hook:从“查看控制点 → 编辑 → 保存”验证等待响应无成功提示,失败保留详情及草稿,成功更新列表、关闭编辑弹窗并显示唯一“控制点已保存”,提示可关闭。未向实际应用注入登录状态或写入业务数据。
- 375px 窄屏下长错误换行、正文独立滚动、关闭按钮处于视口内且可命中;暗色主题成功提示可见可关闭。活动控制点表单仍有既存的无可访问标签 warning,本次不将其列为反馈改动引入的错误。
- 生产构建、源码语法解析与 `git diff --check` 通过。真实登录后的保存 API 尚未实测;本轮验证使用本地接口替身,不替代真实业务验收。接口仍为既有 `PUT /record/:name`,Subject 字段和写入协议不变。
{ {
"name": "audit_manager_new", "name": "audit_manager_new",
"version": "1.0.1", "version": "1.0.3",
"build": "abc", "build": "abc",
"description": "监察稽核管理重构版", "description": "监察稽核管理重构版",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
import '@/tailwind.css'; import '@/tailwind.css';
import '@/global.less'; import '@/global.less';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { createElement } from 'react';
import AppFeedback from '@/components/AppFeedback';
export async function getInitialState() { export async function getInitialState() {
return { return {
...@@ -8,4 +10,8 @@ export async function getInitialState() { ...@@ -8,4 +10,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 { createPortal } from "react-dom";
export default function AppFeedback({ children }) {
return (
<>
{children}
{typeof document !== "undefined"
? createPortal(
<Toast.Provider
aria-label="操作提示"
className="app-feedback-region"
maxVisibleToasts={3}
placement="top"
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 [overflow-wrap:anywhere]">
{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>,
document.body,
)
: null}
</>
);
}
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar'; import DashboardNavbar from './DashboardNavbar';
...@@ -53,7 +52,6 @@ export default function AppShell({ ...@@ -53,7 +52,6 @@ export default function AppShell({
<div className="rc-app-page">{children}</div> <div className="rc-app-page">{children}</div>
</div> </div>
</Sidebar.Main> </Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider> </Sidebar.Provider>
</AppShellHeaderContext.Provider> </AppShellHeaderContext.Provider>
); );
......
import useFeedbackState from '@/hooks/useFeedbackState';
import xinyuanLogo from '@/assets/identity/xy-logo-red.png'; import xinyuanLogo from '@/assets/identity/xy-logo-red.png';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons'; import { MagnifyingGlassIcon } from '@/components/AppIcons';
import { APP_BASE_PATH, APP_DISPLAY_NAME, APP_ROUTE_NAME } from '@/constants/app'; import { APP_BASE_PATH, APP_DISPLAY_NAME, APP_ROUTE_NAME } from '@/constants/app';
import { EmptyStateSurface } from '@/components/DataTable'; import { EmptyStateSurface } from '@/components/DataTable';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
...@@ -7,7 +8,6 @@ import { EmptyState } from '@heroui-pro/react/empty-state'; ...@@ -7,7 +8,6 @@ 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,
...@@ -199,7 +199,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -199,7 +199,7 @@ export default function ApplicationSwitcherDialog({
mergeApplications([], fallbackApplications), mergeApplications([], fallbackApplications),
); );
const [status, setStatus] = useState('idle'); const [status, setStatus] = useState('idle');
const [errorMessage, setErrorMessage] = useState(''); const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState(''); const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => { const currentApplicationName = useMemo(() => {
return ( return (
...@@ -254,7 +254,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -254,7 +254,7 @@ export default function ApplicationSwitcherDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [fallbackApplications, open]); }, [fallbackApplications, open, setErrorMessage]);
return ( return (
<Modal.Root> <Modal.Root>
...@@ -292,17 +292,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -292,17 +292,6 @@ export default function ApplicationSwitcherDialog({
</Modal.Header> </Modal.Header>
<Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}> <Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}>
<ScrollShadow className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1" hideScrollBar={false} offset={12} size={24}> <ScrollShadow className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1" hideScrollBar={false} offset={12} size={24}>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator>
<ExclamationTriangleIcon size={20} />
</Alert.Indicator>
<Alert.Content>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? ( {status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? ( ) : filteredApplicationSections.length > 0 ? (
......
import { Button } from '@/catalyst-ui-kit/javascript/button'; import { Button } from '@/catalyst-ui-kit/javascript/button';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox'; import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { Input } from '@/catalyst-ui-kit/javascript/input'; import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select'; import { Select } from '@/catalyst-ui-kit/javascript/select';
...@@ -483,6 +484,15 @@ export default function RecordWorkbench({ ...@@ -483,6 +484,15 @@ export default function RecordWorkbench({
const [expandedRowIds, setExpandedRowIds] = useState({}); const [expandedRowIds, setExpandedRowIds] = useState({});
const loading = Boolean(states?.loading); const loading = Boolean(states?.loading);
const error = states?.error || ''; const error = states?.error || '';
const success = states?.success || '';
const warning = states?.warning || '';
const [, setErrorFeedback] = useFeedbackState('error');
const [, setSuccessFeedback] = useFeedbackState('success');
const [, setWarningFeedback] = useFeedbackState('warning');
useEffect(() => { setErrorFeedback(error); }, [error, setErrorFeedback]);
useEffect(() => { setSuccessFeedback(success); }, [success, setSuccessFeedback]);
useEffect(() => { setWarningFeedback(warning); }, [warning, setWarningFeedback]);
const pageRecordCount = records.length; const pageRecordCount = records.length;
const totalRecords = pagination?.totalRecords ?? records.length; const totalRecords = pagination?.totalRecords ?? records.length;
const columnGroupMeta = useMemo( const columnGroupMeta = useMemo(
...@@ -801,18 +811,6 @@ export default function RecordWorkbench({ ...@@ -801,18 +811,6 @@ export default function RecordWorkbench({
</div> </div>
) : null} ) : null}
{error ? (
<div className={clsx('border-b border-zinc-100 bg-rose-50/70 px-6 py-4 text-sm text-rose-700', fixedTablePanelClass)}>
{error}
</div>
) : null}
{states?.success ? (
<div className={clsx('border-b border-zinc-100 bg-emerald-50/70 px-6 py-4 text-sm text-emerald-700', fixedTablePanelClass)}>
{states.success}
</div>
) : null}
{loading ? ( {loading ? (
<div className={stateContentClass}> <div className={stateContentClass}>
<EmptyState <EmptyState
...@@ -820,6 +818,11 @@ export default function RecordWorkbench({ ...@@ -820,6 +818,11 @@ export default function RecordWorkbench({
description={states?.loadingDescription || '正在读取对象结构、视图配置和记录数据,请稍候。'} description={states?.loadingDescription || '正在读取对象结构、视图配置和记录数据,请稍候。'}
/> />
</div> </div>
) : error && pageRecordCount === 0 ? (
<div className={stateContentClass}>
<EmptyState title="列表加载失败" description="请重新加载,或调整筛选条件后重试。" />
{states.onRetry ? <Button outline onClick={states.onRetry}>重新加载</Button> : null}
</div>
) : pageRecordCount === 0 ? ( ) : pageRecordCount === 0 ? (
<div className={stateContentClass}> <div className={stateContentClass}>
<EmptyState <EmptyState
......
/* Operation feedback stays 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 { toast } from "@heroui/react";
import { showFeedback } from "@/utils/feedback";
import { useCallback, useEffect, useRef, useState } from "react";
// Closing a toast preserves the request's failure state and the form draft.
export default function useFeedbackState(tone = "error") {
const [message, setMessage] = useState("");
const toastKeyRef = useRef(null);
const isMountedRef = useRef(true);
const updateMessage = useCallback(
(nextMessage) => {
if (!isMountedRef.current) return;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
setMessage(nextMessage);
// Retries with the same error must still display new feedback.
toastKeyRef.current = showFeedback(nextMessage, tone);
},
[tone],
);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
};
}, []);
return [message, updateMessage];
}
import useFeedbackState from '@/hooks/useFeedbackState';
import xyLogoRed from '@/assets/identity/xy-logo-red.png'; import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png'; import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { import {
...@@ -7,7 +8,7 @@ import { ...@@ -7,7 +8,7 @@ import {
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react'; import { Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi'; import { history, useSearchParams } from 'umi';
...@@ -55,7 +56,7 @@ export default function LoginPage() { ...@@ -55,7 +56,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null); const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const redirect = normalizeRedirect(searchParams.get('redirect')); const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
...@@ -205,14 +206,6 @@ export default function LoginPage() { ...@@ -205,14 +206,6 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button <Button
fullWidth fullWidth
className="login-submit-button" className="login-submit-button"
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button } from '@/catalyst-ui-kit/javascript/button';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
Table, Table,
...@@ -106,7 +108,8 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 ...@@ -106,7 +108,8 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -170,7 +173,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 ...@@ -170,7 +173,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [pageSize, populatedRecords, referenceIds, subjectName]); }, [pageSize, populatedRecords, referenceIds, subjectName, retryKey, setError]);
if (referenceIds.length === 0) { if (referenceIds.length === 0) {
return ( return (
...@@ -201,16 +204,14 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 ...@@ -201,16 +204,14 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{error ? ( {error ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700"> <Button outline onClick={() => setRetryKey((key) => key + 1)}>重新加载关联明细</Button>
{error}
</div>
) : null} ) : null}
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
<div className="border-b border-zinc-100 px-4 py-3 text-xs text-zinc-500"> <div className="border-b border-zinc-100 px-4 py-3 text-xs text-zinc-500">
共 {records.length} 条关联记录 共 {records.length} 条关联记录
</div> </div>
{records.length === 0 ? ( {records.length === 0 ? (
<div className="px-4 py-5 text-sm text-zinc-500">暂无可展示的关联明细</div> <div className="px-4 py-5 text-sm text-zinc-500">{error ? '关联明细加载失败,请重新加载' : '暂无可展示的关联明细'}</div>
) : ( ) : (
<Table dense grid className="max-h-96 overflow-auto"> <Table dense grid className="max-h-96 overflow-auto">
<TableHead> <TableHead>
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Input } from '@/catalyst-ui-kit/javascript/input'; import { Input } from '@/catalyst-ui-kit/javascript/input';
import { queryRecords } from '@/services/DataService'; import { queryRecords } from '@/services/DataService';
...@@ -16,6 +17,7 @@ export default function ReferenceSearchSelect({ ...@@ -16,6 +17,7 @@ export default function ReferenceSearchSelect({
}) { }) {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [, setError] = useFeedbackState('error');
const subjectName = getReferenceSubjectName(fieldDef); const subjectName = getReferenceSubjectName(fieldDef);
const multiple = Boolean(fieldDef?.multiple); const multiple = Boolean(fieldDef?.multiple);
const selectedIds = useMemo( const selectedIds = useMemo(
...@@ -27,6 +29,7 @@ export default function ReferenceSearchSelect({ ...@@ -27,6 +29,7 @@ export default function ReferenceSearchSelect({
let cancelled = false; let cancelled = false;
const loadOptions = async () => { const loadOptions = async () => {
setError('');
if (!subjectName) { if (!subjectName) {
setOptions([]); setOptions([]);
return; return;
...@@ -46,6 +49,7 @@ export default function ReferenceSearchSelect({ ...@@ -46,6 +49,7 @@ export default function ReferenceSearchSelect({
} }
} catch (requestError) { } catch (requestError) {
if (!cancelled) { if (!cancelled) {
setError(requestError?.message || '引用记录加载失败,请重新搜索。');
setOptions([]); setOptions([]);
} }
} }
...@@ -56,7 +60,7 @@ export default function ReferenceSearchSelect({ ...@@ -56,7 +60,7 @@ export default function ReferenceSearchSelect({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [keyword, subjectName]); }, [keyword, subjectName, setError]);
const toggleRecord = (record, checked) => { const toggleRecord = (record, checked) => {
const recordId = normalizeRecordId(record); const recordId = normalizeRecordId(record);
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox'; import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
...@@ -133,6 +134,7 @@ function FieldValue({ value, fieldDef }) { ...@@ -133,6 +134,7 @@ function FieldValue({ value, fieldDef }) {
function UserSelector({ value, onChange, multiple }) { function UserSelector({ value, onChange, multiple }) {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [, setRequestError] = useFeedbackState('error');
const selectedIds = useMemo( const selectedIds = useMemo(
() => toArray(value).map((item) => item?._id || item).filter(Boolean), () => toArray(value).map((item) => item?._id || item).filter(Boolean),
[value], [value],
...@@ -142,6 +144,7 @@ function UserSelector({ value, onChange, multiple }) { ...@@ -142,6 +144,7 @@ function UserSelector({ value, onChange, multiple }) {
let cancelled = false; let cancelled = false;
const loadOptions = async () => { const loadOptions = async () => {
setRequestError('');
const filter = keyword.trim() const filter = keyword.trim()
? { ? {
$or: [ $or: [
...@@ -158,6 +161,7 @@ function UserSelector({ value, onChange, multiple }) { ...@@ -158,6 +161,7 @@ function UserSelector({ value, onChange, multiple }) {
} }
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
setRequestError(error?.message || '用户列表加载失败,请重新搜索。');
setOptions([]); setOptions([]);
} }
} }
...@@ -168,7 +172,7 @@ function UserSelector({ value, onChange, multiple }) { ...@@ -168,7 +172,7 @@ function UserSelector({ value, onChange, multiple }) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [keyword]); }, [keyword, setRequestError]);
const toggleUser = (user, checked) => { const toggleUser = (user, checked) => {
if (!multiple) { if (!multiple) {
...@@ -226,6 +230,7 @@ export default function TailwindEditableField({ ...@@ -226,6 +230,7 @@ export default function TailwindEditableField({
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
useEffect(() => { useEffect(() => {
...@@ -246,6 +251,7 @@ export default function TailwindEditableField({ ...@@ -246,6 +251,7 @@ export default function TailwindEditableField({
const save = async (nextValue = draft) => { const save = async (nextValue = draft) => {
setError(''); setError('');
setRequestError('');
if (fieldType === 'object' && typeof nextValue === 'string' && nextValue.trim()) { if (fieldType === 'object' && typeof nextValue === 'string' && nextValue.trim()) {
try { try {
...@@ -260,7 +266,7 @@ export default function TailwindEditableField({ ...@@ -260,7 +266,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 || '保存失败,请稍后重试。');
} }
}; };
...@@ -273,6 +279,7 @@ export default function TailwindEditableField({ ...@@ -273,6 +279,7 @@ export default function TailwindEditableField({
const cancel = () => { const cancel = () => {
setDraft(value); setDraft(value);
setError(''); setError('');
setRequestError('');
setEditing(false); setEditing(false);
}; };
...@@ -284,6 +291,7 @@ export default function TailwindEditableField({ ...@@ -284,6 +291,7 @@ export default function TailwindEditableField({
setUploading(true); setUploading(true);
setError(''); setError('');
setRequestError('');
try { try {
const uploaded = await uploadFile(file); const uploaded = await uploadFile(file);
...@@ -293,7 +301,7 @@ export default function TailwindEditableField({ ...@@ -293,7 +301,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);
} }
...@@ -301,6 +309,7 @@ export default function TailwindEditableField({ ...@@ -301,6 +309,7 @@ export default function TailwindEditableField({
const handleRemoveFile = async (indexToRemove) => { const handleRemoveFile = async (indexToRemove) => {
setError(''); setError('');
setRequestError('');
const nextValue = fieldDef?.multiple const nextValue = fieldDef?.multiple
? toArray(draft).filter((_, index) => index !== indexToRemove) ? toArray(draft).filter((_, index) => index !== indexToRemove)
...@@ -314,7 +323,7 @@ export default function TailwindEditableField({ ...@@ -314,7 +323,7 @@ export default function TailwindEditableField({
} }
} catch (saveError) { } catch (saveError) {
setDraft(draft); setDraft(draft);
setError(saveError?.message || '删除附件失败,请稍后重试。'); setRequestError(saveError?.message || '删除附件失败,请稍后重试。');
} }
}; };
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
getSubjectView, getSubjectView,
...@@ -46,13 +47,15 @@ export function useSubjectRecord(recordName, refreshKey, readonly, subjectName) ...@@ -46,13 +47,15 @@ export function useSubjectRecord(recordName, refreshKey, readonly, subjectName)
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]); const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [, setSaveSuccess] = useFeedbackState('success');
const requestIdRef = useRef(0); const requestIdRef = useRef(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const requestId = requestIdRef.current + 1; const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId; requestIdRef.current = requestId;
setSaveSuccess('');
const loadAll = async () => { const loadAll = async () => {
if (!recordName) { if (!recordName) {
...@@ -99,26 +102,24 @@ export function useSubjectRecord(recordName, refreshKey, readonly, subjectName) ...@@ -99,26 +102,24 @@ export function useSubjectRecord(recordName, refreshKey, readonly, subjectName)
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [recordName, refreshKey, subjectName]); }, [recordName, refreshKey, subjectName, setError, setSaveSuccess]);
const handleFieldUpdate = async (fieldPath, newValue, fieldDef) => { const handleFieldUpdate = async (fieldPath, newValue, fieldDef) => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) { if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
return; return;
} }
const previousRecord = editableRecord; setSaveSuccess('');
const nextRecord = setFieldValue(editableRecord, fieldPath, newValue); const requestId = requestIdRef.current;
setEditableRecord(nextRecord);
try {
const saveValue = transformSaveValue(newValue, fieldDef); const saveValue = transformSaveValue(newValue, fieldDef);
const payload = fieldPath.split('.').reduceRight((value, key) => ({ [key]: value }), saveValue); const payload = fieldPath.split('.').reduceRight((value, key) => ({ [key]: value }), saveValue);
// Keep the saved snapshot unchanged until the request succeeds, so failures preserve the field draft.
await updateRecord(recordName, payload); await updateRecord(recordName, payload);
setRemoteRecord(nextRecord); if (requestIdRef.current !== requestId) return;
} catch (updateError) { setEditableRecord((current) => setFieldValue(current, fieldPath, newValue));
setEditableRecord(previousRecord); setRemoteRecord((current) => setFieldValue(current, fieldPath, newValue));
throw updateError; setSaveSuccess('详情已保存。');
}
}; };
const isFieldDisabled = (fieldPath, currentView) => { const isFieldDisabled = (fieldPath, currentView) => {
......
...@@ -212,8 +212,9 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -212,8 +212,9 @@ const RecordDetailDrawer = (props, ref) => {
正在加载详情... 正在加载详情...
</div> </div>
) : error ? ( ) : error ? (
<div className="rounded-[28px] border border-rose-200 bg-rose-50 px-6 py-5 text-sm text-rose-700 shadow-sm"> <div className="flex min-h-48 flex-col items-center justify-center gap-3 text-sm text-zinc-500">
{error} <span>详情加载失败,请重新加载。</span>
<Button outline onClick={() => setRefreshKey((key) => key + 1)}>重新加载</Button>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { DataTable, DataTableChip, DataTableTextPair } from '@/components/DataTable'; import { DataTable, DataTableChip, DataTableTextPair } from '@/components/DataTable';
import { Delete, Edit, Eye, Search } from '@/components/AppIcons'; import { Delete, Edit, Eye, Search } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton'; import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
...@@ -29,6 +30,7 @@ function AuditProjectDialog({ open, onClose, onSaved }) { ...@@ -29,6 +30,7 @@ function AuditProjectDialog({ open, onClose, onSaved }) {
const [form, setForm] = useState(EMPTY_PROJECT_FORM); const [form, setForm] = useState(EMPTY_PROJECT_FORM);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
const handleSave = async () => { const handleSave = async () => {
if (!form.title.trim()) { if (!form.title.trim()) {
...@@ -38,6 +40,7 @@ function AuditProjectDialog({ open, onClose, onSaved }) { ...@@ -38,6 +40,7 @@ function AuditProjectDialog({ open, onClose, onSaved }) {
setSaving(true); setSaving(true);
setError(''); setError('');
setRequestError('');
try { try {
const record = await createAuditProcess({ const record = await createAuditProcess({
title: form.title.trim(), title: form.title.trim(),
...@@ -47,7 +50,7 @@ function AuditProjectDialog({ open, onClose, onSaved }) { ...@@ -47,7 +50,7 @@ function AuditProjectDialog({ open, onClose, onSaved }) {
setForm(EMPTY_PROJECT_FORM); setForm(EMPTY_PROJECT_FORM);
onSaved(record); onSaved(record);
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '添加稽核项目失败'); setRequestError(requestError?.message || '添加稽核项目失败');
} finally { } finally {
setSaving(false); setSaving(false);
} }
...@@ -97,7 +100,7 @@ export default function AuditProcess() { ...@@ -97,7 +100,7 @@ export default function AuditProcess() {
const [definition, setDefinition] = useState(null); const [definition, setDefinition] = useState(null);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [notice, setNotice] = useState(null); const [notice, setNotice] = useState(null);
const [projectDialogOpen, setProjectDialogOpen] = useState(false); const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState(null); const [projectToDelete, setProjectToDelete] = useState(null);
...@@ -127,7 +130,7 @@ export default function AuditProcess() { ...@@ -127,7 +130,7 @@ export default function AuditProcess() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [setError]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { DataTable, DataTablePrimaryText } from '@/components/DataTable'; import { DataTable, DataTablePrimaryText } from '@/components/DataTable';
import { CheckCircleIcon, ClipboardDocumentListIcon, ClockIcon, Eye, Project } from '@/components/AppIcons'; import { CheckCircleIcon, ClipboardDocumentListIcon, ClockIcon, Eye, Project } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton'; import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
...@@ -106,7 +107,7 @@ export default function AuditReport() { ...@@ -106,7 +107,7 @@ export default function AuditReport() {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [notice, setNotice] = useState(null); const [notice, setNotice] = useState(null);
const [submitOpen, setSubmitOpen] = useState(false); const [submitOpen, setSubmitOpen] = useState(false);
const [linksOpen, setLinksOpen] = useState(false); const [linksOpen, setLinksOpen] = useState(false);
...@@ -132,7 +133,7 @@ export default function AuditReport() { ...@@ -132,7 +133,7 @@ export default function AuditReport() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [setError]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { DataTable } from '@/components/DataTable'; import { DataTable } from '@/components/DataTable';
import FileUploadField from '@/components/FileUploadField'; import FileUploadField from '@/components/FileUploadField';
import FilterSelect from '@/components/FilterSelect'; import FilterSelect from '@/components/FilterSelect';
...@@ -33,7 +34,7 @@ function PointContent({ ...@@ -33,7 +34,7 @@ function PointContent({
onUpdate, onUpdate,
}) { }) {
const [uploadingLabel, setUploadingLabel] = useState(''); const [uploadingLabel, setUploadingLabel] = useState('');
const [uploadError, setUploadError] = useState(''); const [, setUploadError] = useFeedbackState('error');
if (point.type === 'form') { if (point.type === 'form') {
if (!editable) { if (!editable) {
...@@ -120,7 +121,6 @@ function PointContent({ ...@@ -120,7 +121,6 @@ function PointContent({
</div> </div>
); );
})} })}
{uploadError ? <div role="alert">{uploadError}</div> : null}
</div> </div>
); );
} }
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { CheckCircleIcon, ClipboardDocumentListIcon, Project } from '@/components/AppIcons'; import { CheckCircleIcon, ClipboardDocumentListIcon, Project } from '@/components/AppIcons';
import { useAppShellHeader } from '@/components/AppShell'; import { useAppShellHeader } from '@/components/AppShell';
import { KPI } from '@heroui-pro/react/kpi'; import { KPI } from '@heroui-pro/react/kpi';
...@@ -19,7 +20,7 @@ export default function AuditReportDetail() { ...@@ -19,7 +20,7 @@ export default function AuditReportDetail() {
const [report, setReport] = useState(null); const [report, setReport] = useState(null);
const [issueOptions, setIssueOptions] = useState([]); const [issueOptions, setIssueOptions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
...@@ -38,7 +39,7 @@ export default function AuditReportDetail() { ...@@ -38,7 +39,7 @@ export default function AuditReportDetail() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [reportId]); }, [reportId, setError]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
......
import useFeedbackState from '@/hooks/useFeedbackState';
import xyLogo from '@/assets/identity/xy-logo-red.png'; import xyLogo from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png'; import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app'; import { APP_DISPLAY_NAME } from '@/constants/app';
...@@ -56,7 +57,7 @@ export default function AuditReportFill() { ...@@ -56,7 +57,7 @@ export default function AuditReportFill() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [notice, setNotice] = useState(null); const [notice, setNotice] = useState(null);
const [resetOpen, setResetOpen] = useState(false); const [resetOpen, setResetOpen] = useState(false);
...@@ -92,7 +93,7 @@ export default function AuditReportFill() { ...@@ -92,7 +93,7 @@ export default function AuditReportFill() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [ownerId, reportId, ticket]); }, [ownerId, reportId, ticket, setError]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Close, Delete } from '@/components/AppIcons'; import { Close, Delete } from '@/components/AppIcons';
import FilterSelect from '@/components/FilterSelect'; import FilterSelect from '@/components/FilterSelect';
import { Button, FieldError, Input, Label, Modal, TextArea, TextField, Tooltip } from '@heroui/react'; import { Button, FieldError, Input, Label, Modal, TextArea, TextField, Tooltip } from '@heroui/react';
...@@ -49,6 +50,7 @@ export default function UnifiedControlPointDialog({ ...@@ -49,6 +50,7 @@ export default function UnifiedControlPointDialog({
const [form, setForm] = useState(EMPTY_POINT_FORM); const [form, setForm] = useState(EMPTY_POINT_FORM);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
const [isEditing, setIsEditing] = useState(initialMode === 'edit' || !point); const [isEditing, setIsEditing] = useState(initialMode === 'edit' || !point);
const loadDepartmentOptions = useCallback((keyword) => searchReferenceRecords('department', keyword), []); const loadDepartmentOptions = useCallback((keyword) => searchReferenceRecords('department', keyword), []);
const loadRegulationOptions = useCallback((keyword) => searchReferenceRecords('internal_regulation', keyword), []); const loadRegulationOptions = useCallback((keyword) => searchReferenceRecords('internal_regulation', keyword), []);
...@@ -92,6 +94,7 @@ export default function UnifiedControlPointDialog({ ...@@ -92,6 +94,7 @@ export default function UnifiedControlPointDialog({
setSaving(true); setSaving(true);
setError(''); setError('');
setRequestError('');
try { try {
await onSaved({ await onSaved({
...form, ...form,
...@@ -105,7 +108,7 @@ export default function UnifiedControlPointDialog({ ...@@ -105,7 +108,7 @@ export default function UnifiedControlPointDialog({
attachment_label: compactLabels(form.attachment_label), attachment_label: compactLabels(form.attachment_label),
}); });
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '保存控制点失败'); setRequestError(requestError?.message || '保存控制点失败');
} finally { } finally {
setSaving(false); setSaving(false);
} }
......
import { CheckCircleIcon, Close, ExclamationTriangleIcon } from '@/components/AppIcons'; import { ExclamationTriangleIcon } from '@/components/AppIcons';
import { showFeedback } from '@/utils/feedback';
import { EmptyState as HeroEmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState as HeroEmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Button, Spinner, toast } from '@heroui/react'; import { Button, Spinner } from '@heroui/react';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
export function LoadingState({ label = '正在加载...' }) { export function LoadingState({ label = '正在加载...' }) {
return <div className="flex min-h-56 items-center justify-center gap-2"><Spinner size="sm" /><span className="text-sm text-muted">{label}</span></div>; return <div className="flex min-h-56 items-center justify-center gap-2"><Spinner size="sm" /><span className="text-sm text-muted">{label}</span></div>;
...@@ -30,24 +31,15 @@ export function EmptyState({ title, description, action }) { ...@@ -30,24 +31,15 @@ export function EmptyState({ title, description, action }) {
} }
export function InlineNotice({ tone = 'success', children, onDismiss }) { export function InlineNotice({ tone = 'success', children, onDismiss }) {
const reportedRef = useRef(null);
useEffect(() => { useEffect(() => {
if (tone !== 'success') return undefined; if (!children) return;
toast.success(String(children || ''), { timeout: 4000 }); const feedback = { message: String(children), tone };
if (reportedRef.current?.message === feedback.message && reportedRef.current?.tone === tone) return;
reportedRef.current = feedback;
showFeedback(feedback.message, tone);
onDismiss?.(); onDismiss?.();
return undefined;
}, [children, onDismiss, tone]); }, [children, onDismiss, tone]);
if (tone === 'success') return null; return null;
return (
<Alert status="danger">
<Alert.Indicator><ExclamationTriangleIcon size={16} /></Alert.Indicator>
<Alert.Content><Alert.Description>{children}</Alert.Description></Alert.Content>
{onDismiss ? (
<Button isIconOnly aria-label="关闭提示" size="sm" variant="ghost" onPress={onDismiss}>
<Close size={14} />
</Button>
) : null}
</Alert>
);
} }
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { normalizeRecordId, normalizeRecordLabel } from './auditManagementService'; import { normalizeRecordId, normalizeRecordLabel } from './auditManagementService';
...@@ -28,7 +29,7 @@ export default function ReferenceSearchSelect({ ...@@ -28,7 +29,7 @@ export default function ReferenceSearchSelect({
const [query, setQuery] = useState(''); const [query, setQuery] = 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('error');
const selectedIds = useMemo( const selectedIds = useMemo(
() => (Array.isArray(value) ? value : value ? [value] : []) () => (Array.isArray(value) ? value : value ? [value] : [])
.map(normalizeRecordId) .map(normalizeRecordId)
...@@ -53,8 +54,9 @@ export default function ReferenceSearchSelect({ ...@@ -53,8 +54,9 @@ export default function ReferenceSearchSelect({
} }
}, [valueRecords]); }, [valueRecords]);
const missingSelectedIds = selectedIds.filter( const missingSelectedIds = useMemo(
(id) => !selectedRecordMap.get(id), () => selectedIds.filter((id) => !selectedRecordMap.get(id)),
[selectedIds, selectedRecordMap],
); );
useEffect(() => { useEffect(() => {
...@@ -70,7 +72,7 @@ export default function ReferenceSearchSelect({ ...@@ -70,7 +72,7 @@ export default function ReferenceSearchSelect({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [missingSelectedIds, resolveMissingIds]); }, [missingSelectedIds, resolveMissingIds, setError]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -83,6 +85,11 @@ export default function ReferenceSearchSelect({ ...@@ -83,6 +85,11 @@ export default function ReferenceSearchSelect({
mergeRecords(records); mergeRecords(records);
setOptions(records || []); setOptions(records || []);
} }
} catch (requestError) {
if (!cancelled) {
setOptions([]);
setError(requestError?.message || '引用记录加载失败,请重新搜索');
}
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
...@@ -92,7 +99,7 @@ export default function ReferenceSearchSelect({ ...@@ -92,7 +99,7 @@ export default function ReferenceSearchSelect({
cancelled = true; cancelled = true;
window.clearTimeout(timer); window.clearTimeout(timer);
}; };
}, [loadOptions, query]); }, [loadOptions, query, setError]);
const normalizedOptions = uniqueRecords([ const normalizedOptions = uniqueRecords([
...selectedIds.map((id) => selectedRecordMap.get(id)).filter(Boolean), ...selectedIds.map((id) => selectedRecordMap.get(id)).filter(Boolean),
...@@ -113,9 +120,9 @@ export default function ReferenceSearchSelect({ ...@@ -113,9 +120,9 @@ export default function ReferenceSearchSelect({
return ( return (
<div className="space-y-1"> <div className="space-y-1">
{error ? <div className="text-sm text-rose-600">{error}</div> : null}
<XinyuanAutocomplete <XinyuanAutocomplete
aria-label={placeholder} aria-label={placeholder}
errorText={error ? '引用记录加载失败,请重新搜索' : ''}
inputValue={query} inputValue={query}
isLoading={loading} isLoading={loading}
options={normalizedOptions} options={normalizedOptions}
......
...@@ -94,10 +94,20 @@ ...@@ -94,10 +94,20 @@
异常与限制: 异常与限制:
- 列表或操作失败时保留当前分类、筛选和页码,并显示可重试错误或 Toast。 - 列表或操作失败时保留当前分类、筛选和页码,错误详情使用顶部操作提示;列表加载失败显示“重新加载”入口,关闭提示后仍保留加载失败状态。
- 通知页不改变稽核项目、报告、权限、保存回调及请求时机。 - 通知页不改变稽核项目、报告、权限、保存回调及请求时机。
## 8. 当前边界 ## 8. 操作反馈
1. 登录/验证码、稽核项目、控制点弹窗、报告复核、独立填报、通知操作与应用切换共用根部 `AppFeedback`;成功、失败和降级警告均以视口顶部 Toast 展示,不在页面或弹窗正文中留下接口反馈横条。
2. 浮层通过 Portal 挂到 `body`,高于业务 Modal、删除确认弹窗和移动侧栏,不随内容滚动,也不受弹窗对页面背景的不可交互限制。关闭图标位于正文右侧,使用 HeroUI 小号透明按钮,通过 Toast 官方 `close` slot 点击或键盘关闭;前排无需悬停即可操作,后排堆叠提示隐藏关闭按钮。关闭仅影响提示本身;成功默认 4 秒、错误与警告默认 8 秒后自动消失,最多同时显示 3 条。
3. 关闭提示只移除提示本身,不清空表单草稿、不重置查询条件/页码,也不将失败伪装为空数据。加载失败保留原有重新加载入口;重复尝试遇到相同错误时重新显示反馈。
4. 项目/控制点必填校验仍在表单字段附近展示;接口保存失败保留编辑内容和弹窗,上传失败不写入无效附件。引用选项查询或已选引用加载失败显示错误提示,用户可重新搜索;不会把接口失败当作成功选中。
5. 本次仅调整反馈呈现,继续使用 `record-api.md` 的查询、创建、更新、软删除与通知接口;`audit_process`、`audit_process_control_point`、`audit_report` 等对象的 `metadata`、引用 ID、状态与日期规则不变。
6. 通用 `RecordDetailDrawer` 详情按字段保存:仅在 `PUT /record/:name` 成功后更新详情快照并弹出“详情已保存。”;等待响应和失败时不覆盖已保存快照,失败保留字段草稿并显示错误,不提示成功。新一轮保存、重新加载或切换记录时清理上一条成功提示,旧记录的请求完成后不会给新记录弹出成功反馈;只读、锁定或尚未加载的详情不提交、不提示成功。字段保存、附件上传后保存和附件移除后保存共用此成功入口,不重复报成功。
7. 活动稽核控制点详情继续在 `saveAuditControlPoints` 成功后关闭编辑弹窗并显示唯一“控制点已保存”提示;校验失败不提交,接口失败保留弹窗及草稿。通用详情为存量复用组件,当前活动路由不新增详情入口。
## 9. 当前边界
- 本次重构提供按责任部门复制填报链接,不自动发送邮件。 - 本次重构提供按责任部门复制填报链接,不自动发送邮件。
- 旧版邮件发送依赖未收录于 `record-api.md` 的邮件接口;在补齐接口契约、发件身份、失败重试和审计要求前不接入。 - 旧版邮件发送依赖未收录于 `record-api.md` 的邮件接口;在补齐接口契约、发件身份、失败重试和审计要求前不接入。
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { showFeedback } from '@/utils/feedback';
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons'; import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable'; import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Card, Card,
...@@ -11,7 +12,6 @@ import { ...@@ -11,7 +12,6 @@ import {
SearchField, SearchField,
Spinner, Spinner,
Tabs, Tabs,
toast,
} from '@heroui/react'; } from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react'; import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
...@@ -150,7 +150,7 @@ export default function NotificationListPage() { ...@@ -150,7 +150,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0); const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [busyKey, setBusyKey] = useState(''); const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -192,7 +192,7 @@ export default function NotificationListPage() { ...@@ -192,7 +192,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;
...@@ -214,13 +214,11 @@ export default function NotificationListPage() { ...@@ -214,13 +214,11 @@ export default function NotificationListPage() {
setBusyKey(key); setBusyKey(key);
try { try {
await operation(); await operation();
toast.success(successMessage, { timeout: 3000 }); showFeedback(successMessage, 'success');
refresh(); refresh();
return true; return true;
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', { showFeedback(operationError?.message || '操作失败,请稍后重试');
timeout: 4000,
});
return false; return false;
} finally { } finally {
setBusyKey(''); setBusyKey('');
...@@ -271,9 +269,7 @@ export default function NotificationListPage() { ...@@ -271,9 +269,7 @@ export default function NotificationListPage() {
} }
refresh(); refresh();
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { showFeedback(operationError?.message || '无法打开通知关联流程');
timeout: 4000,
});
} finally { } finally {
setBusyKey(''); setBusyKey('');
} }
...@@ -356,14 +352,15 @@ export default function NotificationListPage() { ...@@ -356,14 +352,15 @@ 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.Footer>
</Button> <Button size="sm" variant="secondary" onPress={refresh}>重新加载</Button>
</Alert> </EmptyState.Footer>
</EmptyState>
) : null} ) : null}
{loading && notifications.length === 0 ? ( {loading && notifications.length === 0 ? (
......
import { showFeedback } from '@/utils/feedback';
import { ErrorState } from '@/pages/audit_management/PageState';
import useFeedbackState from '@/hooks/useFeedbackState';
import { ArrowLeft, Download } from '@/components/AppIcons'; import { ArrowLeft, Download } from '@/components/AppIcons';
import { Description, Modal, Spinner, Surface } from '@heroui/react'; import { Description, Spinner } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useParams, useSearchParams } from 'umi'; import { Link, useParams, useSearchParams } from 'umi';
import { buildDepartmentLookup, buildMemberLookup, downloadProcessAttachment, formatDateTime, getDisplayFieldMap, loadProcessInstanceDetail } from './processMonitorService'; import { buildDepartmentLookup, buildMemberLookup, downloadProcessAttachment, formatDateTime, getDisplayFieldMap, loadProcessInstanceDetail } from './processMonitorService';
function resolveValue(field, fieldMap, lookups) { const definition = fieldMap[field?.label]; const value = field?.value; if (field?.type === 'file') { const files = Array.isArray(value) ? value : []; return files.length ? <div className="flex flex-wrap gap-2">{files.map((file, index) => <button className="text-sm text-accent underline" key={`${file.fileUrl || file.url}-${index}`} type="button" onClick={() => downloadProcessAttachment(file)}>{file.filename || file.fileName || '附件'}</button>)}</div> : <Description>暂无内容</Description>; } if (definition?.type === 'date') return formatDateTime(value, 'YYYY-MM-DD HH:mm'); if (definition?.type === 'member') return String(value || '').split(',').map((item) => lookups.members[item.trim()] || item.trim()).filter(Boolean).join('、') || '暂无内容'; if (definition?.type === 'department') return String(value || '').split(',').map((item) => lookups.departments[item.trim()] || item.trim()).filter(Boolean).join('、') || '暂无内容'; return value || <Description>暂无内容</Description>; } function resolveValue(field, fieldMap, lookups) { const definition = fieldMap[field?.label]; const value = field?.value; if (field?.type === 'file') { const files = Array.isArray(value) ? value : []; return files.length ? <div className="flex flex-wrap gap-2">{files.map((file, index) => <button className="text-sm text-accent underline" key={`${file.fileUrl || file.url}-${index}`} type="button" onClick={async () => { try { await downloadProcessAttachment(file); } catch (requestError) { showFeedback(requestError?.message || '附件下载失败'); } }}>{file.filename || file.fileName || '附件'}</button>)}</div> : <Description>暂无内容</Description>; } if (definition?.type === 'date') return formatDateTime(value, 'YYYY-MM-DD HH:mm'); if (definition?.type === 'member') return String(value || '').split(',').map((item) => lookups.members[item.trim()] || item.trim()).filter(Boolean).join('、') || '暂无内容'; if (definition?.type === 'department') return String(value || '').split(',').map((item) => lookups.departments[item.trim()] || item.trim()).filter(Boolean).join('、') || '暂无内容'; return value || <Description>暂无内容</Description>; }
export default function ProcessDetailPage() { export default function ProcessDetailPage() {
const { processId, application } = useParams(); const [searchParams] = useSearchParams(); const tabName = searchParams.get('tab') || ''; const [record, setRecord] = useState(null); const [fieldsDefinition, setFieldsDefinition] = useState([]); const [members, setMembers] = useState([]); const [departments, setDepartments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const { processId, application } = useParams(); const [searchParams] = useSearchParams(); const tabName = searchParams.get('tab') || ''; const [record, setRecord] = useState(null); const [fieldsDefinition, setFieldsDefinition] = useState([]); const [members, setMembers] = useState([]); const [departments, setDepartments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useFeedbackState('error'); const [retryKey, setRetryKey] = useState(0);
useEffect(() => { let cancelled = false; setLoading(true); loadProcessInstanceDetail(application).then((result) => { if (cancelled) return; setRecord(result.record); setFieldsDefinition(result.fieldsDefinition); setMembers(result.members); setDepartments(result.departments); }).catch((requestError) => { if (!cancelled) setError(requestError?.message || '加载流程详情失败'); }).finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [application]); useEffect(() => { let cancelled = false; setLoading(true); setError(''); loadProcessInstanceDetail(application).then((result) => { if (cancelled) return; setRecord(result.record); setFieldsDefinition(result.fieldsDefinition); setMembers(result.members); setDepartments(result.departments); }).catch((requestError) => { if (!cancelled) setError(requestError?.message || '加载流程详情失败'); }).finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [application, retryKey, setError]);
const lookups = useMemo(() => ({ members: buildMemberLookup(members), departments: buildDepartmentLookup(departments) }), [members, departments]); const fieldMap = useMemo(() => getDisplayFieldMap(fieldsDefinition), [fieldsDefinition]); const details = record?.metadata?.detail || []; const lookups = useMemo(() => ({ members: buildMemberLookup(members), departments: buildDepartmentLookup(departments) }), [members, departments]); const fieldMap = useMemo(() => getDisplayFieldMap(fieldsDefinition), [fieldsDefinition]); const details = record?.metadata?.detail || [];
return <div className="space-y-6"><div className="flex flex-wrap items-center gap-3"><Link className="inline-flex items-center gap-2 text-sm text-muted" to={tabName ? `/process_monitor/${processId}?tab=${tabName}` : `/process_monitor/${processId}`}><ArrowLeft size={16} />返回实例列表</Link></div><h1 className="rc-section-title">{record?.title || '流程实例详情'}</h1>{error ? <Surface className="p-4" variant="secondary">{error}</Surface> : null}{loading ? <div className="flex min-h-48 items-center justify-center"><Spinner size="sm" /></div> : <section className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">{details.length ? details.map((field, index) => <div className="min-w-0 py-3" key={`${field.label || 'field'}-${index}`}><div className="text-sm font-medium text-muted">{field.label || `字段 ${index + 1}`}</div><div className="mt-1 break-words text-sm text-foreground">{resolveValue(field, fieldMap, lookups)}</div></div>) : <Description>暂无内容</Description>}</section>}</div>; return <div className="space-y-6"><div className="flex flex-wrap items-center gap-3"><Link className="inline-flex items-center gap-2 text-sm text-muted" to={tabName ? `/process_monitor/${processId}?tab=${tabName}` : `/process_monitor/${processId}`}><ArrowLeft size={16} />返回实例列表</Link></div><h1 className="rc-section-title">{record?.title || '流程实例详情'}</h1>{loading ? <div className="flex min-h-48 items-center justify-center"><Spinner size="sm" /></div> : error ? <ErrorState message="流程详情加载失败" onRetry={() => setRetryKey((key) => key + 1)} /> : <section className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">{details.length ? details.map((field, index) => <div className="min-w-0 py-3" key={`${field.label || 'field'}-${index}`}><div className="text-sm font-medium text-muted">{field.label || `字段 ${index + 1}`}</div><div className="mt-1 break-words text-sm text-foreground">{resolveValue(field, fieldMap, lookups)}</div></div>) : <Description>暂无内容</Description>}</section>}</div>;
} }
...@@ -155,7 +155,17 @@ ...@@ -155,7 +155,17 @@
--- ---
## 8. 当前项目对应文件 ## 8. 操作反馈与异常处理
- 分组创建、模板添加/编辑/移除和显示字段保存沿用根部 `AppFeedback`,成功默认 4 秒、接口错误默认 8 秒后消失;移除常驻成功横条和弹窗内的接口错误横条。
- 浮层位于视口顶部,层级高于配置 Modal 和移除确认弹窗,关闭按钮始终可见。关闭提示不清空分组名称、模板选择或字段草稿,不改变当前分组、关键字、部门筛选或分页。
- 加载失败保留失败状态和已有数据;无可展示数据时提供重新加载入口。实例详情加载失败可重新加载,附件下载失败通过操作提示报告,允许再次点击下载。
- 请求期间继续保持加载/保存状态与原有按钮禁用条件;重复失败重新显示提示。本次不修改 `process_monitor_group.metadata.progresses`、`audit_related_progress.metadata.fields_definition`、`audit_progress_info.metadata.detail` 或 Reference ID 写入规则。
- 本模块保留为复用实现,当前 `.umirc.ts` 未配置流程监控路由;本次不新增导航入口。
---
## 9. 当前项目对应文件
- `src/pages/process_monitor/Layout.jsx` - `src/pages/process_monitor/Layout.jsx`
- `src/pages/process_monitor/ProcessMonitor.jsx` - `src/pages/process_monitor/ProcessMonitor.jsx`
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
@import "./design-system/theme.css"; @import "./design-system/theme.css";
@import "./design-system/tokens.css"; @import "./design-system/tokens.css";
@import "./design-system/shell.css"; @import "./design-system/shell.css";
@import "./design-system/feedback.css";
@theme { @theme {
--font-inter: --font-inter:
......
import { toast } from "@heroui/react";
const FEEDBACK_OPTIONS = {
error: { title: "操作失败", variant: "danger", timeout: 8000 },
success: { title: "操作成功", variant: "success", timeout: 4000 },
warning: { title: "请注意", variant: "warning", timeout: 8000 },
};
export function showFeedback(message, tone = "error") {
if (!message) return null;
const { title, variant, timeout } =
FEEDBACK_OPTIONS[tone] || FEEDBACK_OPTIONS.error;
return toast(title, { description: String(message), variant, timeout });
}
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService'; import { getSystemInfo } from '@/services/DataService';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
...@@ -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('warning');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -54,7 +55,7 @@ export default function SiteWrapper() { ...@@ -54,7 +55,7 @@ export default function SiteWrapper() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [setLoadError]);
if (loading) { if (loading) {
return ( return (
......
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