Commit bd075758 authored by drigle's avatar drigle

fix: unify operation feedback and loading error recovery

parent 2b9d215b
......@@ -42,6 +42,14 @@
- 禁止 Catalyst、Headless UI、可见原生 select/date/file、旧 props 兼容包装层和手写 HeroUI 仿制组件。
- 共享组件只能组合官方组件、业务无关布局和可访问性,不得重绘组件外观。
## 操作反馈浮层
- 接口成功、失败与降级警告统一由根部 `AppFeedback` 的 HeroUI `Toast.Provider` 呈现,登录页、公告管理、流程监控和通知中心共用一个 Provider;业务页面不再渲染接口反馈横条。
- 成功提示默认 4 秒、错误和警告默认 8 秒后消失;悬停或键盘聚焦暂停计时。前排关闭图标无需悬停即可操作,提示出现时不抢走表单焦点。
- 关闭按钮沿用问题整改应用 `bfc5862`:直接组合 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`),通过官方 `slot="close"` 接入 Toast 关闭行为,使用 `AppIcons.Close`,位于内容右侧并随正文参与布局。使用官方透明按钮及默认 Hover/焦点样式,不再放大带边框的 Toast 默认关闭按钮,不设置额外绝对位置或正文右侧占位。
- `src/design-system/feedback.css` 仅约定层级、安全区位置与后排关闭按钮可见性,长文换行和滚动由组件布局类处理。Toast 层级为 300,高于业务 Modal、确认框和移动侧栏;后排提示不显示关闭按钮,颜色、边框、圆角、阴影与交互配色沿用 HeroUI 默认主题。
- 关闭提示不清空表单草稿或请求失败状态。加载失败继续展示 HeroUI Pro EmptyState 与读取重试入口,详细错误由浮层展示;再次发生相同错误仍会弹出提示。
## Shell
- Sidebar 宽 240px,`collapsible="offcanvas"`,收起后宽度为零且无 icon rail。
......
{
"name": "disclosure_management_new",
"version": "1.0.1",
"version": "1.0.3",
"build": "abc",
"description": "基金公告管理",
"date": "2025-07-14 19:27:20",
......
import '@/global.less';
import '@/tailwind.css';
import AppFeedback from '@/components/AppFeedback';
import { I18nProvider } from '@heroui/react';
import { createElement } from 'react';
import { defineApp } from 'umi';
......@@ -12,6 +13,6 @@ export async function getInitialState() {
export default defineApp({
rootContainer(container) {
return createElement(I18nProvider, { locale: 'zh-CN' }, container);
return createElement(I18nProvider, { locale: 'zh-CN' }, createElement(AppFeedback, null, container));
},
});
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 className="whitespace-pre-wrap break-words">{toast.content.title}</Toast.Title>
{toast.content.description ? (
<Toast.Description className="max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words text-foreground">
{toast.content.description}
</Toast.Description>
) : null}
{toast.content.actionProps ? <Toast.ActionButton {...toast.content.actionProps} /> : null}
</Toast.Content>
<Button
aria-label="关闭提示"
className="app-feedback-close shrink-0 self-start"
isIconOnly
size="sm"
slot="close"
variant="ghost"
>
<Close size={16} />
</Button>
</Toast>
)}
</Toast.Provider>
</>
);
}
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
......@@ -36,7 +35,6 @@ export default function AppShell({
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import useFeedbackState from '@/hooks/useFeedbackState';
import xinyuanLogo from '@/assets/xy-logo-red.png';
import {
APPLICATION_BASE_PATH,
......@@ -5,12 +6,11 @@ import {
APPLICATION_NAME,
} from '@/config/application';
import { getApplications } from '@/services/DataService';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons';
import { MagnifyingGlassIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Chip,
Modal,
......@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
......@@ -352,6 +352,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => {
if (!open) {
setApplicationSearch('');
setErrorMessage('');
return;
}
......@@ -392,7 +393,7 @@ export default function ApplicationSwitcherDialog({
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
}, [fallbackApplications, open, setErrorMessage]);
return (
<Modal.Root>
......@@ -443,13 +444,6 @@ export default function ApplicationSwitcherDialog({
offset={12}
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 ? (
<LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? (
......
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Button } from '@heroui/react';
export default function RequestErrorState({ title = '数据加载失败', onRetry, onBack }) {
return (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>{title}</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
{onBack ? <Button variant="secondary" onPress={onBack}>返回列表</Button> : null}
{onRetry ? <Button variant="secondary" onPress={onRetry}>重新加载</Button> : null}
</EmptyState.Content>
</EmptyState>
);
}
/* 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, never request state or drafts.
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 an identical error on 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 useFeedbackState from '@/hooks/useFeedbackState';
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import {
......@@ -7,7 +8,7 @@ import {
import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
import { Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi';
......@@ -55,7 +56,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => {
......@@ -205,14 +206,6 @@ export default function LoginPage() {
</div>
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
className="login-submit-button"
......
import { Close, FileText } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Button, Card, Input, Label, Modal, Pagination, Spinner, Table, toast } from '@heroui/react';
import { Button, Card, Input, Label, Modal, Pagination, Spinner, Table, toast } from '@heroui/react';
import { useRef } from 'react';
export function PageHeader({ action, eyebrow = '基金公告管理', title, description }) {
......@@ -23,20 +23,7 @@ export function LoadingState({ label = '正在加载' }) {
return <div className="grid min-h-48 place-items-center"><Spinner aria-label={label} /></div>;
}
export function ErrorState({ message, onRetry }) {
return (
<Alert status="danger">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>加载失败</Alert.Title>
<Alert.Description className="flex flex-wrap items-center gap-3">
<span>{message || '请求未完成,请稍后重试'}</span>
{onRetry ? <Button size="sm" variant="danger-soft" onPress={onRetry}>重新加载</Button> : null}
</Alert.Description>
</Alert.Content>
</Alert>
);
}
export { default as ErrorState } from '@/components/RequestErrorState';
export function EmptyData({ title = '暂无数据', description = '当前筛选条件下没有匹配记录' }) {
return (
......@@ -161,9 +148,9 @@ export function ReportDataDialog({ data, isOpen, onOpenChange }) {
}
export function notifyError(error, fallback) {
toast.danger(error?.message || fallback);
toast.danger(error?.message || fallback, { timeout: 8000 });
}
export function notifySuccess(message) {
toast.success(message);
toast.success(message, { timeout: 4000 });
}
import useFeedbackState from '@/hooks/useFeedbackState';
import { ArrowDownTrayIcon, Back, FileText } from '@/components/AppIcons';
import { Button, Card, Spinner } from '@heroui/react';
import { useEffect, useState } from 'react';
......@@ -22,7 +23,8 @@ export default function DisclosureDetailPage() {
const [disclosure, setDisclosure] = useState(null);
const [reportData, setReportData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [reloadKey, setReloadKey] = useState(0);
const [attachmentOpen, setAttachmentOpen] = useState(false);
const [dataOpen, setDataOpen] = useState(false);
useEffect(() => {
......@@ -34,9 +36,9 @@ export default function DisclosureDetailPage() {
setDisclosure(nextDisclosure); setReportData(nextReportData);
}).catch((requestError) => { if (!cancelled) setError(requestError?.message || '公告详情加载失败'); }).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [record]);
}, [record, reloadKey, setError]);
if (loading) return <div className="rc-page-stack"><div className="grid min-h-64 place-items-center"><Spinner aria-label="正在加载公告详情" /></div></div>;
if (error) return <ErrorState message={error} onRetry={() => navigate('/fund_disclosure')} />;
if (error) return <ErrorState title="公告详情加载失败" onRetry={() => setReloadKey((value) => value + 1)} onBack={() => navigate('/fund_disclosure')} />;
const metadata = disclosure.metadata || {};
const elements = reportData?.metadata?.elements || [];
return <div className="rc-page-stack">
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Check, Delete, Eye, FileText, OpenExternal, Search, Upload } from '@/components/AppIcons';
import { useAuthorization } from '@/hooks/user';
import { Button, Card, Checkbox, Input, toast } from '@heroui/react';
import { Button, Card, Checkbox, Input } from '@heroui/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'umi';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
......@@ -56,8 +57,8 @@ function ReportImportModal({ isOpen, onOpenChange, existingRecord, onComplete, d
}, [existingRecord, isOpen]);
const update = (key) => (event) => setForm((value) => ({ ...value, [key]: event.target.value }));
const submit = async () => {
if (!file) return toast.danger('请选择PDF或XBRL文件');
if (!form.fund_code || !form.disclosure_type || !form.disclosure_year) return toast.danger('请填写基金代码、公告类型和公告年份');
if (!file) return notifyError(null, '请选择PDF或XBRL文件');
if (!form.fund_code || !form.disclosure_type || !form.disclosure_year) return notifyError(null, '请填写基金代码、公告类型和公告年份');
setBusy(true);
try {
await importDisclosureFile(file, form);
......@@ -113,7 +114,7 @@ export default function DisclosureListPage() {
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [reloadKey, setReloadKey] = useState(0);
const [importOpen, setImportOpen] = useState(false);
const [importTarget, setImportTarget] = useState(null);
......@@ -133,9 +134,9 @@ export default function DisclosureListPage() {
} catch (requestError) {
setRecords([]); setTotal(0); setError(requestError?.message || '公告列表加载失败');
} finally { setLoading(false); }
}, [filters, page, pageSize, queryKeyword]);
}, [filters, page, pageSize, queryKeyword, setError]);
useEffect(() => { loadPage(); }, [loadPage, reloadKey]);
useEffect(() => { loadDisclosureOptions().then(setOptions).catch(() => {}); }, []);
useEffect(() => { loadDisclosureOptions().then(setOptions).catch((requestError) => notifyError(requestError, '筛选选项加载失败,请刷新页面重试')); }, []);
const search = (nextFilters, nextKeyword = keyword) => { if (nextFilters) setFilters(nextFilters); setQueryKeyword(nextKeyword.trim()); setPage(0); };
const refresh = () => setReloadKey((value) => value + 1);
......@@ -144,7 +145,7 @@ export default function DisclosureListPage() {
};
const runExport = async (type) => {
const selected = records.filter((record) => selectedIds.includes(getRecordId(record)));
if (!selected.length) return toast.danger('请先选择公告');
if (!selected.length) return notifyError(null, '请先选择公告');
setExportBusy(true);
try { await exportDisclosureFiles(selected, type); notifySuccess('导出任务已完成'); } catch (error) { notifyError(error, '导出失败'); } finally { setExportBusy(false); }
};
......@@ -176,7 +177,7 @@ export default function DisclosureListPage() {
<div><h2 className="rc-module-title">公告清单</h2><p className="mt-1 text-sm text-muted">已加载 {records.length} 条,累计 {total} 条</p></div>
<div className="flex flex-wrap gap-2"><Button isDisabled={!selectedIds.length || exportBusy} size="sm" variant="secondary" onPress={() => runExport('xbrl')}><OpenExternal size={15} />导出XBRL</Button><Button isDisabled={!selectedIds.length || exportBusy} size="sm" variant="secondary" onPress={() => runExport('pdf')}><OpenExternal size={15} />导出PDF</Button><Button isDisabled={!selectedIds.length || exportBusy} size="sm" variant="danger-soft" onPress={() => { setRemoveTarget(null); setRemoveOpen(true); }}><Delete size={15} />删除</Button></div>
</Card.Content>
{loading ? <LoadingState label="正在加载公告列表" /> : error ? <ErrorState message={error} onRetry={loadPage} /> : <DataTable emptyTitle="暂无公告" rows={records} columns={columns} />}
{loading ? <LoadingState label="正在加载公告列表" /> : error ? <ErrorState title="公告列表加载失败" onRetry={loadPage} /> : <DataTable emptyTitle="暂无公告" rows={records} columns={columns} />}
{!loading && !error ? <PaginationBar page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(value) => { setPageSize(value); setPage(0); }} /> : null}
</Card>
<ReportImportModal disclosureTypes={options.disclosureTypes} isOpen={importOpen} existingRecord={importTarget} onOpenChange={setImportOpen} onComplete={refresh} />
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Back, FileText } from '@/components/AppIcons';
import { Button, Card, Spinner } from '@heroui/react';
import { useEffect, useState } from 'react';
......@@ -22,7 +23,8 @@ export default function DisclosureTemplateDetailPage() {
const [template, setTemplate] = useState(null);
const [modules, setModules] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true); setError('');
......@@ -32,9 +34,9 @@ export default function DisclosureTemplateDetailPage() {
if (!cancelled) { setTemplate(nextTemplate); setModules(nextModules); }
}).catch((requestError) => { if (!cancelled) setError(requestError?.message || '公告模板加载失败'); }).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [record]);
}, [record, reloadKey, setError]);
if (loading) return <div className="grid min-h-64 place-items-center"><Spinner aria-label="正在加载模板详情" /></div>;
if (error) return <ErrorState message={error} onRetry={() => navigate('/fund_disclosure_template')} />;
if (error) return <ErrorState title="公告模板详情加载失败" onRetry={() => setReloadKey((value) => value + 1)} onBack={() => navigate('/fund_disclosure_template')} />;
return <div className="rc-page-stack"><PageHeader title={template.title} description={`${template.metadata?.type || '未设置类型'} · ${template.metadata?.fund_category || '未设置基金类别'} · ${modules.length} 个模块`} action={<Button size="sm" variant="secondary" onPress={() => navigate('/fund_disclosure_template')}><Back size={15} />返回列表</Button>} />
<div className="grid gap-4 lg:grid-cols-[16rem_minmax(0,1fr)]">
<Card className="rc-card-surface lg:sticky lg:top-4 lg:self-start"><Card.Content className="p-4"><h2 className="rc-module-title">模块目录</h2><nav className="mt-3 flex flex-col gap-1" aria-label="模板模块目录">{modules.map((module, index) => <a className="rounded-lg px-3 py-2 text-sm transition hover:bg-surface-secondary" href={`#module-${index}`} key={module._id || index}>{module.title || `模块 ${index + 1}`}</a>)}</nav></Card.Content></Card>
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Delete, Eye, Search, Upload } from '@/components/AppIcons';
import { useAuthorization } from '@/hooks/user';
import { Button, Card, Input, toast } from '@heroui/react';
import { Button, Card, Input } from '@heroui/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'umi';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
......@@ -12,7 +13,7 @@ function TemplateImportModal({ isOpen, onOpenChange, onComplete, templateTypes =
const [form, setForm] = useState({ type: '', fund_category: '', fund_status: '' });
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!file || !form.type || !form.fund_category) return toast.danger('请选择模板文件、报告类型和基金类别');
if (!file || !form.type || !form.fund_category) return notifyError(null, '请选择模板文件、报告类型和基金类别');
setBusy(true);
try { await importDisclosureTemplate(file, form); notifySuccess('公告模板导入成功'); onOpenChange(false); onComplete?.(); } catch (error) { notifyError(error, '公告模板导入失败'); } finally { setBusy(false); }
};
......@@ -34,7 +35,7 @@ export default function DisclosureTemplateListPage() {
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [reloadKey, setReloadKey] = useState(0);
const [importOpen, setImportOpen] = useState(false);
const [removeOpen, setRemoveOpen] = useState(false);
......@@ -43,9 +44,9 @@ export default function DisclosureTemplateListPage() {
const loadPage = useCallback(async () => {
setLoading(true); setError('');
try { const result = await queryTemplates({ page, pageSize, keyword: queryKeyword }); setRecords(result.records); setTotal(result.total); } catch (requestError) { setError(requestError?.message || '公告模板加载失败'); } finally { setLoading(false); }
}, [page, pageSize, queryKeyword]);
}, [page, pageSize, queryKeyword, setError]);
useEffect(() => { loadPage(); }, [loadPage, reloadKey]);
useEffect(() => { loadDisclosureOptions().then(setOptions).catch(() => {}); }, []);
useEffect(() => { loadDisclosureOptions().then(setOptions).catch((requestError) => notifyError(requestError, '筛选选项加载失败,请刷新页面重试')); }, []);
const columns = useMemo(() => [
{ key: 'title', label: '模板标题', render: (record) => <Button className="justify-start px-0" size="sm" variant="ghost" onPress={() => navigate(`/fund_disclosure_template/${getRecordId(record)}`)}>{record.title}</Button> },
{ key: 'type', label: '模板类型', render: (record) => record.metadata?.type || '-' },
......@@ -61,7 +62,7 @@ export default function DisclosureTemplateListPage() {
return <div className="rc-page-stack">
<PageHeader title="公告模板" description="维护公告模板文件和模块结构,导入后可在模板详情中检查标题层级与字段占位。" action={canManage ? <Button size="sm" variant="primary" onPress={() => setImportOpen(true)}><Upload size={15} />导入模板</Button> : null} />
<Card className="rc-card-surface rc-filter-surface"><Card.Content className="grid gap-3 p-4 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-end"><div className="min-w-0"><Input aria-label="搜索模板" label="搜索模板" placeholder="输入模板名称" value={keyword} onChange={(event) => setKeyword(event.target.value)} onKeyDown={(event) => event.key === 'Enter' && (setQueryKeyword(keyword.trim()), setPage(0))} /></div><Button size="sm" variant="primary" onPress={() => { setQueryKeyword(keyword.trim()); setPage(0); }}><Search size={15} />查询</Button><Button size="sm" variant="tertiary" onPress={() => { setKeyword(''); setQueryKeyword(''); setPage(0); }}>重置</Button></Card.Content></Card>
<Card className="rc-card-surface overflow-hidden"><Card.Content className="border-b border-separator p-4"><h2 className="rc-module-title">模板清单</h2><p className="mt-1 text-sm text-muted">累计 {total} 条模板</p></Card.Content>{loading ? <LoadingState label="正在加载模板列表" /> : error ? <ErrorState message={error} onRetry={loadPage} /> : <DataTable emptyTitle="暂无公告模板" rows={records} columns={columns} />}{!loading && !error ? <PaginationBar page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(value) => { setPageSize(value); setPage(0); }} /> : null}</Card>
<Card className="rc-card-surface overflow-hidden"><Card.Content className="border-b border-separator p-4"><h2 className="rc-module-title">模板清单</h2><p className="mt-1 text-sm text-muted">累计 {total} 条模板</p></Card.Content>{loading ? <LoadingState label="正在加载模板列表" /> : error ? <ErrorState title="公告模板列表加载失败" onRetry={loadPage} /> : <DataTable emptyTitle="暂无公告模板" rows={records} columns={columns} />}{!loading && !error ? <PaginationBar page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(value) => { setPageSize(value); setPage(0); }} /> : null}</Card>
<TemplateImportModal templateTypes={options.templateTypes} fundCategories={options.fundCategories} isOpen={importOpen} onOpenChange={setImportOpen} onComplete={() => setReloadKey((value) => value + 1)} />
<ConfirmModal title="删除公告模板" description={`确定删除“${removeTarget?.title || ''}”吗?模板及其模块记录会一并移除。`} isOpen={removeOpen} busy={busy} onOpenChange={setRemoveOpen} onConfirm={confirmRemove} />
</div>;
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Check, Close, Delete, Eye, OpenExternal, Search } from '@/components/AppIcons';
import { useAuthorization } from '@/hooks/user';
import { Button, Card, Checkbox, Input, Modal } from '@heroui/react';
......@@ -22,7 +23,7 @@ export default function FundProductPage() {
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [reloadKey, setReloadKey] = useState(0);
const [selectedIds, setSelectedIds] = useState([]);
const [selectedProduct, setSelectedProduct] = useState(null);
......@@ -32,7 +33,7 @@ export default function FundProductPage() {
const loadPage = useCallback(async () => {
setLoading(true); setError('');
try { const result = await queryProducts({ page, pageSize, keyword: queryKeyword }); setRecords(result.records); setTotal(result.total); setSelectedIds([]); } catch (requestError) { setError(requestError?.message || '基金产品加载失败'); } finally { setLoading(false); }
}, [page, pageSize, queryKeyword]);
}, [page, pageSize, queryKeyword, setError]);
useEffect(() => { loadPage(); }, [loadPage, reloadKey]);
const exportSelected = async (type) => {
const selected = records.filter((record) => selectedIds.includes(getRecordId(record)));
......@@ -53,5 +54,5 @@ export default function FundProductPage() {
{ key: 'fund_status', label: '基金状态', render: (record) => record.metadata?.fund_status || '-' },
{ key: 'actions', label: '操作', render: (record) => <TableActionGroup><TableActionButton icon={Eye} label="查看详情" onPress={() => setSelectedProduct(record)} /><TableActionButton icon={OpenExternal} label="导出投监表" onPress={() => exportProducts([record], '投监表(有指标代码)')} />{canManage ? <TableActionButton icon={Delete} label="删除产品" tone="danger" onPress={() => { setRemoveTarget(record); setRemoveOpen(true); }} /> : null}</TableActionGroup> },
], [canManage, selectedIds]);
return <div className="rc-page-stack"><PageHeader title="基金产品" description="查看已纳入公告管理的基金产品,并导出投监表或关联方清单。" /><Card className="rc-card-surface rc-filter-surface"><Card.Content className="grid gap-3 p-4 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-end"><div className="min-w-0"><Input aria-label="搜索基金产品" label="基金名称 / 基金代码" placeholder="输入关键词" value={keyword} onChange={(event) => setKeyword(event.target.value)} onKeyDown={(event) => event.key === 'Enter' && (setQueryKeyword(keyword.trim()), setPage(0))} /></div><Button size="sm" variant="primary" onPress={() => { setQueryKeyword(keyword.trim()); setPage(0); }}><Search size={15} />查询</Button><Button size="sm" variant="tertiary" onPress={() => { setKeyword(''); setQueryKeyword(''); setPage(0); }}>重置</Button></Card.Content></Card><Card className="rc-card-surface overflow-hidden"><Card.Content className="flex flex-col gap-3 border-b border-separator p-4 sm:flex-row sm:items-center sm:justify-between"><div><h2 className="rc-module-title">产品清单</h2><p className="mt-1 text-sm text-muted">累计 {total} 条基金产品</p></div><div className="flex flex-wrap gap-2"><Button isDisabled={!selectedIds.length || busy} size="sm" variant="secondary" onPress={() => exportSelected('投监表(有指标代码)')}><OpenExternal size={15} />导出投监表</Button><Button isDisabled={!selectedIds.length || busy} size="sm" variant="secondary" onPress={() => exportSelected('关联方清单')}><OpenExternal size={15} />导出关联方</Button><Button isDisabled={!selectedIds.length || busy} size="sm" variant="danger-soft" onPress={() => { setRemoveTarget(null); setRemoveOpen(true); }}><Delete size={15} />删除</Button></div></Card.Content>{loading ? <LoadingState label="正在加载基金产品" /> : error ? <ErrorState message={error} onRetry={loadPage} /> : <DataTable emptyTitle="暂无基金产品" rows={records} columns={columns} />}{!loading && !error ? <PaginationBar page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(value) => { setPageSize(value); setPage(0); }} /> : null}</Card><ProductDetail product={selectedProduct} isOpen={Boolean(selectedProduct)} onOpenChange={(open) => !open && setSelectedProduct(null)} /><ConfirmModal title="删除基金产品" description={removeTarget ? `确定删除“${removeTarget.title}”吗?` : `确定删除已选择的 ${selectedIds.length} 条基金产品吗?`} isOpen={removeOpen} busy={busy} onOpenChange={setRemoveOpen} onConfirm={confirmRemove} /></div>;
return <div className="rc-page-stack"><PageHeader title="基金产品" description="查看已纳入公告管理的基金产品,并导出投监表或关联方清单。" /><Card className="rc-card-surface rc-filter-surface"><Card.Content className="grid gap-3 p-4 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-end"><div className="min-w-0"><Input aria-label="搜索基金产品" label="基金名称 / 基金代码" placeholder="输入关键词" value={keyword} onChange={(event) => setKeyword(event.target.value)} onKeyDown={(event) => event.key === 'Enter' && (setQueryKeyword(keyword.trim()), setPage(0))} /></div><Button size="sm" variant="primary" onPress={() => { setQueryKeyword(keyword.trim()); setPage(0); }}><Search size={15} />查询</Button><Button size="sm" variant="tertiary" onPress={() => { setKeyword(''); setQueryKeyword(''); setPage(0); }}>重置</Button></Card.Content></Card><Card className="rc-card-surface overflow-hidden"><Card.Content className="flex flex-col gap-3 border-b border-separator p-4 sm:flex-row sm:items-center sm:justify-between"><div><h2 className="rc-module-title">产品清单</h2><p className="mt-1 text-sm text-muted">累计 {total} 条基金产品</p></div><div className="flex flex-wrap gap-2"><Button isDisabled={!selectedIds.length || busy} size="sm" variant="secondary" onPress={() => exportSelected('投监表(有指标代码)')}><OpenExternal size={15} />导出投监表</Button><Button isDisabled={!selectedIds.length || busy} size="sm" variant="secondary" onPress={() => exportSelected('关联方清单')}><OpenExternal size={15} />导出关联方</Button><Button isDisabled={!selectedIds.length || busy} size="sm" variant="danger-soft" onPress={() => { setRemoveTarget(null); setRemoveOpen(true); }}><Delete size={15} />删除</Button></div></Card.Content>{loading ? <LoadingState label="正在加载基金产品" /> : error ? <ErrorState title="基金产品加载失败" onRetry={loadPage} /> : <DataTable emptyTitle="暂无基金产品" rows={records} columns={columns} />}{!loading && !error ? <PaginationBar page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(value) => { setPageSize(value); setPage(0); }} /> : null}</Card><ProductDetail product={selectedProduct} isOpen={Boolean(selectedProduct)} onOpenChange={(open) => !open && setSelectedProduct(null)} /><ConfirmModal title="删除基金产品" description={removeTarget ? `确定删除“${removeTarget.title}”吗?` : `确定删除已选择的 ${selectedIds.length} 条基金产品吗?`} isOpen={removeOpen} busy={busy} onOpenChange={setRemoveOpen} onConfirm={confirmRemove} /></div>;
}
......@@ -29,6 +29,9 @@
## 状态与异常处理
- 列表、详情、导入、导出和删除均展示加载状态;请求失败在操作区域显示可重试错误。
- 列表、详情、导入、导出和删除均展示加载状态;接口成功、失败统一显示视口顶部的 HeroUI Toast,浮层高于导入、详情和删除确认弹窗,页面滚动时仍可看到并手动关闭。
- 成功提示默认 4 秒、错误和警告默认 8 秒后消失;悬停或键盘聚焦暂停计时。关闭按钮使用 HeroUI `Button` 的 `ghost` 小号图标按钮与 `slot="close"`,随正文排列在右侧;前排无需悬停即可操作,后排隐藏关闭按钮。关闭仅影响提示显示,不改变请求结果、表单草稿、文件、选中记录、分页、筛选条件或业务 `metadata`;相同错误再次发生时重新提示。
- 公告、模板、产品列表与公告/模板详情加载失败时保留失败空状态,详细原因由 Toast 展示。列表“重新加载”按当前关键字、页码、条数和筛选条件重新读取;详情“重新加载”读取当前记录,另提供“返回列表”。重试不重新执行导入、删除或导出。
- 筛选选项加载失败时通过 Toast 提示刷新页面重试。导入、删除、导出失败保留当前弹窗和输入,用户修正或重试后再提交。
- 导入缺少文件、基金代码、公告类型或年份时阻止提交;服务端返回的校验和权限错误直接展示。
- 记录不存在或无权访问时显示详情错误并提供返回列表入口。
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon } from '@/components/AppIcons';
import { useUserInfo } from '@/hooks/user';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
......@@ -205,7 +205,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
......@@ -247,7 +247,7 @@ export default function NotificationListPage() {
return () => {
cancelled = true;
};
}, [category, keyword, page, readStatus, reloadKey]);
}, [category, keyword, page, readStatus, reloadKey, setError]);
useEffect(() => {
let cancelled = false;
......@@ -278,12 +278,12 @@ export default function NotificationListPage() {
setBusyKey(key);
try {
await operation();
toast.success(successMessage, { timeout: 3000 });
toast.success(successMessage, { timeout: 4000 });
refresh();
return true;
} catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', {
timeout: 4000,
timeout: 8000,
});
return false;
} finally {
......@@ -333,7 +333,7 @@ export default function NotificationListPage() {
refresh();
} catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', {
timeout: 4000,
timeout: 8000,
});
} finally {
setBusyKey('');
......@@ -418,14 +418,15 @@ export default function NotificationListPage() {
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="ghost" onPress={refresh}>
重试
</Button>
</Alert>
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>通知加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={refresh}>重试</Button>
</EmptyState.Content>
</EmptyState>
) : null}
{loading && notifications.length === 0 ? (
......
......@@ -2,33 +2,42 @@ import {
loadProcessInstanceDetail,
} from '@/pages/process_monitor/processMonitorService';
import ProcessInstanceDetailContent from '@/components/ProcessInstanceDetailContent';
import { Alert } from '@heroui/react';
import RequestErrorState from '@/components/RequestErrorState';
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'umi';
export default function ProcessDetailPage() {
const { application } = useParams();
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useFeedbackState('error');
const [reloadKey, setReloadKey] = useState(0);
const [record, setRecord] = useState(null);
const [fieldsDefinition, setFieldsDefinition] = useState([]);
const [members, setMembers] = useState([]);
const [departments, setDepartments] = useState([]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setErrorMessage('');
loadProcessInstanceDetail(application).then((result) => {
if (cancelled) return;
setRecord(result.record); setFieldsDefinition(result.fieldsDefinition); setMembers(result.members); setDepartments(result.departments); setErrorMessage('');
}).catch((error) => setErrorMessage(error?.message || '加载流程详情失败')).finally(() => setLoading(false));
}, [application]);
}).catch((error) => {
if (!cancelled) setErrorMessage(error?.message || '加载流程详情失败');
}).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [application, reloadKey, setErrorMessage]);
const detailData = useMemo(() => ({ record, fieldsDefinition, members, departments }), [departments, fieldsDefinition, members, record]);
return (
<div className="rc-page-stack">
{errorMessage ? <Alert status="danger"><Alert.Indicator /><Alert.Content><Alert.Title>详情加载失败</Alert.Title><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-list-action-bar"><h2>流程实例信息</h2></div>
<ProcessInstanceDetailContent data={detailData} loading={loading} withSurface />
{!loading && errorMessage ? (
<RequestErrorState title="流程详情加载失败" onRetry={() => setReloadKey((value) => value + 1)} />
) : <ProcessInstanceDetailContent data={detailData} loading={loading} withSurface />}
</div>
);
}
import RequestErrorState from '@/components/RequestErrorState';
import useFeedbackState from '@/hooks/useFeedbackState';
import { useAuthorization } from '@/hooks/user';
import { Check, Close, DataStore, Delete, Edit, Eye, FileText, FilterControls, FolderOpen, PlusIcon, Search, Team } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Alert, AlertDialog, Button, Checkbox, Input, Label, Modal, Pagination, Popover, SearchField, Surface, Tabs, Tooltip, toast } from '@heroui/react';
import { AlertDialog, Button, Checkbox, Input, Label, Modal, Pagination, Popover, SearchField, Surface, Tabs, Tooltip, toast } from '@heroui/react';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
......@@ -92,7 +94,9 @@ export default function ProcessMonitorPage() {
const [departmentFilter, setDepartmentFilter] = useState('');
const [listPage, setListPage] = useState(0);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const [errorMessage, setErrorMessage] = useState('');
const [, setErrorMessage] = useFeedbackState('error');
const [, setSaveSuccess] = useFeedbackState('success');
const [loadFailed, setLoadFailed] = useState(false);
const [createGroupOpen, setCreateGroupOpen] = useState(false);
const [createGroupTitle, setCreateGroupTitle] = useState('');
const [addTemplateOpen, setAddTemplateOpen] = useState(false);
......@@ -107,6 +111,8 @@ export default function ProcessMonitorPage() {
const loadPage = async (preferredGroupName) => {
setLoading(true);
setLoadFailed(false);
setErrorMessage('');
try {
const result = await loadProcessMonitorOverview();
setGroups(result.groups);
......@@ -116,7 +122,7 @@ export default function ProcessMonitorPage() {
const nextActiveGroup = result.groups.find((group) => group.name === preferredGroupName) || result.groups.find((group) => group.name === activeGroupName) || result.groups[0] || null;
setActiveGroupName(nextActiveGroup?.name || '');
setErrorMessage('');
} catch (error) { setErrorMessage(error?.message || '加载流程监控数据失败'); }
} catch (error) { setLoadFailed(true); setErrorMessage(error?.message || '加载流程监控数据失败'); }
finally { setLoading(false); }
};
......@@ -139,7 +145,7 @@ export default function ProcessMonitorPage() {
const title = createGroupTitle.trim();
const record = await createProcessGroup(title);
setCreateGroupOpen(false); setCreateGroupTitle(''); await loadPage(record?.name);
toast.success(`已创建分组“${record?.title || title}”`);
toast.success(`已创建分组“${record?.title || title}”`, { timeout: 4000 });
} catch (error) { setErrorMessage(error?.message || '创建分组失败'); }
finally { setSubmitting(false); }
};
......@@ -152,12 +158,14 @@ export default function ProcessMonitorPage() {
try {
await addTemplatesToGroup(activeGroup, selectedTemplates);
setAddTemplateOpen(false); setTemplatePickerKeyword(''); setSelectedTemplateIds([]); await loadPage(activeGroup.name);
toast.success(`已添加 ${selectedTemplates.length} 个流程监控模板`);
toast.success(`已添加 ${selectedTemplates.length} 个流程监控模板`, { timeout: 4000 });
} catch (error) { setErrorMessage(error?.message || '添加流程监控失败'); }
finally { setSubmitting(false); }
};
const openEditDialog = (template) => {
setErrorMessage('');
setSaveSuccess('');
setEditingTemplate(template);
setTemplateForm({ title: template?.title || '', department: template?.metadata?.department || '', templateCode: template?.metadata?.template_code || '', conditionKey: template?.metadata?.conditions?.key || '', conditionValue: template?.metadata?.conditions?.value || '' });
setEditTemplateOpen(true);
......@@ -165,9 +173,17 @@ export default function ProcessMonitorPage() {
const handleUpdateTemplate = async () => {
if (!editingTemplate?._id) return;
setErrorMessage('');
setSaveSuccess('');
if (!templateForm.title.trim()) { setErrorMessage('模板标题不能为空'); return; }
setSubmitting(true);
try { await updateMonitoredTemplate(editingTemplate._id, templateForm); setEditTemplateOpen(false); setEditingTemplate(null); await loadPage(activeGroup?.name); toast.success('模板配置已更新'); }
try {
await updateMonitoredTemplate(editingTemplate._id, templateForm);
setEditTemplateOpen(false);
setEditingTemplate(null);
setSaveSuccess('模板配置已更新');
await loadPage(activeGroup?.name);
}
catch (error) { setErrorMessage(error?.message || '模板更新失败'); }
finally { setSubmitting(false); }
};
......@@ -175,7 +191,7 @@ export default function ProcessMonitorPage() {
const handleRemoveTemplate = async () => {
if (!activeGroup || !removeTarget) return;
setSubmitting(true);
try { const latestGroup = await loadProcessGroup(activeGroup.name); await removeMonitoredTemplate(latestGroup, removeTarget); setRemoveTarget(null); await loadPage(activeGroup.name); toast.success('已取消该流程模板监控'); }
try { const latestGroup = await loadProcessGroup(activeGroup.name); await removeMonitoredTemplate(latestGroup, removeTarget); setRemoveTarget(null); await loadPage(activeGroup.name); toast.success('已取消该流程模板监控', { timeout: 4000 }); }
catch (error) { setErrorMessage(error?.message || '取消监控失败'); }
finally { setSubmitting(false); }
};
......@@ -189,7 +205,7 @@ export default function ProcessMonitorPage() {
await removeProcessGroup(latestGroup);
setRemoveGroupTarget(null);
await loadPage(nextGroup?.name);
toast.success(`已删除分组“${removeGroupTarget.title}”`);
toast.success(`已删除分组“${removeGroupTarget.title}”`, { timeout: 4000 });
} catch (error) { setErrorMessage(error?.message || '删除分组失败'); }
finally { setSubmitting(false); }
};
......@@ -208,8 +224,6 @@ export default function ProcessMonitorPage() {
return (
<div className="rc-page-stack">
{errorMessage ? <Alert status="danger"><Alert.Indicator /><Alert.Content><Alert.Title>操作未完成</Alert.Title><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-kpi-grid" data-count="4">
<Metric label="监控分组" value={groups.length} hint="按业务视角组织模板监控范围" icon={Team} tone="info" />
<Metric label="当前分组模板" value={activeGroup?.monitoredCount || 0} hint="当前选中分组的监控模板数量" icon={FileText} tone="success" />
......@@ -239,10 +253,14 @@ export default function ProcessMonitorPage() {
</div>
</div>
{!loading && loadFailed ? (
<RequestErrorState title="流程监控数据加载失败" onRetry={() => loadPage(activeGroupName)} />
) : (
<div className="rc-list-data-stack">
<section className="rc-list-data-grid rc-data-table-sticky"><DataGrid allowsColumnResize aria-label="流程监控模板列表" className="pm-data-grid" columns={listColumns} contentClassName="min-w-[1074px]" data={loading ? [] : pagedTemplates} getRowId={(template) => template._id || template.name} renderEmptyState={() => loading ? <div className="py-8 text-sm text-muted">正在加载流程监控列表...</div> : <DataEmptyState title={activeGroup ? '当前分组还没有监控模板' : '暂无可展示模板'} action={canManage && activeGroup ? <Button size="sm" variant="secondary" onPress={() => setAddTemplateOpen(true)}>添加监控模板</Button> : null} />} scrollContainerClassName="data-table-scroll" verticalAlign="middle" /></section>
{!loading ? <div className="pm-pagination-bar"><PagePagination page={listPage} pageSize={pageSize} total={filteredTemplates.length} onPageChange={setListPage} onPageSizeChange={(nextPageSize) => { if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) setPageSize(nextPageSize); }} /></div> : null}
</div>
)}
</section>
<FormModal isOpen={createGroupOpen} onOpenChange={setCreateGroupOpen} title="新建流程监控分组" footer={<><Button isDisabled={submitting} size="sm" variant="secondary" onPress={() => setCreateGroupOpen(false)}>取消</Button><Button isPending={submitting} size="sm" variant="primary" onPress={handleCreateGroup}>创建分组</Button></>}><div className="grid gap-1.5"><Label>分组名称</Label><Input fullWidth variant="secondary" value={createGroupTitle} onChange={(event) => setCreateGroupTitle(event.target.value)} placeholder="例如:投资业务流程" /></div></FormModal>
......
import RequestErrorState from '@/components/RequestErrorState';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Close, DataStore, Delete, Eye, FieldTime, FileText, Filter, FolderOpen, Search } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import { TitleInfo } from '@/components/XinyuanUi';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Alert, Button, Input, Label, Modal, Pagination, SearchField, Tooltip, toast } from '@heroui/react';
import { Button, Input, Label, Modal, Pagination, SearchField, Tooltip, toast } from '@heroui/react';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
......@@ -97,9 +99,17 @@ export default function ProcessTemplateListPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const tabName = searchParams.get('tab') || '';
const [loading, setLoading] = useState(true);
const [metadataLoading, setMetadataLoading] = useState(true);
const [instancesLoading, setInstancesLoading] = useState(true);
const [metadataFailed, setMetadataFailed] = useState(false);
const [instancesFailed, setInstancesFailed] = useState(false);
const [readyProcessId, setReadyProcessId] = useState('');
const [reloadKey, setReloadKey] = useState(0);
const loading = metadataLoading || instancesLoading;
const loadFailed = metadataFailed || instancesFailed;
const [downloading, setDownloading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [, setErrorMessage] = useFeedbackState('error');
const [, setSaveSuccess] = useFeedbackState('success');
const [template, setTemplate] = useState(null);
const [fieldsDefinition, setFieldsDefinition] = useState([]);
const [members, setMembers] = useState([]);
......@@ -114,20 +124,55 @@ export default function ProcessTemplateListPage() {
const [fieldDrafts, setFieldDrafts] = useState([]);
const [fieldSaving, setFieldSaving] = useState(false);
const loadTemplateMeta = async () => {
const result = await loadTemplateMonitorPage(processId);
setTemplate(result.template); setFieldsDefinition(result.fieldsDefinition); setMembers(result.members); setDepartments(result.departments); setConditions(result.conditions);
};
const loadInstances = async () => {
const result = await queryProcessInstances({ processId, page, pageSize, keyword, conditions });
setInstances(result.records); setTotal(result.total);
};
useEffect(() => {
let cancelled = false;
setMetadataLoading(true);
setInstancesLoading(true);
setMetadataFailed(false);
setInstancesFailed(false);
setReadyProcessId('');
setErrorMessage('');
setSaveSuccess('');
loadTemplateMonitorPage(processId).then((result) => {
if (cancelled) return;
setTemplate(result.template);
setFieldsDefinition(result.fieldsDefinition);
setMembers(result.members);
setDepartments(result.departments);
setConditions(result.conditions);
setReadyProcessId(processId);
}).catch((error) => {
if (cancelled) return;
setMetadataFailed(true);
setInstancesLoading(false);
setErrorMessage(error?.message || '加载流程模板失败');
}).finally(() => {
if (!cancelled) setMetadataLoading(false);
});
return () => { cancelled = true; };
}, [processId, reloadKey, setErrorMessage, setSaveSuccess]);
useEffect(() => {
setLoading(true);
Promise.all([loadTemplateMeta(), loadInstances()]).then(() => setErrorMessage('')).catch((error) => setErrorMessage(error?.message || '加载流程实例失败')).finally(() => setLoading(false));
}, [processId]);
useEffect(() => { loadInstances().catch((error) => setErrorMessage(error?.message || '查询流程实例失败')); }, [page, pageSize, keyword, conditions]);
if (readyProcessId !== processId) return;
let cancelled = false;
setInstancesLoading(true);
setInstancesFailed(false);
setErrorMessage('');
queryProcessInstances({ processId, page, pageSize, keyword, conditions }).then((result) => {
if (cancelled) return;
setInstances(result.records);
setTotal(result.total);
}).catch((error) => {
if (cancelled) return;
setInstances([]);
setTotal(0);
setInstancesFailed(true);
setErrorMessage(error?.message || '查询流程实例失败');
}).finally(() => {
if (!cancelled) setInstancesLoading(false);
});
return () => { cancelled = true; };
}, [readyProcessId, processId, page, pageSize, keyword, conditions, setErrorMessage]);
const lookups = useMemo(() => ({ memberLookup: buildMemberLookup(members), departmentLookup: buildDepartmentLookup(departments) }), [departments, members]);
const totalPages = Math.max(Math.ceil(total / pageSize), 1);
......@@ -142,6 +187,8 @@ export default function ProcessTemplateListPage() {
], [fieldsDefinition, lookups, navigate, processId, tabName]);
const openFieldDialog = () => {
setErrorMessage('');
setSaveSuccess('');
const normalizedFields = normalizeDisplayFieldDefinitions(fieldsDefinition);
setFieldDrafts((normalizedFields.length ? normalizedFields : [{}]).map(createFieldDraft));
setFieldDialogOpen(true);
......@@ -149,13 +196,20 @@ export default function ProcessTemplateListPage() {
const saveFieldDefinitions = async () => {
const normalizedFields = fieldDrafts.map((field) => ({ display_name: field.display_name?.trim() || '', type: field.type || 'text' })).filter((field) => field.display_name);
setFieldSaving(true);
try { await updateTemplateDisplayFields(processId, normalizedFields); setFieldsDefinition(normalizedFields); setFieldDialogOpen(false); toast.success('显示字段配置已更新'); }
setErrorMessage('');
setSaveSuccess('');
try {
await updateTemplateDisplayFields(processId, normalizedFields);
setFieldsDefinition(normalizedFields);
setFieldDialogOpen(false);
setSaveSuccess('显示字段配置已更新');
}
catch (error) { setErrorMessage(error?.message || '显示字段保存失败'); }
finally { setFieldSaving(false); }
};
const handleDownloadAll = async () => {
setDownloading(true);
try { const count = await downloadAllProcessAttachments(processId); count > 0 ? toast.success(`已触发 ${count} 个附件下载`) : toast.info('当前模板下没有可下载附件'); }
try { const count = await downloadAllProcessAttachments(processId); count > 0 ? toast.success(`已触发 ${count} 个附件下载`, { timeout: 4000 }) : toast.info('当前模板下没有可下载附件', { timeout: 4000 }); }
catch (error) { setErrorMessage(error?.message || '批量下载失败'); }
finally { setDownloading(false); }
};
......@@ -163,17 +217,20 @@ export default function ProcessTemplateListPage() {
return (
<div className="rc-page-stack">
<div className="rc-detail-toolbar flex min-w-0 flex-wrap items-center justify-end gap-x-4 gap-y-3"><div className="rc-page-command-bar ml-auto flex shrink-0 justify-end"><div className="flex flex-nowrap gap-2"><Button isDisabled={loading} isPending={downloading} size="sm" variant="secondary" onPress={handleDownloadAll}>批量下载附件</Button><Button isDisabled={loading} size="sm" variant="primary" onPress={openFieldDialog}>定义显示字段</Button></div></div></div>
{errorMessage ? <Alert status="danger"><Alert.Indicator /><Alert.Content><Alert.Title>操作未完成</Alert.Title><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-kpi-grid" data-count="4"><Metric label="模板实例" value={total} hint="当前模板下命中的提交流程数量" icon={DataStore} tone="info" /><Metric label="显示字段" value={fieldsDefinition.length} hint="由模板配置决定表格展开的动态列" icon={FileText} tone="success" /><Metric label="条件筛选" value={conditions?.key ? '已启用' : '未启用'} hint={conditions?.key ? `${conditions.key} = ${conditions.value || '-'}` : '当前没有预设显示条件'} icon={Filter} tone="warning" /><Metric label="分页设置" value={pageSize} hint={`当前第 ${page} / ${totalPages} 页`} icon={FieldTime} tone="teal" /></div>
<section className="rc-list-stack">
<div className="rc-list-action-bar"><div className="flex items-center gap-1.5"><h2>实例列表</h2><TitleInfo label="实例列表" content="按模板展示实际提交的流程实例,可通过操作列查看单条详情。" /></div></div>
<div className="flex flex-wrap items-center justify-between gap-3"><SearchField aria-label="搜索表单名称" className="w-full sm:w-96" value={keyword} onChange={(value) => { setPage(1); setKeyword(value); }}><SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder="搜索表单名称" /><SearchField.ClearButton aria-label="清空关键词"><Close size={14} /></SearchField.ClearButton></SearchField.Group></SearchField></div>
{!loading && loadFailed ? (
<RequestErrorState title="流程实例列表加载失败" onRetry={() => setReloadKey((value) => value + 1)} />
) : (
<div className="rc-list-data-stack">
<section className="rc-list-data-grid rc-data-table-sticky"><DataGrid allowsColumnResize aria-label="流程实例列表" className="pm-data-grid" columns={instanceColumns} contentClassName="min-w-[760px]" data={loading ? [] : instances} getRowId={(record) => record._id || record.name} renderEmptyState={() => loading ? <div className="py-8 text-sm text-muted">正在加载模板实例...</div> : <DataEmptyState />} scrollContainerClassName="data-table-scroll" verticalAlign="middle" /></section>
{!loading ? <div className="pm-pagination-bar"><PagePagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(nextPageSize) => { if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) { setPageSize(nextPageSize); setPage(1); } }} /></div> : null}
</div>
)}
</section>
<Modal.Root><Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /><Modal.Backdrop isOpen={fieldDialogOpen} onOpenChange={setFieldDialogOpen} variant="blur"><Modal.Container placement="center" scroll="inside" size="lg"><Modal.Dialog aria-label="定义显示字段" className="p-7 sm:max-w-[68rem]"><Modal.CloseTrigger aria-label="关闭"><Close size={16} /></Modal.CloseTrigger><Modal.Header><div className="flex items-center gap-1.5"><Modal.Heading>定义显示字段</Modal.Heading><TitleInfo label="定义显示字段" content="为当前监控模板指定实例表格需要展示的字段列。" /></div></Modal.Header><Modal.Body>
......
......@@ -122,6 +122,7 @@ Shell 中的应用切换入口继续读取当前登录用户可访问应用,
- 支持批量触发附件下载
- 支持维护实例列表的显示字段定义
- 打开“定义显示字段”时,已有字段按配置回显;无字段配置时默认展示一行空字段,字段名和字段类型分别使用“请输入字段名”“请选择”提示,不额外展示重复的字段表头
- “保存字段”通过 `PUT /record/:name` 更新当前 `audit_related_progress.metadata.fields_definition`;请求期间按钮显示加载状态,成功后更新列表列并关闭弹窗,视口顶部显示“显示字段配置已更新”。失败时保留弹窗与字段草稿并显示错误;重新打开或再次保存前清理上次保存提示。
- 页面导航统一使用顶部 Navbar 页面标题,页面内容区不再重复渲染返回入口或面包屑;批量下载附件与定义显示字段保持同行并右对齐,窄屏空间不足时操作组整体换到下一行,两个操作按钮不拆行
- 实例列表的标题、搜索工具栏、DataGrid 和 Pagination 作为同一列表板块组织,标题与工具栏、工具栏与 DataGrid、DataGrid 与 Pagination 均采用 16px 关联间距
......@@ -185,6 +186,16 @@ Shell 中的应用切换入口继续读取当前登录用户可访问应用,
- `src/pages/process_monitor/ProcessDetail.jsx`
- `src/pages/process_monitor/processMonitorService.js`
### 操作提示与失败重试
- 功能入口:登录与验证码刷新、站点配置、应用切换、流程监控总览、模板实例列表、实例详情、显示字段配置、批量附件下载和通知中心。
- 查询、创建、修改、取消监控、删除、下载及通知已读/待办操作的成功与失败统一显示根部 HeroUI Toast;站点配置与应用列表回退显示 warning。提示位于视口顶部,层级高于 Modal、确认框和移动侧栏,弹窗打开或页面滚动时仍可看到并关闭,出现时不抢走焦点。
- 成功提示默认 4 秒、错误和警告默认 8 秒后消失;悬停或键盘聚焦暂停计时。关闭按钮使用 HeroUI `Button` 的 `ghost` 小号图标按钮与 `slot="close"`,随正文排列在右侧,前排始终可操作,后排隐藏关闭按钮。同一来源的新错误替换旧错误,相同错误重试后仍会重新弹出;清空反馈或离开对应页面会清理该来源提示,关闭应用切换弹窗清理其降级提示。
- 在总览操作列打开“编辑模板监控配置”,保存通过 `PUT /record/:name` 更新 `audit_related_progress` 的标题及 `metadata.department`、`metadata.template_code`、`metadata.conditions`。请求期间显示加载状态,写入成功后关闭弹窗并立即显示“模板配置已更新”,随后刷新当前分组;刷新失败另显示读取错误,已完成的保存成功提示仍保留。写入失败保留弹窗和草稿,不显示成功;重新打开或再次保存前清理上次保存提示。成功提示属于页面而非弹窗,关闭弹窗后仍可见。
- 关闭提示仅改变展示,不把失败改为成功,不清空分组名称、模板配置、显示字段草稿或选择项,不写入 `metadata`,也不重发创建、修改、删除、下载或待办请求。
- 总览、实例列表与详情加载失败时展示失败空状态及“重新加载”,不会显示成“暂无数据”。总览重读当前分组资源;实例列表保留当前关键字、页码、每页条数和模板 `metadata.conditions`,先读取模板及人员/部门映射,再查询实例,避免并发请求覆盖失败状态;详情重读当前实例。写入失败仅弹出提示,保留弹窗和草稿供再次提交。
- 通知加载失败保留“重试”,继续使用当前消息/待办分类、全部/未读状态、搜索关键词和页码,只重新读取数据;关闭提示不清除失败状态或触发已读/待办操作。删除确认说明和表单必填校验继续保留在对应交互中。
## 9. 消息通知流程
### 9.1 入口与范围
......
......@@ -4,6 +4,7 @@
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
@import "./design-system/feedback.css";
@source "./*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}";
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService';
import { Spinner } from '@heroui/react';
import dayjs from 'dayjs';
......@@ -19,7 +20,7 @@ export default function SiteWrapper() {
const [siteInfo, setSiteInfo] = useState(null);
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [loadError, setLoadError] = useFeedbackState('warning');
useEffect(() => {
let cancelled = false;
......@@ -55,7 +56,7 @@ export default function SiteWrapper() {
return () => {
cancelled = true;
};
}, []);
}, [setLoadError]);
if (loading) {
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