Commit 3e6db798 authored by drigle's avatar drigle

feat: 统一操作反馈与失败重试并优化拓扑节点详情

parent ba3d23a8
...@@ -53,3 +53,9 @@ AppShell 采用低饱和深藏蓝侧栏与中性灰白内容画布。`theme.css` ...@@ -53,3 +53,9 @@ AppShell 采用低饱和深藏蓝侧栏与中性灰白内容画布。`theme.css`
- Reference 写入仍只保存 ID 或现有页面协议对象;枚举来自 `settings.options`。 - Reference 写入仍只保存 ID 或现有页面协议对象;枚举来自 `settings.options`。
- DatePicker 保持既有 service 转 ISO 流程;动态 Subject 继续使用现有 `loadSubject`/subject definition。 - DatePicker 保持既有 service 转 ISO 流程;动态 Subject 继续使用现有 `loadSubject`/subject definition。
- UI 迁移不得改变 API、路由、权限、校验、保存回调、请求时机、分页口径或审批状态。 - UI 迁移不得改变 API、路由、权限、校验、保存回调、请求时机、分页口径或审批状态。
## 操作反馈浮层
- 全应用统一由根部 `AppFeedback` 挂载 HeroUI `Toast.Provider` 和官方 compound 组件,登录和 AppShell 共用一个 Provider;接口成功/失败/降级警告不再渲染页面横条。
- 成功默认 4 秒、错误和警告默认 8 秒后消失;关闭只改变提示展示。详细加载错误通过 Toast 展示,失败空状态使用 HeroUI Pro EmptyState,并提供读取重试。
- 操作提示沿用问题整改 `bfc5862`:关闭按钮直接组合 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`),通过官方 `slot="close"` 接入关闭行为,使用 16px `AppIcons.Close`,位于正文右侧并参与正常布局。当前提示关闭图标始终可见,后排堆叠提示隐藏关闭按钮,避免误关闭;移除旧关闭按钮的边框、绝对定位和正文右侧占位。`src/design-system/feedback.css` 只保留层级 300、安全区顶部和后排关闭按钮可见性约定;颜色、圆角、阴影、Hover 与焦点样式使用 HeroUI 默认主题。
{ {
"name": "internal_regulation_manager_new", "name": "internal_regulation_manager_new",
"version": "1.0.2", "version": "1.0.4",
"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';
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
...@@ -15,4 +17,8 @@ export async function getInitialState() { ...@@ -15,4 +17,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>
</>
);
}
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi'; import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar'; import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar'; import DashboardSidebar from './DashboardSidebar';
...@@ -34,7 +33,6 @@ export default function AppShell({ ...@@ -34,7 +33,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>
); );
} }
import { Button } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
export default function RequestFailure({ title = '加载失败', onRetry }) {
return (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>{title}</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
{onRetry ? (
<EmptyState.Content>
<Button variant="secondary" onPress={onRetry}>重新加载</Button>
</EmptyState.Content>
) : null}
</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 },
};
// Dismissing feedback must not clear the request's failure state or the form draft.
export default function useFeedbackState(tone = 'error') {
const [message, setMessage] = useState('');
const toastKeyRef = useRef(null);
const isMountedRef = useRef(true);
const updateMessage = useCallback((nextMessage) => {
if (!isMountedRef.current) return;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
setMessage(nextMessage);
if (!nextMessage) return;
const { title, variant, timeout } = FEEDBACK_OPTIONS[tone] || FEEDBACK_OPTIONS.error;
// Every attempt reports feedback, including identical errors on a retry.
toastKeyRef.current = 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 xinyuanLogo from '@/assets/xy-logo-red.png'; import xinyuanLogo from '@/assets/xy-logo-red.png';
import xinyuanLogoWhite from '@/assets/xy-logo-white.png'; import xinyuanLogoWhite from '@/assets/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') || '/';
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
...@@ -171,14 +172,6 @@ export default function LoginPage() { ...@@ -171,14 +172,6 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{error ? (
<Alert className="login-error" status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button <Button
className="login-submit-button" className="login-submit-button"
fullWidth fullWidth
......
import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Input, Label, Spinner, TextField } from '@heroui/react'; import { Button, Input, Label, Spinner, TextField } from '@heroui/react';
import { Download, Search } from '@/components/AppIcons'; import { Download, Search } from '@/components/AppIcons';
import ListPagination from '@/components/ListPagination'; import ListPagination from '@/components/ListPagination';
...@@ -18,16 +20,29 @@ export default function ArchiveList() { ...@@ -18,16 +20,29 @@ export default function ArchiveList() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [, setOptionsError] = useFeedbackState('warning');
const refresh = async () => { const refresh = async () => {
try { try {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
const result = await loadArchivedRegulations({ archive, keyword, page, pageSize }); const result = await loadArchivedRegulations({ archive, keyword, page, pageSize });
setRecords(result.records); setTotal(result.total); setTotalPages(result.totalPages); setError(''); setRecords(result.records); setTotal(result.total); setTotalPages(result.totalPages); setError('');
} catch (loadError) { setError(loadError?.message || '归档历史加载失败。'); } finally { setLoading(false); } } catch (loadError) { setLoadFailed(true); setError(loadError?.message || '归档历史加载失败。'); } finally { setLoading(false); }
}; };
useEffect(() => { loadArchiveOptions().then((values) => setArchiveOptions(values.map((value) => ({ value, label: value })))).catch(() => setArchiveOptions([])); }, []); const refreshOptions = async () => {
try {
const values = await loadArchiveOptions();
setArchiveOptions(values.map((value) => ({ value, label: value })));
setOptionsError('');
} catch (requestError) {
setOptionsError(requestError?.message || '归档批次列表加载失败。');
}
};
useEffect(() => { refreshOptions(); }, []);
const changePageSize = (nextPageSize) => { setPageSize(nextPageSize); setPage(0); }; const changePageSize = (nextPageSize) => { setPageSize(nextPageSize); setPage(0); };
useEffect(() => { refresh(); }, [archive, keyword, page, pageSize]); useEffect(() => { refresh(); }, [archive, keyword, page, pageSize]);
...@@ -53,5 +68,5 @@ export default function ArchiveList() { ...@@ -53,5 +68,5 @@ export default function ArchiveList() {
} catch (exportError) { setError(exportError?.message || '导出失败。'); } } catch (exportError) { setError(exportError?.message || '导出失败。'); }
}; };
return <div className="space-y-4"><div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between"><div className="flex flex-col gap-3 sm:flex-row sm:items-end"><XinyuanAutocomplete className="w-full sm:w-56" label="归档批次" options={[{ value: '', label: '全部归档' }, ...archiveOptions]} selectedKey={archive} onSelectionChange={(value) => { setArchive(String(value || '')); setPage(0); }} /><div className="flex w-full items-end gap-2 sm:w-72"><TextField className="min-w-0 flex-1" fullWidth value={searchText} onChange={setSearchText} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitSearch(); } }}><Label className="sr-only">搜索归档制度</Label><div className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted" size={16} /><Input className="pl-9" fullWidth placeholder="搜索制度名称" variant="secondary" /></div></TextField><Button isDisabled={loading} variant="secondary" onPress={submitSearch}>查询</Button></div></div><div className="flex items-center gap-3"><span className="text-sm text-muted">归档记录只读展示,不会修改现行制度</span><Button variant="tertiary" onPress={exportList}><Download size={16} />批量下载</Button></div></div>{error ? <div className="rounded-xl border border-danger/30 bg-danger/5 p-3 text-sm text-danger">{error}</div> : null}{loading ? <div className="flex min-h-48 items-center justify-center"><Spinner label="正在加载归档记录..." /></div> : records.length === 0 ? <div className="rounded-2xl border border-dashed border-separator p-10 text-center text-sm text-muted">暂无归档记录</div> : <div className="overflow-x-auto rounded-2xl border border-separator"><table className="w-full min-w-[900px] text-left text-sm"><thead className="bg-surface-secondary text-muted"><tr>{['制度名称', '时效性', '效力位阶', '审批主体', '制定部门', '发文日期', '归档批次', '操作'].map((title) => <th className="px-4 py-3 font-medium" key={title}>{title}</th>)}</tr></thead><tbody className="divide-y divide-separator">{records.map((record) => { const metadata = record.metadata || {}; return <tr key={record._id || record.name}><td className="px-4 py-3"><button className="font-medium text-accent hover:underline" onClick={() => history.push(`/internal_regulation/${metadata.origin_regulation_id || record.name}`)}>{metadata.regulation_name || record.title || '-'}</button></td><td className="px-4 py-3">{metadata.effective_status || '-'}</td><td className="px-4 py-3">{metadata.level || '-'}</td><td className="max-w-48 truncate px-4 py-3">{Array.isArray(metadata.approve) ? metadata.approve.join('、') : metadata.approve || '-'}</td><td className="max-w-48 truncate px-4 py-3">{Array.isArray(metadata.departments) ? metadata.departments.join('、') : metadata.departments || '-'}</td><td className="px-4 py-3">{metadata.public_date ? new Date(metadata.public_date).toLocaleDateString('zh-CN') : '-'}</td><td className="px-4 py-3">{metadata.archive || '-'}</td><td className="px-4 py-3"><Button size="sm" variant="tertiary" onPress={() => history.push(`/internal_regulation/${metadata.origin_regulation_id || record.name}`)}>查看详情</Button></td></tr>; })}</tbody></table></div>}<ListPagination isDisabled={loading} page={page} pageSize={pageSize} total={total} totalPages={totalPages} onPageChange={setPage} onPageSizeChange={changePageSize}></ListPagination></div>; return <div className="space-y-4"><div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between"><div className="flex flex-col gap-3 sm:flex-row sm:items-end"><XinyuanAutocomplete className="w-full sm:w-56" label="归档批次" options={[{ value: '', label: '全部归档' }, ...archiveOptions]} selectedKey={archive} onSelectionChange={(value) => { setArchive(String(value || '')); setPage(0); }} /><div className="flex w-full items-end gap-2 sm:w-72"><TextField className="min-w-0 flex-1" fullWidth value={searchText} onChange={setSearchText} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitSearch(); } }}><Label className="sr-only">搜索归档制度</Label><div className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted" size={16} /><Input className="pl-9" fullWidth placeholder="搜索制度名称" variant="secondary" /></div></TextField><Button isDisabled={loading} variant="secondary" onPress={submitSearch}>查询</Button></div></div><div className="flex items-center gap-3"><span className="text-sm text-muted">归档记录只读展示,不会修改现行制度</span><Button variant="tertiary" onPress={exportList}><Download size={16} />批量下载</Button></div></div>{loading ? <div className="flex min-h-48 items-center justify-center"><Spinner label="正在加载归档记录..." /></div> : loadFailed ? <RequestFailure title="归档历史加载失败" onRetry={refresh} /> : records.length === 0 ? <div className="rounded-2xl border border-dashed border-separator p-10 text-center text-sm text-muted">暂无归档记录</div> : <div className="overflow-x-auto rounded-2xl border border-separator"><table className="w-full min-w-[900px] text-left text-sm"><thead className="bg-surface-secondary text-muted"><tr>{['制度名称', '时效性', '效力位阶', '审批主体', '制定部门', '发文日期', '归档批次', '操作'].map((title) => <th className="px-4 py-3 font-medium" key={title}>{title}</th>)}</tr></thead><tbody className="divide-y divide-separator">{records.map((record) => { const metadata = record.metadata || {}; return <tr key={record._id || record.name}><td className="px-4 py-3"><button className="font-medium text-accent hover:underline" onClick={() => history.push(`/internal_regulation/${metadata.origin_regulation_id || record.name}`)}>{metadata.regulation_name || record.title || '-'}</button></td><td className="px-4 py-3">{metadata.effective_status || '-'}</td><td className="px-4 py-3">{metadata.level || '-'}</td><td className="max-w-48 truncate px-4 py-3">{Array.isArray(metadata.approve) ? metadata.approve.join('、') : metadata.approve || '-'}</td><td className="max-w-48 truncate px-4 py-3">{Array.isArray(metadata.departments) ? metadata.departments.join('、') : metadata.departments || '-'}</td><td className="px-4 py-3">{metadata.public_date ? new Date(metadata.public_date).toLocaleDateString('zh-CN') : '-'}</td><td className="px-4 py-3">{metadata.archive || '-'}</td><td className="px-4 py-3"><Button size="sm" variant="tertiary" onPress={() => history.push(`/internal_regulation/${metadata.origin_regulation_id || record.name}`)}>查看详情</Button></td></tr>; })}</tbody></table></div>}<ListPagination isDisabled={loading || loadFailed} page={page} pageSize={pageSize} total={total} totalPages={totalPages} onPageChange={setPage} onPageSizeChange={changePageSize}></ListPagination></div>;
} }
import { Button, Chip, Input, Label, Tabs, TextArea, TextField } from '@heroui/react';
import { ChevronDown, ChevronRight, Close, Delete, OpenExternal, PlusIcon, Reload } from '@/components/AppIcons';
function NodePath({ items, emptyText }) {
if (!items.length) return <p className="py-3 text-sm text-muted">{emptyText}</p>;
return (
<ol className="space-y-2">
{items.map((item, index) => (
<li className="flex items-start gap-2 text-sm leading-6" key={item.id}>
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-surface-secondary text-xs text-muted">{index + 1}</span>
<span className="min-w-0 break-words">{item.title}</span>
</li>
))}
</ol>
);
}
export default function GraphNodeDetails({
node,
typeLabel,
status,
isCollapsed,
isArchived,
isSaving,
hasDescendants,
onClearSelection,
onFieldChange,
onToggleCollapsed,
onAddNode,
onDeleteNode,
onRestoreNode,
onOpenRegulation,
}) {
const ancestors = node.ancestors || [];
const historyRegulations = node.historyRegulation || [];
const relatedId = node.relatedRegulation || node.relatedReviseRegulation;
const isMutationDisabled = isArchived || isSaving;
return (
<div className="flex min-h-0 flex-1 flex-col bg-surface">
<header className="flex shrink-0 items-start gap-3 border-b border-separator p-4">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-xs text-muted">{typeLabel}</span>
<Chip aria-label={`节点状态:${status.label}`} color={status.color} size="sm" variant="soft"><Chip.Label>{status.label}</Chip.Label></Chip>
{node.removed ? <Chip color="danger" size="sm" variant="soft"><Chip.Label>已删除</Chip.Label></Chip> : null}
</div>
<h2 className="break-words text-base font-semibold leading-6">{node.title}</h2>
</div>
<Button isIconOnly aria-label="清除选择" className="shrink-0 rounded-lg" size="sm" variant="ghost" onPress={onClearSelection}><Close size={16} /></Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4" aria-label="节点详情内容">
<div className="space-y-5">
<section aria-label="基本信息" className="space-y-3">
<h3 className="text-sm font-semibold">基本信息</h3>
<TextField fullWidth isReadOnly={isArchived} isDisabled={isSaving} value={node.title} onChange={(value) => onFieldChange('title', value)}>
<Label className="text-sm text-muted">标题</Label>
<Input className="min-h-10 rounded-lg" fullWidth variant="secondary" />
</TextField>
<TextField fullWidth isReadOnly={isArchived} isDisabled={isSaving} value={node.content || ''} onChange={(value) => onFieldChange('content', value)}>
<Label className="text-sm text-muted">备注</Label>
<TextArea className="min-h-20 max-h-48 resize-y rounded-lg" fullWidth placeholder="补充节点说明" rows={3} variant="secondary" />
</TextField>
</section>
<section aria-label="节点操作" className="space-y-3 border-t border-separator pt-4">
<h3 className="text-sm font-semibold">节点操作</h3>
<div className="grid grid-cols-2 gap-2">
<Button className="min-h-10 min-w-0 gap-1.5 rounded-lg px-2 text-sm" fullWidth variant="tertiary" onPress={onToggleCollapsed}>
{isCollapsed ? <ChevronRight size={15} /> : <ChevronDown size={15} />}{isCollapsed ? '展开子节点' : '折叠子节点'}
</Button>
<Button className="min-h-10 min-w-0 gap-1.5 rounded-lg px-2 text-sm" fullWidth isDisabled={isMutationDisabled || node.removed} variant="tertiary" onPress={onAddNode}>
<PlusIcon size={15} />添加流程节点
</Button>
</div>
{relatedId ? (
<Button className="min-h-10 justify-between rounded-lg px-3 text-sm" fullWidth variant="outline" onPress={() => onOpenRegulation(relatedId)}>
查看关联制度<OpenExternal size={15} />
</Button>
) : null}
</section>
<section aria-label="关联信息" className="space-y-3 border-t border-separator pt-4">
<h3 className="text-sm font-semibold">关联信息</h3>
<Tabs className="min-w-0 gap-3" defaultSelectedKey="process" variant="secondary">
<Tabs.ListContainer className="w-full min-w-0">
<Tabs.List aria-label="节点关联信息" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', width: '100%' }}>
<Tabs.Tab className="min-w-0 px-1" id="process">流程路径<Tabs.Indicator /></Tabs.Tab>
<Tabs.Tab className="min-w-0 px-1" id="regulation">制度路径<Tabs.Indicator /></Tabs.Tab>
<Tabs.Tab className="min-w-0 px-1" id="history">历史版本<Tabs.Indicator /></Tabs.Tab>
</Tabs.List>
</Tabs.ListContainer>
<Tabs.Panel style={{ marginTop: 0, padding: 0 }} id="process">
<NodePath items={ancestors.filter((item) => item.type === 'groupNode')} emptyText="暂无流程路径" />
</Tabs.Panel>
<Tabs.Panel style={{ marginTop: 0, padding: 0 }} id="regulation">
<NodePath items={ancestors.filter((item) => item.type === 'regulationNode')} emptyText="暂无制度路径" />
</Tabs.Panel>
<Tabs.Panel style={{ marginTop: 0, padding: 0 }} id="history">
<div className="space-y-1">
{historyRegulations.map((item) => (
<Button className="h-auto min-h-10 justify-start whitespace-normal rounded-lg px-2 py-2 text-left text-sm text-accent" fullWidth key={item._id || item.id || item.name} variant="ghost" onPress={() => onOpenRegulation(item._id || item.name)}>
{item.metadata?.regulation_name || item.title}
</Button>
))}
{!historyRegulations.length ? <p className="py-3 text-sm text-muted">暂无历史版本</p> : null}
</div>
</Tabs.Panel>
</Tabs>
</section>
</div>
</div>
<footer className="shrink-0 space-y-2 border-t border-separator bg-background p-4">
{isArchived ? <p className="text-xs text-muted">归档版本仅供查看,无法保存修改。</p> : null}
{node.removed ? (
<>
<Button className="min-h-10 rounded-lg" fullWidth isDisabled={isMutationDisabled} variant="secondary" onPress={onRestoreNode}>
<Reload size={16} />{hasDescendants ? '恢复节点及子节点' : '恢复节点'}
</Button>
<p className="text-xs leading-5 text-muted">恢复后点击顶部“保存”使修改生效。</p>
</>
) : (
<>
<Button aria-keyshortcuts="Delete Backspace" className="min-h-10 rounded-lg" fullWidth isDisabled={isMutationDisabled} variant="danger-soft" onPress={onDeleteNode}>
<Delete size={16} />删除节点
</Button>
<p className="text-xs leading-5 text-muted">
{hasDescendants ? '同时移除所有子节点,可在“删除节点”列表恢复。' : '快捷键 Delete(Mac ⌫),可在“删除节点”列表恢复。'}
</p>
</>
)}
</footer>
</div>
);
}
import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Card, Spinner, Tabs } from '@heroui/react'; import { Card, Spinner, Tabs } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'umi'; import { useSearchParams } from 'umi';
...@@ -18,7 +20,9 @@ export default function InternalRegulationManager() { ...@@ -18,7 +20,9 @@ export default function InternalRegulationManager() {
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [options, setOptions] = useState({}); const [options, setOptions] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -38,7 +42,7 @@ export default function InternalRegulationManager() { ...@@ -38,7 +42,7 @@ export default function InternalRegulationManager() {
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [reloadKey, setError]);
const changeTab = (key) => { const changeTab = (key) => {
const value = String(key); const value = String(key);
...@@ -52,7 +56,7 @@ export default function InternalRegulationManager() { ...@@ -52,7 +56,7 @@ export default function InternalRegulationManager() {
const content = useMemo(() => { const content = useMemo(() => {
if (loading) return <div className="flex min-h-72 items-center justify-center"><Spinner label="正在加载制度配置..." /></div>; if (loading) return <div className="flex min-h-72 items-center justify-center"><Spinner label="正在加载制度配置..." /></div>;
if (error) return <div className="rounded-2xl border border-danger/30 bg-danger/5 p-6 text-sm text-danger">{error}</div>; if (error) return <RequestFailure title="制度配置加载失败" onRetry={() => setReloadKey((key) => key + 1)} />;
if (activeTab === 'process') return <RevisionList options={options} />; if (activeTab === 'process') return <RevisionList options={options} />;
if (activeTab === 'archive') return <ArchiveList options={options} />; if (activeTab === 'archive') return <ArchiveList options={options} />;
return <RegulationList subject={subject} options={options} />; return <RegulationList subject={subject} options={options} />;
......
import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Chip, Input, Label, Spinner, TextArea, TextField, toast } from '@heroui/react'; import { Button, Chip, Input, Label, Spinner, TextArea, TextField, toast } from '@heroui/react';
import { Close, Download, Edit, Eye, FileText, PlusIcon, Search } from '@/components/AppIcons'; import { Close, Download, Edit, Eye, FileText, PlusIcon, Search } from '@/components/AppIcons';
import ListPagination from '@/components/ListPagination'; import ListPagination from '@/components/ListPagination';
...@@ -41,7 +43,8 @@ export default function RegulationList({ options = {} }) { ...@@ -41,7 +43,8 @@ export default function RegulationList({ options = {} }) {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(INITIAL_FORM); const [form, setForm] = useState(INITIAL_FORM);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
...@@ -52,6 +55,8 @@ export default function RegulationList({ options = {} }) { ...@@ -52,6 +55,8 @@ export default function RegulationList({ options = {} }) {
const refresh = async () => { const refresh = async () => {
try { try {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
const activeFilters = Object.fromEntries(Object.entries(filters).filter(([, value]) => value).map(([key, value]) => [`metadata.${key}`, ['departments', 'approve'].includes(key) ? { $in: [value] } : value])); const activeFilters = Object.fromEntries(Object.entries(filters).filter(([, value]) => value).map(([key, value]) => [`metadata.${key}`, ['departments', 'approve'].includes(key) ? { $in: [value] } : value]));
const result = await loadRegulations({ page, pageSize, keyword, filters: activeFilters }); const result = await loadRegulations({ page, pageSize, keyword, filters: activeFilters });
setRecords(result.records); setRecords(result.records);
...@@ -59,6 +64,7 @@ export default function RegulationList({ options = {} }) { ...@@ -59,6 +64,7 @@ export default function RegulationList({ options = {} }) {
setTotalPages(result.totalPages); setTotalPages(result.totalPages);
setError(''); setError('');
} catch (loadError) { } catch (loadError) {
setLoadFailed(true);
setError(loadError?.message || '制度目录加载失败。'); setError(loadError?.message || '制度目录加载失败。');
} finally { } finally {
setLoading(false); setLoading(false);
...@@ -110,7 +116,7 @@ export default function RegulationList({ options = {} }) { ...@@ -110,7 +116,7 @@ export default function RegulationList({ options = {} }) {
setSaving(true); setSaving(true);
await createRevision(form); await createRevision(form);
setDialogOpen(false); setDialogOpen(false);
toast.success('制度创建成功'); toast.success('制度创建成功', { timeout: 4000 });
await refresh(); await refresh();
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '保存失败,请检查字段后重试。'); setError(saveError?.message || '保存失败,请检查字段后重试。');
...@@ -137,7 +143,7 @@ export default function RegulationList({ options = {} }) { ...@@ -137,7 +143,7 @@ export default function RegulationList({ options = {} }) {
origin_regulation: record._id, origin_regulation: record._id,
}); });
setConfirmState({ open: false, type: '', record: null }); setConfirmState({ open: false, type: '', record: null });
toast.success(type === '修订' ? '修订制度创建成功' : '废除制度创建成功'); toast.success(type === '修订' ? '修订制度创建成功' : '废除制度创建成功', { timeout: 4000 });
await refresh(); await refresh();
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '保存失败,请检查字段后重试。'); setError(saveError?.message || '保存失败,请检查字段后重试。');
...@@ -157,7 +163,7 @@ export default function RegulationList({ options = {} }) { ...@@ -157,7 +163,7 @@ export default function RegulationList({ options = {} }) {
setSaving(true); setSaving(true);
await updateRegulationComment(record, commentState.comment); await updateRegulationComment(record, commentState.comment);
setCommentState({ open: false, record: null, comment: '' }); setCommentState({ open: false, record: null, comment: '' });
toast.success('已成功添加备注'); toast.success('已成功添加备注', { timeout: 4000 });
await refresh(); await refresh();
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '备注保存失败,请稍后重试。'); setError(saveError?.message || '备注保存失败,请稍后重试。');
...@@ -184,8 +190,8 @@ export default function RegulationList({ options = {} }) { ...@@ -184,8 +190,8 @@ export default function RegulationList({ options = {} }) {
<XinyuanAutocomplete className="w-52" label="制定部门" options={[{ value: '', label: '全部' }, ...departmentOptions]} selectedKey={filters.departments} onSelectionChange={(value) => { setFilters((current) => ({ ...current, departments: String(value || '') })); setPage(0); }} /> <XinyuanAutocomplete className="w-52" label="制定部门" options={[{ value: '', label: '全部' }, ...departmentOptions]} selectedKey={filters.departments} onSelectionChange={(value) => { setFilters((current) => ({ ...current, departments: String(value || '') })); setPage(0); }} />
<XinyuanAutocomplete className="w-52" label="审批主体" options={[{ value: '', label: '全部' }, ...approveOptions]} selectedKey={filters.approve} onSelectionChange={(value) => { setFilters((current) => ({ ...current, approve: String(value || '') })); setPage(0); }} /> <XinyuanAutocomplete className="w-52" label="审批主体" options={[{ value: '', label: '全部' }, ...approveOptions]} selectedKey={filters.approve} onSelectionChange={(value) => { setFilters((current) => ({ ...current, approve: String(value || '') })); setPage(0); }} />
</div> </div>
{error ? <div className="rounded-xl border border-danger/30 bg-danger/5 p-3 text-sm text-danger">{error}</div> : null}
{loading ? <div className="flex min-h-48 items-center justify-center"><Spinner label="正在加载制度..." /></div> : records.length === 0 ? <div className="rounded-2xl border border-dashed border-separator p-10 text-center text-sm text-muted">暂无制度记录</div> : ( {loading ? <div className="flex min-h-48 items-center justify-center"><Spinner label="正在加载制度..." /></div> : loadFailed ? <RequestFailure title="制度目录加载失败" onRetry={refresh} /> : records.length === 0 ? <div className="rounded-2xl border border-dashed border-separator p-10 text-center text-sm text-muted">暂无制度记录</div> : (
<div className="overflow-x-auto rounded-2xl border border-separator"> <div className="overflow-x-auto rounded-2xl border border-separator">
<table className="w-full min-w-[1040px] text-left text-sm"> <table className="w-full min-w-[1040px] text-left text-sm">
<thead className="bg-surface-secondary text-muted"><tr>{['制度名称', '修订状态', '时效性', '效力位阶', '制定部门', '审批主体', '发文日期', '备注', '操作'].map((title) => <th className="px-4 py-3 font-medium" key={title}>{title}</th>)}</tr></thead> <thead className="bg-surface-secondary text-muted"><tr>{['制度名称', '修订状态', '时效性', '效力位阶', '制定部门', '审批主体', '发文日期', '备注', '操作'].map((title) => <th className="px-4 py-3 font-medium" key={title}>{title}</th>)}</tr></thead>
...@@ -209,7 +215,7 @@ export default function RegulationList({ options = {} }) { ...@@ -209,7 +215,7 @@ export default function RegulationList({ options = {} }) {
</table> </table>
</div> </div>
)} )}
<ListPagination isDisabled={loading} page={page} pageSize={pageSize} total={total} totalPages={totalPages} onPageChange={setPage} onPageSizeChange={changePageSize} /> <ListPagination isDisabled={loading || loadFailed} page={page} pageSize={pageSize} total={total} totalPages={totalPages} onPageChange={setPage} onPageSizeChange={changePageSize} />
<XinyuanModal isOpen={dialogOpen} onOpenChange={setDialogOpen} size="2xl" title="创建修订计划" footer={<div className="flex justify-end gap-2"><Button variant="tertiary" onPress={() => setDialogOpen(false)}>取消</Button><Button isDisabled={saving || !form.regulation_name.trim()} variant="primary" onPress={submit}>{saving ? '保存中...' : '保存'}</Button></div>}> <XinyuanModal isOpen={dialogOpen} onOpenChange={setDialogOpen} size="2xl" title="创建修订计划" footer={<div className="flex justify-end gap-2"><Button variant="tertiary" onPress={() => setDialogOpen(false)}>取消</Button><Button isDisabled={saving || !form.regulation_name.trim()} variant="primary" onPress={submit}>{saving ? '保存中...' : '保存'}</Button></div>}>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<TextField className="sm:col-span-2" fullWidth isRequired value={form.regulation_name} onChange={(value) => setForm((current) => ({ ...current, regulation_name: value }))}><Label>制度名称</Label><Input fullWidth placeholder="请输入制度名称" variant="secondary" /></TextField> <TextField className="sm:col-span-2" fullWidth isRequired value={form.regulation_name} onChange={(value) => setForm((current) => ({ ...current, regulation_name: value }))}><Label>制度名称</Label><Input fullWidth placeholder="请输入制度名称" variant="secondary" /></TextField>
......
import { Button, Card, Input, Label, Spinner, TextArea, TextField } from '@heroui/react'; import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Card, Input, Label, Spinner, TextArea, TextField, toast } from '@heroui/react';
import { Back } from '@/components/AppIcons'; import { Back } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
...@@ -12,11 +14,13 @@ export default function RevisionDetail() { ...@@ -12,11 +14,13 @@ export default function RevisionDetail() {
const [form, setForm] = useState({}); const [form, setForm] = 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 [, setError] = useFeedbackState('error');
useEffect(() => { Promise.all([loadRecord(name), loadSubjectOptions('internal_regulation')]).then(([loaded, loadedOptions]) => { setRecord(loaded); setForm(loaded?.metadata || {}); setOptions(loadedOptions.options); }).catch((loadError) => setError(loadError?.message || '修订详情加载失败。')).finally(() => setLoading(false)); }, [name]); const [loadFailed, setLoadFailed] = useState(false);
const save = async () => { try { setSaving(true); await updateRevision(record.name || name, form); history.push('/internal_regulation_manager?tab=process'); } catch (saveError) { setError(saveError?.message || '保存失败。'); } finally { setSaving(false); } }; const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { setLoading(true); setLoadFailed(false); setError(''); Promise.all([loadRecord(name), loadSubjectOptions('internal_regulation')]).then(([loaded, loadedOptions]) => { setRecord(loaded); setForm(loaded?.metadata || {}); setOptions(loadedOptions.options); }).catch((loadError) => { setLoadFailed(true); setError(loadError?.message || '修订详情加载失败。'); }).finally(() => setLoading(false)); }, [name, reloadKey, setError]);
const save = async () => { try { setSaving(true); await updateRevision(record.name || name, form); toast.success('修订计划已保存。', { timeout: 4000 }); history.push('/internal_regulation_manager?tab=process'); } catch (saveError) { setError(saveError?.message || '保存失败。'); } finally { setSaving(false); } };
if (loading) return <div className="flex min-h-72 items-center justify-center"><Spinner label="正在加载修订详情..." /></div>; if (loading) return <div className="flex min-h-72 items-center justify-center"><Spinner label="正在加载修订详情..." /></div>;
if (error || !record) return <div className="space-y-4"><Button variant="tertiary" onPress={() => history.back()}><Back size={16} />返回</Button><div className="rounded-2xl border border-danger/30 bg-danger/5 p-6 text-sm text-danger">{error || '修订计划不存在。'}</div></div>; if (loadFailed || !record) return <div className="space-y-4"><Button variant="tertiary" onPress={() => history.back()}><Back size={16} />返回</Button><RequestFailure title={loadFailed ? '修订详情加载失败' : '修订计划不存在'} onRetry={() => setReloadKey((key) => key + 1)} /></div>;
const selectOptions = (field) => (options[field] || []).map((item) => ({ value: item.value ?? item.label ?? item, label: item.label ?? item.value ?? item })); const selectOptions = (field) => (options[field] || []).map((item) => ({ value: item.value ?? item.label ?? item, label: item.label ?? item.value ?? item }));
return <div className="mx-auto max-w-4xl space-y-5"><div className="flex items-center justify-between"><Button variant="tertiary" onPress={() => history.push('/internal_regulation_manager?tab=process')}><Back size={16} />返回修订计划</Button><Button isDisabled={saving || !form.regulation_name} variant="primary" onPress={save}>{saving ? '保存中...' : '保存'}</Button></div><Card className="rc-card-surface"><Card.Header><Card.Title>编辑修订计划</Card.Title></Card.Header><Card.Content><div className="grid gap-4 sm:grid-cols-2"><TextField className="sm:col-span-2" fullWidth isRequired value={form.regulation_name || ''} onChange={(value) => setForm((current) => ({ ...current, regulation_name: value }))}><Label>制度名称</Label><Input fullWidth variant="secondary" /></TextField><XinyuanAutocomplete label="修订类型" options={['新增', '修订', '废止'].map((value) => ({ value, label: value }))} selectedKey={form.type || ''} onSelectionChange={(value) => setForm((current) => ({ ...current, type: String(value) }))} /><XinyuanAutocomplete label="效力位阶" options={REGULATION_LEVELS.map((value) => ({ value, label: value }))} selectedKey={form.level || ''} onSelectionChange={(value) => setForm((current) => ({ ...current, level: String(value) }))} /><XinyuanAutocomplete label="审批主体" options={selectOptions('approve')} selectedKeys={new Set(form.approve || [])} selectionMode="multiple" onSelectionChange={(value) => setForm((current) => ({ ...current, approve: value === 'all' ? selectOptions('approve').map((item) => item.value) : Array.from(value || []) }))} /><XinyuanAutocomplete label="制定部门" options={selectOptions('departments')} selectedKeys={new Set(form.departments || [])} selectionMode="multiple" onSelectionChange={(value) => setForm((current) => ({ ...current, departments: value === 'all' ? selectOptions('departments').map((item) => item.value) : Array.from(value || []) }))} /><TextField className="sm:col-span-2" fullWidth value={form.comment || ''} onChange={(value) => setForm((current) => ({ ...current, comment: value }))}><Label>备注</Label><TextArea fullWidth rows={6} variant="secondary" /></TextField></div></Card.Content></Card></div>; return <div className="mx-auto max-w-4xl space-y-5"><div className="flex items-center justify-between"><Button variant="tertiary" onPress={() => history.push('/internal_regulation_manager?tab=process')}><Back size={16} />返回修订计划</Button><Button isDisabled={saving || !form.regulation_name} variant="primary" onPress={save}>{saving ? '保存中...' : '保存'}</Button></div><Card className="rc-card-surface"><Card.Header><Card.Title>编辑修订计划</Card.Title></Card.Header><Card.Content><div className="grid gap-4 sm:grid-cols-2"><TextField className="sm:col-span-2" fullWidth isRequired value={form.regulation_name || ''} onChange={(value) => setForm((current) => ({ ...current, regulation_name: value }))}><Label>制度名称</Label><Input fullWidth variant="secondary" /></TextField><XinyuanAutocomplete label="修订类型" options={['新增', '修订', '废止'].map((value) => ({ value, label: value }))} selectedKey={form.type || ''} onSelectionChange={(value) => setForm((current) => ({ ...current, type: String(value) }))} /><XinyuanAutocomplete label="效力位阶" options={REGULATION_LEVELS.map((value) => ({ value, label: value }))} selectedKey={form.level || ''} onSelectionChange={(value) => setForm((current) => ({ ...current, level: String(value) }))} /><XinyuanAutocomplete label="审批主体" options={selectOptions('approve')} selectedKeys={new Set(form.approve || [])} selectionMode="multiple" onSelectionChange={(value) => setForm((current) => ({ ...current, approve: value === 'all' ? selectOptions('approve').map((item) => item.value) : Array.from(value || []) }))} /><XinyuanAutocomplete label="制定部门" options={selectOptions('departments')} selectedKeys={new Set(form.departments || [])} selectionMode="multiple" onSelectionChange={(value) => setForm((current) => ({ ...current, departments: value === 'all' ? selectOptions('departments').map((item) => item.value) : Array.from(value || []) }))} /><TextField className="sm:col-span-2" fullWidth value={form.comment || ''} onChange={(value) => setForm((current) => ({ ...current, comment: value }))}><Label>备注</Label><TextArea fullWidth rows={6} variant="secondary" /></TextField></div></Card.Content></Card></div>;
} }
import useFeedbackState from '@/hooks/useFeedbackState';
import xinyuanLogo from '@/assets/xy-logo-red.png'; import xinyuanLogo from '@/assets/xy-logo-red.png';
import { APP_DISPLAY_NAME, APP_ROUTE_NAME } from '@/constants/app'; import { APP_DISPLAY_NAME, APP_ROUTE_NAME } from '@/constants/app';
import { getApplications } from '@/services/DataService'; 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 { 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,
...@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({
const [status, setStatus] = useState( const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle', fallbackApplications.length > 0 ? 'success' : 'idle',
); );
const [errorMessage, setErrorMessage] = useState(''); const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState(''); const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => { const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME; return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
...@@ -352,6 +352,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -352,6 +352,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setApplicationSearch(''); setApplicationSearch('');
setErrorMessage('');
return; return;
} }
...@@ -442,13 +443,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -442,13 +443,6 @@ export default function ApplicationSwitcherDialog({
offset={12} offset={12}
size={24} size={24}
> >
{status === 'error' && errorMessage ? (
<Alert className="mb-4" status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? ( {status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? ( ) : filteredApplicationSections.length > 0 ? (
......
import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import { Eye } from '@/components/AppIcons'; import { Eye } from '@/components/AppIcons';
...@@ -91,9 +93,12 @@ export default function ForwardApprovals() { ...@@ -91,9 +93,12 @@ export default function ForwardApprovals() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [editLoading, setEditLoading] = useState(false); const [editLoading, setEditLoading] = useState(false);
const [editLoadFailed, setEditLoadFailed] = useState(false);
const [editSaving, setEditSaving] = useState(false); const [editSaving, setEditSaving] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [notice, setNotice] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [, setNotice] = useFeedbackState('success');
const [activeRecordId, setActiveRecordId] = useState(''); const [activeRecordId, setActiveRecordId] = useState('');
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
...@@ -107,6 +112,8 @@ export default function ForwardApprovals() { ...@@ -107,6 +112,8 @@ export default function ForwardApprovals() {
(async () => { (async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
try { try {
await ensureModelMonitorSubjects(); await ensureModelMonitorSubjects();
const result = await loadForwardRecords(); const result = await loadForwardRecords();
...@@ -122,6 +129,7 @@ export default function ForwardApprovals() { ...@@ -122,6 +129,7 @@ export default function ForwardApprovals() {
} }
} catch (loadError) { } catch (loadError) {
if (!cancelled) { if (!cancelled) {
setLoadFailed(true);
setError(loadError?.message || '转发审批列表加载失败'); setError(loadError?.message || '转发审批列表加载失败');
} }
} finally { } finally {
...@@ -134,7 +142,7 @@ export default function ForwardApprovals() { ...@@ -134,7 +142,7 @@ export default function ForwardApprovals() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentUserId]); }, [currentUserId, reloadKey, setError]);
const editableInvolvedCount = useMemo( const editableInvolvedCount = useMemo(
() => () =>
...@@ -212,6 +220,7 @@ export default function ForwardApprovals() { ...@@ -212,6 +220,7 @@ export default function ForwardApprovals() {
setEditingRecord(record); setEditingRecord(record);
setIsEditDialogOpen(true); setIsEditDialogOpen(true);
setEditLoading(true); setEditLoading(true);
setEditLoadFailed(false);
setError(''); setError('');
try { try {
...@@ -220,8 +229,8 @@ export default function ForwardApprovals() { ...@@ -220,8 +229,8 @@ export default function ForwardApprovals() {
setEditableRecords(result.records); setEditableRecords(result.records);
setActiveEditableRecordId(result.records[0]?.id || ''); setActiveEditableRecordId(result.records[0]?.id || '');
} catch (loadError) { } catch (loadError) {
setEditLoadFailed(true);
setError(loadError?.message || '填写数据加载失败'); setError(loadError?.message || '填写数据加载失败');
setEditingRecord(null);
setEditableSubjectDefinition(null); setEditableSubjectDefinition(null);
setEditableRecords([]); setEditableRecords([]);
setActiveEditableRecordId(''); setActiveEditableRecordId('');
...@@ -457,8 +466,7 @@ export default function ForwardApprovals() { ...@@ -457,8 +466,7 @@ export default function ForwardApprovals() {
/> />
{loading ? <NoticeBanner>正在加载转发审批记录...</NoticeBanner> : null} {loading ? <NoticeBanner>正在加载转发审批记录...</NoticeBanner> : null}
{!loading && error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && loadFailed ? <RequestFailure title="转发审批列表加载失败" onRetry={() => setReloadKey((key) => key + 1)} /> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null}
<div className="min-w-0 max-w-full"> <div className="min-w-0 max-w-full">
<Tabs className="w-fit max-w-full min-w-0" selectedKey={activeFilter} variant="primary" onSelectionChange={(key) => { setActiveFilter(String(key)); setPage(0); }}> <Tabs className="w-fit max-w-full min-w-0" selectedKey={activeFilter} variant="primary" onSelectionChange={(key) => { setActiveFilter(String(key)); setPage(0); }}>
...@@ -477,7 +485,7 @@ export default function ForwardApprovals() { ...@@ -477,7 +485,7 @@ export default function ForwardApprovals() {
{editableInvolvedCount > 0 ? <NoticeBanner>你当前有 {editableInvolvedCount} 条待填写记录。</NoticeBanner> : null} {editableInvolvedCount > 0 ? <NoticeBanner>你当前有 {editableInvolvedCount} 条待填写记录。</NoticeBanner> : null}
<section className="min-w-0"> <section className="min-w-0" hidden={loading || loadFailed}>
<SectionHeader title="转发记录列表" /> <SectionHeader title="转发记录列表" />
<DataGrid <DataGrid
aria-label="转发审批列表" aria-label="转发审批列表"
...@@ -573,6 +581,8 @@ export default function ForwardApprovals() { ...@@ -573,6 +581,8 @@ export default function ForwardApprovals() {
> >
{editLoading ? ( {editLoading ? (
<NoticeBanner>正在加载可填写数据...</NoticeBanner> <NoticeBanner>正在加载可填写数据...</NoticeBanner>
) : editLoadFailed ? (
<RequestFailure title="填写数据加载失败" onRetry={() => openEditDialog(editingRecord)} />
) : editableRecords.length > 0 && editableSubjectDefinition ? ( ) : editableRecords.length > 0 && editableSubjectDefinition ? (
<div className="grid gap-5 xl:grid-cols-[22rem_minmax(0,1fr)]"> <div className="grid gap-5 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Surface className="rounded-2xl p-4" variant="secondary"> <Surface className="rounded-2xl p-4" variant="secondary">
......
import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import { import {
...@@ -58,8 +60,10 @@ export default function ModelBatchData() { ...@@ -58,8 +60,10 @@ export default function ModelBatchData() {
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
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 [, setError] = useFeedbackState('error');
const [notice, setNotice] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [, setNotice] = useFeedbackState('success');
const [isHiddenDialogOpen, setIsHiddenDialogOpen] = useState(false); const [isHiddenDialogOpen, setIsHiddenDialogOpen] = useState(false);
const [isForwardDialogOpen, setIsForwardDialogOpen] = useState(false); const [isForwardDialogOpen, setIsForwardDialogOpen] = useState(false);
const [forwardForm, setForwardForm] = useState(createEmptyForwardDraft); const [forwardForm, setForwardForm] = useState(createEmptyForwardDraft);
...@@ -82,6 +86,8 @@ export default function ModelBatchData() { ...@@ -82,6 +86,8 @@ export default function ModelBatchData() {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
try { try {
await ensureModelMonitorSubjects(); await ensureModelMonitorSubjects();
const [nextModel, nextBatch] = await Promise.all([loadModel(modelId), loadModelBatch(batchId)]); const [nextModel, nextBatch] = await Promise.all([loadModel(modelId), loadModelBatch(batchId)]);
...@@ -98,13 +104,13 @@ export default function ModelBatchData() { ...@@ -98,13 +104,13 @@ export default function ModelBatchData() {
setForwardItems(itemList); setForwardItems(itemList);
} }
} catch (loadError) { } catch (loadError) {
if (!cancelled) setError(loadError?.message || '模型批次数据加载失败。'); if (!cancelled) { setLoadFailed(true); setError(loadError?.message || '模型批次数据加载失败。'); }
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [batchId, modelId]); }, [batchId, modelId, reloadKey, setError]);
const forwardStatusIndex = useMemo(() => buildForwardStatusIndex(forwards), [forwards]); const forwardStatusIndex = useMemo(() => buildForwardStatusIndex(forwards), [forwards]);
const forwardItemByRecordId = useMemo(() => buildForwardItemIndex(forwardItems), [forwardItems]); const forwardItemByRecordId = useMemo(() => buildForwardItemIndex(forwardItems), [forwardItems]);
...@@ -225,11 +231,10 @@ export default function ModelBatchData() { ...@@ -225,11 +231,10 @@ export default function ModelBatchData() {
<div className="space-y-5"> <div className="space-y-5">
<PageHeader eyebrow={batch?.batchCode || '模型批次'} title={batch?.batchName || '批次数据'} /> <PageHeader eyebrow={batch?.batchCode || '模型批次'} title={batch?.batchName || '批次数据'} />
{loading ? <NoticeBanner>正在加载批次数据和转发状态...</NoticeBanner> : null} {loading ? <NoticeBanner>正在加载批次数据和转发状态...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && loadFailed ? <RequestFailure title="模型批次数据加载失败" onRetry={() => setReloadKey((key) => key + 1)} /> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null}
{batch?.syncStatus === 'partial' || batch?.syncStatus === 'failed' ? <NoticeBanner tone="error">最近一次同步未完整成功:{batch.errorMessage || '该批次不可重试,请联系管理员排查。'}</NoticeBanner> : null} {batch?.syncStatus === 'partial' || batch?.syncStatus === 'failed' ? <NoticeBanner tone="error">最近一次同步未完整成功:{batch.errorMessage || '该批次不可重试,请联系管理员排查。'}</NoticeBanner> : null}
{!loading && batch ? ( {!loading && !loadFailed && batch ? (
<section className="min-w-0 space-y-4"> <section className="min-w-0 space-y-4">
<SectionHeader <SectionHeader
title="批次数据" title="批次数据"
......
import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
...@@ -35,7 +37,9 @@ export default function ModelBatches() { ...@@ -35,7 +37,9 @@ export default function ModelBatches() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false); const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false);
const [sourceModel, setSourceModel] = useState(null); const [sourceModel, setSourceModel] = useState(null);
const [sourceBatches, setSourceBatches] = useState([]); const [sourceBatches, setSourceBatches] = useState([]);
...@@ -54,6 +58,8 @@ export default function ModelBatches() { ...@@ -54,6 +58,8 @@ export default function ModelBatches() {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
try { try {
await ensureModelMonitorSubjects(); await ensureModelMonitorSubjects();
const nextModel = await loadModel(modelId); const nextModel = await loadModel(modelId);
...@@ -63,13 +69,13 @@ export default function ModelBatches() { ...@@ -63,13 +69,13 @@ export default function ModelBatches() {
setBatches(batchList); setBatches(batchList);
} }
} catch (loadError) { } catch (loadError) {
if (!cancelled) setError(loadError?.message || '模型批次加载失败。'); if (!cancelled) { setLoadFailed(true); setError(loadError?.message || '模型批次加载失败。'); }
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [modelId]); }, [modelId, reloadKey, setError]);
const filteredBatches = useMemo(() => { const filteredBatches = useMemo(() => {
const search = keyword.trim().toLowerCase(); const search = keyword.trim().toLowerCase();
...@@ -115,7 +121,7 @@ export default function ModelBatches() { ...@@ -115,7 +121,7 @@ export default function ModelBatches() {
} catch (loadError) { } catch (loadError) {
const message = loadError?.message || '外部批次列表加载失败。'; const message = loadError?.message || '外部批次列表加载失败。';
if (message === '外部模型列表中已找不到当前模型。') { if (message === '外部模型列表中已找不到当前模型。') {
toast.info(message); toast.info(message, { timeout: 8000 });
setError(''); setError('');
} else { } else {
setError(message); setError(message);
...@@ -137,7 +143,7 @@ export default function ModelBatches() { ...@@ -137,7 +143,7 @@ export default function ModelBatches() {
try { try {
const result = await synchronizeModelBatch(sourceModel, sourceBatch, userInfo, setProgress); const result = await synchronizeModelBatch(sourceModel, sourceBatch, userInfo, setProgress);
await refresh(result.model); await refresh(result.model);
toast.success(`批次“${sourceBatch.batch_name}”同步完成:新增 ${result.insertedCount} 条。`); toast.success(`批次“${sourceBatch.batch_name}”同步完成:新增 ${result.insertedCount} 条。`, { timeout: 4000 });
setError(''); setError('');
setIsSyncDialogOpen(false); setIsSyncDialogOpen(false);
} catch (syncError) { } catch (syncError) {
...@@ -152,9 +158,9 @@ export default function ModelBatches() { ...@@ -152,9 +158,9 @@ export default function ModelBatches() {
<div className="space-y-5"> <div className="space-y-5">
<PageHeader eyebrow={model?.modelCode || '模型'} title={model?.modelName || '模型批次'} /> <PageHeader eyebrow={model?.modelCode || '模型'} title={model?.modelName || '模型批次'} />
{loading ? <NoticeBanner>正在加载模型批次...</NoticeBanner> : null} {loading ? <NoticeBanner>正在加载模型批次...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && loadFailed ? <RequestFailure title="模型批次加载失败" onRetry={() => setReloadKey((key) => key + 1)} /> : null}
{!loading && model ? ( {!loading && !loadFailed && model ? (
<section className="min-w-0 space-y-4"> <section className="min-w-0 space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1 sm:max-w-96"> <div className="min-w-0 flex-1 sm:max-w-96">
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
Archive, Archive,
Back, Back,
...@@ -183,15 +184,23 @@ export function UserSelectField({ value, onChange, label, required = false, plac ...@@ -183,15 +184,23 @@ export function UserSelectField({ value, onChange, label, required = false, plac
const [query, setQuery] = useState(value?.label || ''); const [query, setQuery] = useState(value?.label || '');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState('error');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError('');
searchUsers(query) searchUsers(query)
.then((result) => { if (!cancelled) setOptions(result); }) .then((result) => { if (!cancelled) setOptions(result); })
.catch((requestError) => {
if (!cancelled) {
setOptions([]);
setError(requestError?.message || '用户列表加载失败,请重新搜索。');
}
})
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [query]); }, [query, setError]);
useEffect(() => { useEffect(() => {
if (value?.label && value.label !== query) setQuery(value.label); if (value?.label && value.label !== query) setQuery(value.label);
...@@ -203,6 +212,7 @@ export function UserSelectField({ value, onChange, label, required = false, plac ...@@ -203,6 +212,7 @@ export function UserSelectField({ value, onChange, label, required = false, plac
: options; : options;
return <XinyuanAutocomplete return <XinyuanAutocomplete
emptyText={loading ? '正在加载...' : '没有匹配用户'} emptyText={loading ? '正在加载...' : '没有匹配用户'}
errorText={error}
inputValue={query} inputValue={query}
isLoading={loading} isLoading={loading}
isRequired={required} isRequired={required}
......
import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
AppGrid, AppGrid,
Clock, Clock,
...@@ -32,12 +34,16 @@ export default function ModelOverview() { ...@@ -32,12 +34,16 @@ export default function ModelOverview() {
const [batches, setBatches] = useState([]); const [batches, setBatches] = useState([]);
const [forwards, setForwards] = useState([]); const [forwards, setForwards] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
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('');
try { try {
await ensureModelMonitorSubjects(); await ensureModelMonitorSubjects();
const [modelList, batchList, forwardList] = await Promise.all([ const [modelList, batchList, forwardList] = await Promise.all([
...@@ -52,13 +58,13 @@ export default function ModelOverview() { ...@@ -52,13 +58,13 @@ export default function ModelOverview() {
setError(''); setError('');
} }
} catch (loadError) { } catch (loadError) {
if (!cancelled) setError(loadError?.message || '模型总览加载失败。'); if (!cancelled) { setLoadFailed(true); setError(loadError?.message || '模型总览加载失败。'); }
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [reloadKey, setError]);
const totalRecords = useMemo( const totalRecords = useMemo(
() => batches.reduce((total, batch) => total + batch.totalRecordCount, 0), () => batches.reduce((total, batch) => total + batch.totalRecordCount, 0),
...@@ -123,9 +129,9 @@ export default function ModelOverview() { ...@@ -123,9 +129,9 @@ export default function ModelOverview() {
/> />
{loading ? <NoticeBanner>正在初始化模型监控对象并加载概览...</NoticeBanner> : null} {loading ? <NoticeBanner>正在初始化模型监控对象并加载概览...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && loadFailed ? <RequestFailure title="模型总览加载失败" onRetry={() => setReloadKey((key) => key + 1)} /> : null}
{!loading && !error ? ( {!loading && !loadFailed ? (
<> <>
<div className="rc-kpi-grid" data-count="4"> <div className="rc-kpi-grid" data-count="4">
{metrics.map(({ icon: Icon, iconTone, label, value }) => ( {metrics.map(({ icon: Icon, iconTone, label, value }) => (
......
import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
...@@ -144,8 +146,11 @@ export default function Models() { ...@@ -144,8 +146,11 @@ export default function Models() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [notice, setNotice] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [, setNotice] = useFeedbackState('success');
const [, setWarning] = useFeedbackState('warning');
const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false); const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false);
const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false); const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false);
...@@ -164,7 +169,7 @@ export default function Models() { ...@@ -164,7 +169,7 @@ export default function Models() {
const [syncLoading, setSyncLoading] = useState(false); const [syncLoading, setSyncLoading] = useState(false);
const [syncProgress, setSyncProgress] = useState({ completed: 0, total: 0 }); const [syncProgress, setSyncProgress] = useState({ completed: 0, total: 0 });
const [syncResult, setSyncResult] = useState(null); const [syncResult, setSyncResult] = useState(null);
const [syncDialogError, setSyncDialogError] = useState(''); const [syncDialogError, setSyncDialogError] = useFeedbackState('error');
async function refresh() { async function refresh() {
const [modelList, groupList] = await Promise.all([loadModels(), loadModelGroups()]); const [modelList, groupList] = await Promise.all([loadModels(), loadModelGroups()]);
...@@ -176,17 +181,19 @@ export default function Models() { ...@@ -176,17 +181,19 @@ export default function Models() {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
try { try {
await ensureModelMonitorSubjects(); await ensureModelMonitorSubjects();
if (!cancelled) await refresh(); if (!cancelled) await refresh();
} catch (loadError) { } catch (loadError) {
if (!cancelled) setError(loadError?.message || '模型列表初始化失败。'); if (!cancelled) { setLoadFailed(true); setError(loadError?.message || '模型列表初始化失败。'); }
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [reloadKey, setError]);
const groupTree = useMemo(() => buildGroupTree(groups), [groups]); const groupTree = useMemo(() => buildGroupTree(groups), [groups]);
const groupOptions = useMemo(() => flattenGroupOptions(groupTree), [groupTree]); const groupOptions = useMemo(() => flattenGroupOptions(groupTree), [groupTree]);
...@@ -440,7 +447,9 @@ export default function Models() { ...@@ -440,7 +447,9 @@ export default function Models() {
failedRecordCount, failedRecordCount,
failures, failures,
}); });
setNotice(`已完成 ${completedCodes.length}/${selectedEntries.length} 个批次同步,共新增 ${insertedCount} 条数据。`); const summary = `已完成 ${selectedEntries.length - failures.length}/${selectedEntries.length} 个批次同步,共新增 ${insertedCount} 条数据。`;
if (failures.length) setWarning(`${summary} ${failures.length} 个批次出现异常,请查看同步结果。`);
else setNotice(summary);
setError(''); setError('');
const [, localBatchList] = await Promise.all([refresh(), loadModelBatches()]); const [, localBatchList] = await Promise.all([refresh(), loadModelBatches()]);
const nextSyncedCodes = localBatchList.map((batch) => String(batch.batchCode)); const nextSyncedCodes = localBatchList.map((batch) => String(batch.batchCode));
...@@ -458,10 +467,9 @@ export default function Models() { ...@@ -458,10 +467,9 @@ export default function Models() {
<PageHeader eyebrow="模型监控" title="全部模型" /> <PageHeader eyebrow="模型监控" title="全部模型" />
<div className="space-y-8"> <div className="space-y-8">
{loading ? <NoticeBanner>正在初始化模型对象并加载列表...</NoticeBanner> : null} {loading ? <NoticeBanner>正在初始化模型对象并加载列表...</NoticeBanner> : null}
{error ? <NoticeBanner tone="error">{error}</NoticeBanner> : null} {!loading && loadFailed ? <RequestFailure title="模型列表加载失败" onRetry={() => setReloadKey((key) => key + 1)} /> : null}
{!error && notice ? <NoticeBanner tone="success">{notice}</NoticeBanner> : null}
{!loading ? ( {!loading && !loadFailed ? (
<> <>
<div className="grid min-w-0 gap-5 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start"> <div className="grid min-w-0 gap-5 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start">
<Surface className="min-w-0 rounded-3xl p-4" variant="default"> <Surface className="min-w-0 rounded-3xl p-4" variant="default">
...@@ -591,7 +599,7 @@ export default function Models() { ...@@ -591,7 +599,7 @@ export default function Models() {
<div className="space-y-5"> <div className="space-y-5">
<Stepper aria-label="同步步骤" className="mx-auto max-w-4xl" currentStep={syncStep - 1} size="sm">{['加载批次', '选择批次', '执行同步'].map((label) => <Stepper.Step key={label}><Stepper.Indicator /><Stepper.Content><Stepper.Title>{label}</Stepper.Title></Stepper.Content><Stepper.Separator /></Stepper.Step>)}</Stepper> <Stepper aria-label="同步步骤" className="mx-auto max-w-4xl" currentStep={syncStep - 1} size="sm">{['加载批次', '选择批次', '执行同步'].map((label) => <Stepper.Step key={label}><Stepper.Indicator /><Stepper.Content><Stepper.Title>{label}</Stepper.Title></Stepper.Content><Stepper.Separator /></Stepper.Step>)}</Stepper>
{syncDialogError ? <NoticeBanner tone="error">{syncDialogError}</NoticeBanner> : null} {syncDialogError ? <RequestFailure title={syncStep === 1 ? '来源模型与批次加载失败' : '同步结果读取失败'} onRetry={syncStep === 1 ? openSyncDialog : undefined} /> : null}
{syncStep === 1 && syncLoading ? <NoticeBanner>正在加载全部来源模型、批次和本地同步状态...</NoticeBanner> : null} {syncStep === 1 && syncLoading ? <NoticeBanner>正在加载全部来源模型、批次和本地同步状态...</NoticeBanner> : null}
{syncStep === 2 ? ( {syncStep === 2 ? (
......
...@@ -182,3 +182,11 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` ...@@ -182,3 +182,11 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
- 批量同步模型批次弹窗使用紧凑步骤条、年份筛选与全选工具行;年份下按来源模型分段展示批次,同一模型的批次集中在同一列表区,并在分段标题展示模型名称、编码和批次数量。各分段使用 HeroUI Pro ListView 原生多选,切换年份继续保留其它年份已选项,已同步批次继续禁用,选择和同步口径不变。 - 批量同步模型批次弹窗使用紧凑步骤条、年份筛选与全选工具行;年份下按来源模型分段展示批次,同一模型的批次集中在同一列表区,并在分段标题展示模型名称、编码和批次数量。各分段使用 HeroUI Pro ListView 原生多选,切换年份继续保留其它年份已选项,已同步批次继续禁用,选择和同步口径不变。
- 查看态详情统一放在单一 secondary Surface;编辑态字段直接位于 Modal Overlay,字段必填、枚举来源、主键禁用和完成填写校验保持原规则。 - 查看态详情统一放在单一 secondary Surface;编辑态字段直接位于 Modal Overlay,字段必填、枚举来源、主键禁用和完成填写校验保持原规则。
- Loading、错误和空状态由 HeroUI/HeroUI Pro 语义组件展示;不得生成测试记录或用伪造数值填充空页面。 - Loading、错误和空状态由 HeroUI/HeroUI Pro 语义组件展示;不得生成测试记录或用伪造数值填充空页面。
## 8. 操作提示与失败重试
- 功能入口:模型总览、模型分组/归档信息、批次同步、批次数据隐藏/恢复、转发提交、填写保存、完成填写和两级审批,以及弹窗中的用户查询。
- 成功、失败和降级警告共用应用根部 HeroUI Toast,位于视口顶部且高于业务弹窗和侧栏,当前提示使用正文右侧的小号透明关闭按钮,点击或键盘激活通过 `close` slot 关闭,后排提示隐藏关闭按钮;成功 4 秒、错误/警告 8 秒自动消失,悬停或聚焦暂停计时,同一错误重试时重新弹出。
- 关闭提示不改变同步/填写/审批状态,也不清空选择或草稿。保存失败保留原页面和弹窗;用户搜索失败保留字段级错误并可重新搜索。请求写入协议与 `metadata` 字段保持不变。
- 总览、模型列表、批次、批次数据、转发审批和填写数据加载失败时显示失败空状态及读取重试;重试只读取当前页面资源,不重复同步、转发或审批。批量同步来源加载失败可重新读取,同步结果刷新失败仍保留结果供查看。
- 批量同步出现部分失败时使用警告浮层并保留逐批结果;已经创建的批次继续禁止重复同步。历史同步失败摘要、同步进度、待填写数量属于业务状态,继续在原位置展示。
import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, OpenExternal, Search } from '@/components/AppIcons'; import { Bell, Close, OpenExternal, Search } from '@/components/AppIcons';
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,
...@@ -200,7 +201,7 @@ export default function NotificationListPage() { ...@@ -200,7 +201,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);
...@@ -264,12 +265,12 @@ export default function NotificationListPage() { ...@@ -264,12 +265,12 @@ export default function NotificationListPage() {
setBusyKey(key); setBusyKey(key);
try { try {
await operation(); await operation();
toast.success(successMessage, { timeout: 3000 }); toast.success(successMessage, { timeout: 4000 });
refresh(); refresh();
return true; return true;
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', { toast.danger(operationError?.message || '操作失败,请稍后重试', {
timeout: 4000, timeout: 8000,
}); });
return false; return false;
} finally { } finally {
...@@ -319,7 +320,7 @@ export default function NotificationListPage() { ...@@ -319,7 +320,7 @@ export default function NotificationListPage() {
refresh(); refresh();
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { toast.danger(operationError?.message || '无法打开通知关联流程', {
timeout: 4000, timeout: 8000,
}); });
} finally { } finally {
setBusyKey(''); setBusyKey('');
...@@ -403,16 +404,7 @@ export default function NotificationListPage() { ...@@ -403,16 +404,7 @@ export default function NotificationListPage() {
</Tabs> </Tabs>
</div> </div>
{error ? ( {!loading && error ? <RequestFailure title="通知加载失败" onRetry={refresh} /> : null}
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="ghost" onPress={refresh}>
重试
</Button>
</Alert>
) : null}
{loading && notifications.length === 0 ? ( {loading && notifications.length === 0 ? (
<div className="flex min-h-64 items-center justify-center" aria-label="通知加载中"> <div className="flex min-h-64 items-center justify-center" aria-label="通知加载中">
......
...@@ -35,3 +35,9 @@ ...@@ -35,3 +35,9 @@
- 接口 401/403、网络失败或动态 action 配置缺失时不得伪造成功状态或未读数量。 - 接口 401/403、网络失败或动态 action 配置缺失时不得伪造成功状态或未读数量。
- 通知中心不读取或修改模型监控 Subject、Record、审批字段及用户 `applications` 授权结构。 - 通知中心不读取或修改模型监控 Subject、Record、审批字段及用户 `applications` 授权结构。
- 本应用不在五项完整功能白名单中,不实现 `shortTitle`、Subject View 权限或审计材料中心文件工作流。 - 本应用不在五项完整功能白名单中,不实现 `shortTitle`、Subject View 权限或审计材料中心文件工作流。
## 操作提示与失败重试
- 通知查询、已读操作、待办动作和关联流程打开失败均使用根部 HeroUI Toast,弹窗和移动侧栏上方可见;成功 4 秒、失败 8 秒自动消失,当前提示正文右侧的透明关闭图标始终可见,点击或键盘激活通过 `close` slot 关闭,后排提示隐藏关闭按钮;悬停或聚焦暂停计时。
- 列表加载失败保留“通知加载失败”空状态与“重新加载”,重试保留当前分类、已读状态、关键词及页码,只重新读取通知。
- 关闭提示不会把请求失败改为成功,不修改通知已读/待办状态或未读数量,不重复执行待办动作或打开关联链接;接口协议保持不变。
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,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";
@source "./*.{js,jsx,ts,tsx}"; @source "./*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}"; @source "./components/**/*.{js,jsx,ts,tsx}";
......
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;
......
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