Commit e97b68f0 authored by drigle's avatar drigle

feat: unify operation feedback and improve case save interactions

parent 894205db
...@@ -18,6 +18,12 @@ ...@@ -18,6 +18,12 @@
- `theme.css` 保留 HeroUI 官方变量结构;本项目允许的品牌覆盖仅为鑫元基金品牌主色 `#DA241A`、品牌 Hover `#C91F17`、固定 primary 色阶和对应图表/指标 Token。页面不得覆盖 HeroUI 外观、slot、BEM 类或交互状态。 - `theme.css` 保留 HeroUI 官方变量结构;本项目允许的品牌覆盖仅为鑫元基金品牌主色 `#DA241A`、品牌 Hover `#C91F17`、固定 primary 色阶和对应图表/指标 Token。页面不得覆盖 HeroUI 外观、slot、BEM 类或交互状态。
- 页面 `className` 只承担 flex、grid、宽度、间距、对齐和响应式布局。 - 页面 `className` 只承担 flex、grid、宽度、间距、对齐和响应式布局。
### 操作反馈浮层
接口成功、失败及降级警告统一使用应用根部 `AppFeedback` 的 HeroUI `Toast.Provider` 和官方 compound 组件。登录、业务页面和弹框共用一个 Provider,不再渲染接口反馈横条;成功默认 4 秒、错误/警告默认 8 秒后消失,支持主动关闭。
关闭按钮沿用问题整改 `bfc5862`:组合 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`)与 `AppIcons.Close`,通过官方 `slot="close"` 接入 Toast 关闭行为,位于正文右侧并参与布局。当前提示的关闭图标始终可见,使用官方透明按钮及默认 Hover/焦点样式;不再放大带边框的 Toast 默认关闭按钮,也不设置额外绝对位置和正文右侧占位。`src/design-system/feedback.css` 仅调整 Toast 层级、安全区位置及后排关闭按钮的可见性;层级 300,高于业务 Modal 和移动侧栏。颜色、边框、圆角、阴影与 Hover 继续使用 HeroUI 默认主题。
## 页面规则 ## 页面规则
- Shell 使用 HeroUI Pro offcanvas Sidebar 与 Navbar;页面和 Navbar 使用 48/32/16px 对称 gutter。 - Shell 使用 HeroUI Pro offcanvas Sidebar 与 Navbar;页面和 Navbar 使用 48/32/16px 对称 gutter。
......
{ {
"name": "risk_case_library", "name": "risk_case_library",
"version": "1.0.4", "version": "1.0.5",
"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';
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';
export default function AppFeedback({ children }) {
return (
<>
{children}
<Toast.Provider
aria-label="操作提示"
className="app-feedback-region"
maxVisibleToasts={3}
placement="top"
width="min(30rem, calc(100vw - 2rem))"
>
{({ toast }) => (
<Toast className="app-feedback-toast 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>
</>
);
}
/* Keep operation feedback above Modal and the mobile sidebar, outside page scrolling. */
.app-feedback-region.toast-region {
z-index: 300;
top: max(1rem, env(safe-area-inset-top));
}
/* Only the front toast is interactive; its ghost close button stays visible without hover. */
.app-feedback-toast:not([data-frontmost="true"]) .app-feedback-close {
visibility: hidden;
pointer-events: none;
}
import { toast } from '@heroui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
const FEEDBACK_OPTIONS = {
error: { title: '操作失败', variant: 'danger', timeout: 8000 },
success: { title: '操作成功', variant: 'success', timeout: 4000 },
warning: { title: '请注意', variant: 'warning', timeout: 8000 },
};
// Closing feedback only changes its presentation; request and draft state stay intact.
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;
// Report every attempt, including the same error after a retry.
toastKeyRef.current = toast(title, { description: nextMessage, variant, timeout });
}, [tone]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
};
}, []);
return [message, updateMessage];
}
import xyLogoRed from '@/assets/identity/xy-logo-red.png'; import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import useFeedbackState from '@/hooks/useFeedbackState';
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';
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';
...@@ -26,7 +27,7 @@ export default function LoginPage() { ...@@ -26,7 +27,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null); const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const redirect = searchParams.get('redirect') || '/'; const redirect = searchParams.get('redirect') || '/';
...@@ -185,14 +186,6 @@ export default function LoginPage() { ...@@ -185,14 +186,6 @@ export default function LoginPage() {
</div> </div>
</TextField> </TextField>
{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 xinyuanLogo from '@/assets/identity/xy-logo-red.png'; import xinyuanLogo from '@/assets/identity/xy-logo-red.png';
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyStateSurface } from '@/components/DataTable'; import { EmptyStateSurface } from '@/components/DataTable';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons'; import { MagnifyingGlassIcon } from '@/components/AppIcons';
import { import {
APP_DISPLAY_NAME, APP_DISPLAY_NAME,
APP_LOGIN_SUBTITLE, APP_LOGIN_SUBTITLE,
...@@ -11,7 +12,6 @@ import { EmptyState } from '@heroui-pro/react/empty-state'; ...@@ -11,7 +12,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,
...@@ -321,7 +321,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -321,7 +321,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(
() => () =>
...@@ -363,6 +363,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -363,6 +363,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setApplicationSearch(''); setApplicationSearch('');
setErrorMessage('');
return; return;
} }
...@@ -396,7 +397,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -396,7 +397,7 @@ export default function ApplicationSwitcherDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [fallbackApplications, open]); }, [fallbackApplications, open, setErrorMessage]);
return ( return (
<Modal.Root> <Modal.Root>
...@@ -446,13 +447,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -446,13 +447,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 ? (
......
...@@ -9,7 +9,7 @@ import { UserContext } from '@/wrapper/Auth'; ...@@ -9,7 +9,7 @@ import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Navbar } from '@heroui-pro/react/navbar'; import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar'; import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Badge, Button, Dropdown, Label, Toast, Tooltip } from '@heroui/react'; import { Avatar, Badge, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { Heading } from 'react-aria-components/Dialog'; import { Heading } from 'react-aria-components/Dialog';
import { history, Outlet, useLocation } from 'umi'; import { history, Outlet, useLocation } from 'umi';
...@@ -308,7 +308,6 @@ export default function ProjectManagerLayout() { ...@@ -308,7 +308,6 @@ export default function ProjectManagerLayout() {
userInfo={userInfo} userInfo={userInfo}
siteInfo={siteInfo} siteInfo={siteInfo}
/> />
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider> </Sidebar.Provider>
); );
} }
import { ArrowPathIcon, Delete } from '@/components/AppIcons'; import { ArrowPathIcon, 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 { Alert, Button, Label, Tabs, Tooltip } from '@heroui/react'; import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Label, Tabs, Tooltip } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { queryRiskCaseMaterials, uploadRiskCaseFile } from './riskCaseMaterialService'; import { queryRiskCaseMaterials, uploadRiskCaseFile } from './riskCaseMaterialService';
...@@ -35,7 +36,8 @@ export default function RiskCaseMaterialField({ ...@@ -35,7 +36,8 @@ export default function RiskCaseMaterialField({
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
const [selectedKey, setSelectedKey] = useState(null); const [selectedKey, setSelectedKey] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [, setUploadError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0); const [retryKey, setRetryKey] = useState(0);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [localKeys, setLocalKeys] = useState(() => new Set()); const [localKeys, setLocalKeys] = useState(() => new Set());
...@@ -46,8 +48,9 @@ export default function RiskCaseMaterialField({ ...@@ -46,8 +48,9 @@ export default function RiskCaseMaterialField({
setKeyword(''); setKeyword('');
setSelectedKey(null); setSelectedKey(null);
setError(''); setError('');
setUploadError('');
setLocalKeys(new Set()); setLocalKeys(new Set());
}, [editSessionId]); }, [editSessionId, setError, setUploadError]);
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => setDebouncedKeyword(keyword), 300); const timer = window.setTimeout(() => setDebouncedKeyword(keyword), 300);
...@@ -64,7 +67,7 @@ export default function RiskCaseMaterialField({ ...@@ -64,7 +67,7 @@ export default function RiskCaseMaterialField({
.catch((requestError) => { if (!cancelled) { setMaterials([]); setError(requestError?.message || '审计材料加载失败。'); } }) .catch((requestError) => { if (!cancelled) { setMaterials([]); setError(requestError?.message || '审计材料加载失败。'); } })
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [activeTab, debouncedKeyword, retryKey]); }, [activeTab, debouncedKeyword, retryKey, setError]);
const options = materials.map((material) => ({ const options = materials.map((material) => ({
...material, ...material,
...@@ -93,7 +96,7 @@ export default function RiskCaseMaterialField({ ...@@ -93,7 +96,7 @@ export default function RiskCaseMaterialField({
const nextFiles = multiple ? toFiles(selectedFiles) : toFiles(selectedFiles).slice(0, 1); const nextFiles = multiple ? toFiles(selectedFiles) : toFiles(selectedFiles).slice(0, 1);
if (nextFiles.length === 0) return; if (nextFiles.length === 0) return;
setUploading(true); setUploading(true);
setError(''); setUploadError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId); onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
const uploaded = []; const uploaded = [];
const failed = []; const failed = [];
...@@ -108,7 +111,7 @@ export default function RiskCaseMaterialField({ ...@@ -108,7 +111,7 @@ export default function RiskCaseMaterialField({
onPendingLocalMaterialChange?.(fieldPath, { type: multiple ? 'add' : 'replace', materials: pending }, editSessionId); onPendingLocalMaterialChange?.(fieldPath, { type: multiple ? 'add' : 'replace', materials: pending }, editSessionId);
onChange?.(multiple ? appendUnique(files, uploaded) : uploaded[uploaded.length - 1]); onChange?.(multiple ? appendUnique(files, uploaded) : uploaded[uploaded.length - 1]);
} }
if (failed.length > 0) setError(failed.length === nextFiles.length ? failed[0].uploadError?.message || '附件上传失败。' : `部分附件上传失败:${failed.map((item) => item.file?.name).filter(Boolean).join('、')}`); if (failed.length > 0) setUploadError(failed.length === nextFiles.length ? failed[0].uploadError?.message || '附件上传失败。' : `部分附件上传失败:${failed.map((item) => item.file?.name).filter(Boolean).join('、')}`);
setUploading(false); setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId); onValidationChange?.(fieldPath, '', editSessionId);
} }
...@@ -129,7 +132,7 @@ export default function RiskCaseMaterialField({ ...@@ -129,7 +132,7 @@ export default function RiskCaseMaterialField({
<Tabs.ListContainer><Tabs.List aria-label="附件来源"><Tabs.Tab id={MATERIAL_CENTER_TAB}>材料中心<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id={LOCAL_UPLOAD_TAB}>本地上传<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer> <Tabs.ListContainer><Tabs.List aria-label="附件来源"><Tabs.Tab id={MATERIAL_CENTER_TAB}>材料中心<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id={LOCAL_UPLOAD_TAB}>本地上传<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}> <Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<XinyuanAutocomplete aria-label="选择审计材料" emptyText="没有找到可选择的审计材料" errorText={error} inputValue={keyword} isDisabled={uploading} isLoading={loading} options={options} placeholder={multiple && files.length ? `已选 ${files.length} 个附件,可继续添加` : '请选择审计材料'} searchPlaceholder="搜索材料标题或文件名" selectedKey={selectedKey} shouldFilter={false} onInputChange={setKeyword} onSelectionChange={handleMaterialSelection} renderOption={(material) => <span className="min-w-0"><Label className="block truncate">{material.materialTitle}</Label><span className="block truncate text-xs text-muted">{[material.fileAsset?.name, material.archiveCategory, material.archiveYear].filter(Boolean).join(' · ')}</span></span>} /> <XinyuanAutocomplete aria-label="选择审计材料" emptyText="没有找到可选择的审计材料" errorText={error} inputValue={keyword} isDisabled={uploading} isLoading={loading} options={options} placeholder={multiple && files.length ? `已选 ${files.length} 个附件,可继续添加` : '请选择审计材料'} searchPlaceholder="搜索材料标题或文件名" selectedKey={selectedKey} shouldFilter={false} onInputChange={setKeyword} onSelectionChange={handleMaterialSelection} renderOption={(material) => <span className="min-w-0"><Label className="block truncate">{material.materialTitle}</Label><span className="block truncate text-xs text-muted">{[material.fileAsset?.name, material.archiveCategory, material.archiveYear].filter(Boolean).join(' · ')}</span></span>} />
{error ? <Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="danger-soft" onPress={() => setRetryKey((value) => value + 1)}><ArrowPathIcon size={14} />重试</Button></Alert> : null} {error ? <Button className="mt-3" isDisabled={loading} size="sm" variant="secondary" onPress={() => setRetryKey((value) => value + 1)}><ArrowPathIcon size={14} />重新加载审计材料</Button> : null}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}><FileUploadField isDisabled={uploading} multiple={multiple} triggerLabel={uploading ? '上传中...' : '选择本地文件'} onFilesChange={handleLocalUpload} /></Tabs.Panel> <Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}><FileUploadField isDisabled={uploading} multiple={multiple} triggerLabel={uploading ? '上传中...' : '选择本地文件'} onFilesChange={handleLocalUpload} /></Tabs.Panel>
</Tabs> </Tabs>
......
...@@ -82,7 +82,7 @@ ...@@ -82,7 +82,7 @@
- 标题、案例类型、案例详情至少填写一项;未满足时在三个相关表单字段内部展示必填校验、标记错误状态并聚焦标题输入框,弹框保持打开 - 标题、案例类型、案例详情至少填写一项;未满足时在三个相关表单字段内部展示必填校验、标记错误状态并聚焦标题输入框,弹框保持打开
4. 前端调用 `POST /record` 4. 前端调用 `POST /record`
5. 成功后生成一条 `risk_case_library` 记录 5. 成功后生成一条 `risk_case_library` 记录
6. 页面顶部提示创建成功,并刷新当前列表 6. 通过视口顶部操作提示浮层反馈创建成功,并刷新当前列表
7. 新案例出现在列表中,用户可按需再打开详情继续编辑 7. 新案例出现在列表中,用户可按需再打开详情继续编辑
### 4.2 Excel 批量导入案例 ### 4.2 Excel 批量导入案例
...@@ -152,7 +152,7 @@ ...@@ -152,7 +152,7 @@
5. 关键字编辑区的输入框仅用于新增关键字,已有关键字以标签展示并支持逐个删除;保存时写入 `metadata.keyword` 字符串数组 5. 关键字编辑区的输入框仅用于新增关键字,已有关键字以标签展示并支持逐个删除;保存时写入 `metadata.keyword` 字符串数组
6. 点击 `取消` 时丢弃本次草稿并恢复打开详情时的原值,不发起更新请求 6. 点击 `取消` 时丢弃本次草稿并恢复打开详情时的原值,不发起更新请求
7. 点击 `保存` 后,前端调用 `PUT /record/:name` 7. 点击 `保存` 后,前端调用 `PUT /record/:name`
8. 保存成功后刷新列表与统计数据,并返回查看态;失败时保留当前草稿和错误提示 8. 待建档材料和案例更新全部成功后刷新列表与统计数据,并返回查看态,在弹窗上方弹出“详情已保存”成功提示;写入失败时保留当前草稿和错误提示
创建、编辑、删除、批量修改、批量删除和 Excel 导入成功后,除刷新当前列表外,还必须重新调用分类 aggregate 刷新案例性质标签;aggregate 失败时保留当前标签并展示重试提示。 创建、编辑、删除、批量修改、批量删除和 Excel 导入成功后,除刷新当前列表外,还必须重新调用分类 aggregate 刷新案例性质标签;aggregate 失败时保留当前标签并展示重试提示。
...@@ -183,6 +183,17 @@ ...@@ -183,6 +183,17 @@
5. 前端逐条调用 `DELETE /record/:name` 5. 前端逐条调用 `DELETE /record/:name`
6. 删除后刷新列表,并保留失败项提示 6. 删除后刷新列表,并保留失败项提示
### 4.9 操作提示与失败重试
- 功能入口:登录、站点配置、应用切换、驾驶舱、案例管理及其新建/详情/批量操作/Excel 导入弹框、通知中心、材料中心选择与本地附件上传。
- 查询、创建、编辑、删除、复制、批量修改/删除、Excel 导入/导出和上传的现有成功/失败反馈统一使用根部 HeroUI Toast;站点/应用列表/Subject 与 View 配置及演示数据降级使用 warning。提示显示在视口顶部,层级高于业务 Modal、确认框和移动侧栏,页面滚动及弹框打开时仍可看到并关闭。
- 成功提示默认 4 秒、错误和警告默认 8 秒后消失,悬停或键盘聚焦暂停计时;当前提示右侧的小号透明关闭图标始终可见,通过 HeroUI Toast 的 `close` slot 支持点击和键盘激活。后排堆叠提示隐藏关闭按钮,避免误关闭。相同错误再次发生时重新提示,同一来源的新反馈替换旧反馈;清空反馈或离开所属页面时清理该来源提示。
- 关闭提示只影响展示,不改变请求结果,不清空案例草稿、附件、导入文件或勾选项,不发起创建、保存、删除或上传。新建案例的“标题/案例类型/案例详情至少一项”继续使用字段级校验和聚焦,删除与覆盖字段等确认说明保留在弹框内。
- 详情保存成功提示独立管理,在材料建档、案例更新及本次列表/分类读取结束、弹窗返回查看态后发出,保证提示的 4 秒展示时间不被刷新占用;详情弹窗继续展示保存结果。重新编辑、再次保存、取消编辑、关闭详情或打开其他案例时清理上一条保存成功提示。无修改、校验未通过、附件仍在上传或正在保存时不提交、不显示成功提示;写入失败仅显示错误并保留草稿。
- 案例列表和驾驶舱在未进入既有演示数据降级的加载失败场景中保留失败空状态及“重新加载”,不会误显示“暂无案例”;演示数据降级通过 warning 提示并继续展示案例。通知中心保留“重试”。关闭错误提示不会消除失败状态;重试只读取数据,并保留当前筛选条件、案例性质、页码和每页条数,通知沿用当前分类、已读状态及搜索词。
- 案例分类 aggregate 失败时保留旧标签及“重新加载案例分类”;视图配置失败时继续使用默认字段并以 warning 告知。查询反馈与创建/保存/批量操作/导入反馈分开管理,列表刷新不会清掉操作失败原因。批量删除/修改及导入部分失败时,用一条错误提示同时展示成功数、失败数及首条失败原因,不额外弹出成功提示。
- 材料查询失败时保留选择器字段级错误和读取重试入口;附件上传失败在弹框上方提示,上传部分失败时保留成功附件。创建/保存失败时弹框及草稿保留,重新提交仍执行原有 `metadata`、附件快照和材料建档协议。
--- ---
## 5. 联动规则 ## 5. 联动规则
......
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 useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Button, Card, Chip, SearchField, Spinner, Tabs, toast } from '@heroui/react'; import { Avatar, Button, Card, Chip, SearchField, Spinner, Tabs } 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';
import { import {
...@@ -75,7 +76,9 @@ export default function NotificationListPage() { ...@@ -75,7 +76,9 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0); const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [, setOperationError] = useFeedbackState('error');
const [, setSuccess] = useFeedbackState('success');
const [busyKey, setBusyKey] = useState(''); const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -97,26 +100,29 @@ export default function NotificationListPage() { ...@@ -97,26 +100,29 @@ export default function NotificationListPage() {
if (!cancelled) { setNotifications([]); setTotal(0); setError(requestError?.message || '通知列表加载失败,请稍后重试'); } if (!cancelled) { setNotifications([]); setTotal(0); setError(requestError?.message || '通知列表加载失败,请稍后重试'); }
}).finally(() => { if (!cancelled) setLoading(false); }); }).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [category, keyword, page, readStatus, reloadKey]); }, [category, keyword, page, readStatus, reloadKey, setError]);
const refresh = useCallback(() => setReloadKey((value) => value + 1), []); const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const runOperation = useCallback(async (key, operation, message) => { const runOperation = useCallback(async (key, operation, message) => {
setBusyKey(key); setBusyKey(key);
try { await operation(); toast.success(message, { timeout: 3000 }); refresh(); return true; } setOperationError('');
catch (operationError) { toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 }); return false; } setSuccess('');
try { await operation(); setSuccess(message); refresh(); return true; }
catch (operationError) { setOperationError(operationError?.message || '操作失败,请稍后重试'); return false; }
finally { setBusyKey(''); } finally { setBusyKey(''); }
}, [refresh]); }, [refresh, setOperationError, setSuccess]);
const handleOpenTarget = useCallback(async (notification, target) => { const handleOpenTarget = useCallback(async (notification, target) => {
setBusyKey(notification.name); setBusyKey(notification.name);
setOperationError('');
try { try {
if (!isNotificationRead(notification, userInfo?._id)) await markNotificationRead(notification.name); if (!isNotificationRead(notification, userInfo?._id)) await markNotificationRead(notification.name);
if (target.external) window.open(target.href, '_blank', 'noopener,noreferrer'); if (target.external) window.open(target.href, '_blank', 'noopener,noreferrer');
else history.push(target.href); else history.push(target.href);
refresh(); refresh();
} catch (operationError) { toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 }); } } catch (operationError) { setOperationError(operationError?.message || '无法打开通知关联流程'); }
finally { setBusyKey(''); } finally { setBusyKey(''); }
}, [refresh, userInfo?._id]); }, [refresh, setOperationError, userInfo?._id]);
return ( return (
<div className="rc-page-stack"> <div className="rc-page-stack">
...@@ -138,7 +144,15 @@ export default function NotificationListPage() { ...@@ -138,7 +144,15 @@ export default function NotificationListPage() {
<Tabs.ListContainer><Tabs.List aria-label="通知状态">{READ_STATUS_OPTIONS.map((option) => <Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer> <Tabs.ListContainer><Tabs.List aria-label="通知状态">{READ_STATUS_OPTIONS.map((option) => <Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer>
</Tabs> </Tabs>
</div> </div>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="danger-soft" onPress={refresh}>重试</Button></Alert> : null} {!loading && error ? (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>通知列表加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content><Button variant="secondary" onPress={refresh}>重试</Button></EmptyState.Content>
</EmptyState>
) : null}
{loading && notifications.length === 0 ? <div className="flex min-h-64 items-center justify-center"><Spinner size="sm" /></div> : null} {loading && notifications.length === 0 ? <div className="flex min-h-64 items-center justify-center"><Spinner size="sm" /></div> : null}
{!loading && !error && notifications.length === 0 ? <EmptyState className="min-h-64" size="md"><EmptyState.Media><Bell size={22} /></EmptyState.Media><EmptyState.Header><EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title><EmptyState.Description>{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}</EmptyState.Description></EmptyState.Header></EmptyState> : null} {!loading && !error && notifications.length === 0 ? <EmptyState className="min-h-64" size="md"><EmptyState.Media><Bell size={22} /></EmptyState.Media><EmptyState.Header><EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title><EmptyState.Description>{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}</EmptyState.Description></EmptyState.Header></EmptyState> : null}
{notifications.length > 0 ? <div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>{notifications.map((notification) => <NotificationItem notification={notification} category={category} currentUserId={userInfo?._id} busy={busyKey === notification.name} key={notification.name || notification._id} onMarkRead={(item) => runOperation(item.name, () => markNotificationRead(item.name), '已标记为已读')} onAction={(item, action) => runOperation(item.name, () => executeNotificationAction(item, action), '待办已处理')} onOpenTarget={handleOpenTarget} />)}</div> : null} {notifications.length > 0 ? <div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>{notifications.map((notification) => <NotificationItem notification={notification} category={category} currentUserId={userInfo?._id} busy={busyKey === notification.name} key={notification.name || notification._id} onMarkRead={(item) => runOperation(item.name, () => markNotificationRead(item.name), '已标记为已读')} onAction={(item, action) => runOperation(item.name, () => executeNotificationAction(item, action), '待办已处理')} onOpenTarget={handleOpenTarget} />)}</div> : null}
......
...@@ -12,7 +12,8 @@ import { ...@@ -12,7 +12,8 @@ import {
XINYUAN_CHART_SEMANTIC, XINYUAN_CHART_SEMANTIC,
} from '@/design-system/chartPalette'; } from '@/design-system/chartPalette';
import XinyuanEChart from '@/components/XinyuanEChart'; import XinyuanEChart from '@/components/XinyuanEChart';
import { Alert, Card, Spinner } from '@heroui/react'; import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Card, Spinner } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi'; import { KPI } from '@heroui-pro/react/kpi';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
...@@ -281,40 +282,38 @@ export default function ResourceStatPage() { ...@@ -281,40 +282,38 @@ export default function ResourceStatPage() {
const [cases, setCases] = useState([]); const [cases, setCases] = useState([]);
const [dashboard, setDashboard] = useState(() => buildCaseDashboard([])); const [dashboard, setDashboard] = useState(() => buildCaseDashboard([]));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [isDemoMode, setIsDemoMode] = useState(false); const [, setDemoWarning] = useFeedbackState('warning');
const [loadFailed, setLoadFailed] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
setDemoWarning('');
const caseResult = await loadCaseRecords({}); const caseResult = await loadCaseRecords({});
if (cancelled) return; if (cancelled) return;
setCases(caseResult.cases || []); setCases(caseResult.cases || []);
setDashboard(caseResult.dashboard || buildCaseDashboard([])); setDashboard(caseResult.dashboard || buildCaseDashboard([]));
setError(caseResult.error || ''); setLoadFailed(Boolean(caseResult.error) && !caseResult.isDemoMode);
setIsDemoMode(Boolean(caseResult.isDemoMode)); setError(caseResult.isDemoMode ? '' : caseResult.error || '');
setDemoWarning(caseResult.isDemoMode ? caseResult.error || '' : '');
setLoading(false); setLoading(false);
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [reloadKey, setDemoWarning, setError]);
return ( return (
<div className="rc-page-stack"> <div className="rc-page-stack">
{error ? (
<Alert status={isDemoMode ? 'warning' : 'danger'}>
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{loading ? ( {loading ? (
<div className="flex min-h-48 items-center justify-center gap-2" role="status"> <div className="flex min-h-48 items-center justify-center gap-2" role="status">
<Spinner size="sm" /> <Spinner size="sm" />
...@@ -322,9 +321,21 @@ export default function ResourceStatPage() { ...@@ -322,9 +321,21 @@ export default function ResourceStatPage() {
</div> </div>
) : null} ) : null}
{!loading && cases.length === 0 ? <EmptyBlock title="当前没有可展示的案例记录" /> : null} {!loading && loadFailed ? (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>驾驶舱加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={() => setReloadKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Content>
</EmptyState>
) : null}
{!loading && !loadFailed && cases.length === 0 ? <EmptyBlock title="当前没有可展示的案例记录" /> : null}
{!loading && cases.length > 0 ? ( {!loading && !loadFailed && cases.length > 0 ? (
<> <>
<section className="rc-page-section"> <section className="rc-page-section">
<div className="rc-kpi-grid" data-count="5"> <div className="rc-kpi-grid" data-count="5">
......
...@@ -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 { 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('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