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 { Button, Card, Chip, Spinner, TextArea, TextField, Label, Input } from '@heroui/react'; import { Button, Card, Chip, Spinner, TextArea, TextField, Label, Input } from '@heroui/react';
import { Back, Edit, FileText, PaperClipIcon } from '@/components/AppIcons'; import { Back, Edit, FileText, PaperClipIcon } from '@/components/AppIcons';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
...@@ -15,21 +17,49 @@ export default function InternalRegulationDetail() { ...@@ -15,21 +17,49 @@ export default function InternalRegulationDetail() {
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 [, setError] = useFeedbackState('error');
const [, setSaveSuccess] = useFeedbackState('success');
const [loadFailed, setLoadFailed] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form, setForm] = useState({}); const [form, setForm] = useState({});
const [historyRecords, setHistoryRecords] = useState([]); const [historyRecords, setHistoryRecords] = useState([]);
const load = async () => { try { setLoading(true); const [loaded, loadedSubject, loadedOptions] = await Promise.all([loadRecord(name), loadRegulationSubject(), loadSubjectOptions('internal_regulation')]); const histories = await loadRegulationHistory(loaded); setRecord(loaded); setHistoryRecords(histories); setSubject(loadedSubject); setOptions(loadedOptions.options); setForm(loaded?.metadata || {}); setError(''); } catch (loadError) { setError(loadError?.message || '制度详情加载失败。'); } finally { setLoading(false); } }; const load = async () => { try { setLoading(true); setLoadFailed(false); setError(''); const [loaded, loadedSubject, loadedOptions] = await Promise.all([loadRecord(name), loadRegulationSubject(), loadSubjectOptions('internal_regulation')]); const histories = await loadRegulationHistory(loaded); setRecord(loaded); setHistoryRecords(histories); setSubject(loadedSubject); setOptions(loadedOptions.options); setForm(loaded?.metadata || {}); setError(''); } catch (loadError) { setLoadFailed(true); setError(loadError?.message || '制度详情加载失败。'); } finally { setLoading(false); } };
useEffect(() => { load(); }, [name]); useEffect(() => {
setSaveSuccess('');
load();
}, [name, setSaveSuccess]);
const fields = useMemo(() => (subject?.fields || []).filter((field) => ['regulation_name', 'effective_status', 'level', 'public_date', 'departments', 'approve', 'effective_date', 'comment'].includes(field.name)), [subject]); const fields = useMemo(() => (subject?.fields || []).filter((field) => ['regulation_name', 'effective_status', 'level', 'public_date', 'departments', 'approve', 'effective_date', 'comment'].includes(field.name)), [subject]);
const save = async () => { try { setSaving(true); await updateRecord(record.name || name, { name: record.name || name, metadata: normalizeMetadata({ ...record.metadata, ...form }) }); setEditOpen(false); await load(); } catch (saveError) { setError(saveError?.message || '保存失败。'); } finally { setSaving(false); } }; const openEdit = () => {
setError('');
setSaveSuccess('');
setEditOpen(true);
};
const save = async () => {
if (saving || !record) return;
setSaving(true);
setError('');
setSaveSuccess('');
try {
await updateRecord(record.name || name, {
name: record.name || name,
metadata: normalizeMetadata({ ...record.metadata, ...form }),
});
setEditOpen(false);
setSaveSuccess('详情已保存。');
await load();
} 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={load} /></div>;
const metadata = record.metadata || {}; const metadata = record.metadata || {};
const fieldOptions = (fieldName) => (options[fieldName] || []).map((item) => ({ value: item.value ?? item.label ?? item, label: item.label ?? item.value ?? item })); const fieldOptions = (fieldName) => (options[fieldName] || []).map((item) => ({ value: item.value ?? item.label ?? item, label: item.label ?? item.value ?? item }));
const referenceFields = new Set(['departments', 'approve']); const referenceFields = new Set(['departments', 'approve']);
return <div className="space-y-5"><div className="flex flex-wrap items-center justify-between gap-3"><Button variant="tertiary" onPress={() => history.back()}><Back size={16} />返回制度目录</Button><Button variant="secondary" onPress={() => setEditOpen(true)}><Edit size={16} />编辑信息</Button></div><Card className="rc-card-surface"><Card.Header><div className="flex flex-wrap items-start justify-between gap-4"><div><p className="text-sm text-muted">内部制度</p><Card.Title className="mt-1 text-2xl">{metadata.regulation_name || record.title}</Card.Title><div className="mt-3 flex flex-wrap gap-2">{metadata.effective_status ? <Chip color={metadata.effective_status === '现行有效' ? 'success' : 'default'} size="sm" variant="soft"><Chip.Label>{metadata.effective_status}</Chip.Label></Chip> : null}{metadata.level ? <Chip size="sm" variant="soft"><Chip.Label>{metadata.level}</Chip.Label></Chip> : null}</div></div><div className="text-right text-sm text-muted">发文日期<br /><span className="font-medium text-foreground">{dateText(metadata.public_date)}</span></div></div></Card.Header><Card.Content><dl className="grid gap-x-8 gap-y-5 sm:grid-cols-2">{fields.map((field) => { const value = metadata[field.name]; return <div key={field.name}><dt className="text-sm font-medium text-muted">{field.label || field.name}</dt><dd className="mt-1 whitespace-pre-wrap text-sm">{Array.isArray(value) ? value.map(referenceId).join('、') : field.type === 'date' ? dateText(value) : typeof value === 'object' ? referenceId(value) : value || '-'}</dd></div>; })}</dl></Card.Content></Card><div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]"><Card className="rc-card-surface"><Card.Header><Card.Title className="flex items-center gap-2"><FileText size={18} />制度正文</Card.Title></Card.Header><Card.Content><article className="prose prose-slate max-w-none whitespace-pre-wrap text-sm leading-7" dangerouslySetInnerHTML={{ __html: record.content || '<p class="text-muted">暂无正文内容</p>' }} /></Card.Content></Card><Card className="rc-card-surface"><Card.Header><Card.Title>关联信息</Card.Title></Card.Header><Card.Content className="space-y-4 text-sm"><div><p className="mb-1 text-muted">附件</p>{(metadata.attachments || []).length ? metadata.attachments.map((attachment, index) => { const href = typeof attachment === 'string' ? attachment : attachment.url; return href ? <a className="flex items-center gap-2 text-accent hover:underline" href={href} key={referenceId(attachment) || index} rel="noreferrer" target="_blank"><PaperClipIcon size={15} />{attachment.title || attachment.name || `附件 ${index + 1}`}</a> : <span className="text-muted" key={referenceId(attachment) || index}>附件 {index + 1}</span>; }) : <span className="text-muted">暂无附件</span>}</div><div><p className="mb-1 text-muted">相关制度</p><span>{metadata.related_regulations?.length ? `${metadata.related_regulations.length} 条` : '暂无关联'}</span></div><div><p className="mb-1 text-muted">引用法规</p><span>{metadata.reference_regulation ? '已关联' : '暂无引用'}</span></div><div><p className="mb-1 text-muted">历史沿革</p>{historyRecords.length ? <div className="space-y-2">{historyRecords.map((item) => <div className="border-t border-separator pt-2" key={item._id || item.name}><div className="font-medium">{item.metadata?.regulation_name || item.title || '-'}</div><div className="text-muted">{dateText(item.metadata?.public_date)} · {item.metadata?.effective_status || '历史版本'}</div></div>)}</div> : <span className="text-muted">暂无历史版本</span>}</div></Card.Content></Card></div><XinyuanModal isOpen={editOpen} onOpenChange={setEditOpen} size="2xl" title="编辑制度信息" footer={<div className="flex justify-end gap-2"><Button variant="tertiary" onPress={() => setEditOpen(false)}>取消</Button><Button isDisabled={saving} variant="primary" onPress={save}>{saving ? '保存中...' : '保存'}</Button></div>}><div className="grid gap-4 sm:grid-cols-2">{fields.map((field) => { const value = form[field.name]; const selectedValues = Array.isArray(value) ? value.map(referenceId) : value ? [referenceId(value)] : []; if (field.name === 'regulation_name') return <TextField className="sm:col-span-2" fullWidth isRequired key={field.name} value={value || ''} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><Input fullWidth variant="secondary" /></TextField>; if (field.type === 'long_text' || field.name === 'comment') return <TextField className="sm:col-span-2" fullWidth key={field.name} value={value || ''} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><TextArea fullWidth rows={4} variant="secondary" /></TextField>; if (referenceFields.has(field.name)) return <XinyuanAutocomplete key={field.name} label={field.label || field.name} options={fieldOptions(field.name)} selectedKeys={new Set(selectedValues)} selectionMode="multiple" onSelectionChange={(next) => setForm((current) => ({ ...current, [field.name]: next === 'all' ? fieldOptions(field.name).map((item) => item.value) : Array.from(next || []) }))} />; if (field.name === 'level' || field.name === 'effective_status') return <XinyuanAutocomplete key={field.name} label={field.label || field.name} options={fieldOptions(field.name)} selectedKey={value || ''} onSelectionChange={(next) => setForm((current) => ({ ...current, [field.name]: String(next) }))} />; if (field.type === 'date' || ['public_date', 'effective_date'].includes(field.name)) return <TextField fullWidth key={field.name} value={dateInputValue(value)} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><Input fullWidth type="date" variant="secondary" /></TextField>; return <TextField fullWidth key={field.name} value={Array.isArray(value) ? value.join('、') : value || ''} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><Input fullWidth variant="secondary" /></TextField>; })}</div></XinyuanModal></div>; return <div className="space-y-5"><div className="flex flex-wrap items-center justify-between gap-3"><Button variant="tertiary" onPress={() => history.back()}><Back size={16} />返回制度目录</Button><Button variant="secondary" onPress={openEdit}><Edit size={16} />编辑信息</Button></div><Card className="rc-card-surface"><Card.Header><div className="flex flex-wrap items-start justify-between gap-4"><div><p className="text-sm text-muted">内部制度</p><Card.Title className="mt-1 text-2xl">{metadata.regulation_name || record.title}</Card.Title><div className="mt-3 flex flex-wrap gap-2">{metadata.effective_status ? <Chip color={metadata.effective_status === '现行有效' ? 'success' : 'default'} size="sm" variant="soft"><Chip.Label>{metadata.effective_status}</Chip.Label></Chip> : null}{metadata.level ? <Chip size="sm" variant="soft"><Chip.Label>{metadata.level}</Chip.Label></Chip> : null}</div></div><div className="text-right text-sm text-muted">发文日期<br /><span className="font-medium text-foreground">{dateText(metadata.public_date)}</span></div></div></Card.Header><Card.Content><dl className="grid gap-x-8 gap-y-5 sm:grid-cols-2">{fields.map((field) => { const value = metadata[field.name]; return <div key={field.name}><dt className="text-sm font-medium text-muted">{field.label || field.name}</dt><dd className="mt-1 whitespace-pre-wrap text-sm">{Array.isArray(value) ? value.map(referenceId).join('、') : field.type === 'date' ? dateText(value) : typeof value === 'object' ? referenceId(value) : value || '-'}</dd></div>; })}</dl></Card.Content></Card><div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]"><Card className="rc-card-surface"><Card.Header><Card.Title className="flex items-center gap-2"><FileText size={18} />制度正文</Card.Title></Card.Header><Card.Content><article className="prose prose-slate max-w-none whitespace-pre-wrap text-sm leading-7" dangerouslySetInnerHTML={{ __html: record.content || '<p class="text-muted">暂无正文内容</p>' }} /></Card.Content></Card><Card className="rc-card-surface"><Card.Header><Card.Title>关联信息</Card.Title></Card.Header><Card.Content className="space-y-4 text-sm"><div><p className="mb-1 text-muted">附件</p>{(metadata.attachments || []).length ? metadata.attachments.map((attachment, index) => { const href = typeof attachment === 'string' ? attachment : attachment.url; return href ? <a className="flex items-center gap-2 text-accent hover:underline" href={href} key={referenceId(attachment) || index} rel="noreferrer" target="_blank"><PaperClipIcon size={15} />{attachment.title || attachment.name || `附件 ${index + 1}`}</a> : <span className="text-muted" key={referenceId(attachment) || index}>附件 {index + 1}</span>; }) : <span className="text-muted">暂无附件</span>}</div><div><p className="mb-1 text-muted">相关制度</p><span>{metadata.related_regulations?.length ? `${metadata.related_regulations.length} 条` : '暂无关联'}</span></div><div><p className="mb-1 text-muted">引用法规</p><span>{metadata.reference_regulation ? '已关联' : '暂无引用'}</span></div><div><p className="mb-1 text-muted">历史沿革</p>{historyRecords.length ? <div className="space-y-2">{historyRecords.map((item) => <div className="border-t border-separator pt-2" key={item._id || item.name}><div className="font-medium">{item.metadata?.regulation_name || item.title || '-'}</div><div className="text-muted">{dateText(item.metadata?.public_date)} · {item.metadata?.effective_status || '历史版本'}</div></div>)}</div> : <span className="text-muted">暂无历史版本</span>}</div></Card.Content></Card></div><XinyuanModal isOpen={editOpen} onOpenChange={setEditOpen} size="2xl" title="编辑制度信息" footer={<div className="flex justify-end gap-2"><Button variant="tertiary" onPress={() => setEditOpen(false)}>取消</Button><Button isDisabled={saving} variant="primary" onPress={save}>{saving ? '保存中...' : '保存'}</Button></div>}><div className="grid gap-4 sm:grid-cols-2">{fields.map((field) => { const value = form[field.name]; const selectedValues = Array.isArray(value) ? value.map(referenceId) : value ? [referenceId(value)] : []; if (field.name === 'regulation_name') return <TextField className="sm:col-span-2" fullWidth isRequired key={field.name} value={value || ''} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><Input fullWidth variant="secondary" /></TextField>; if (field.type === 'long_text' || field.name === 'comment') return <TextField className="sm:col-span-2" fullWidth key={field.name} value={value || ''} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><TextArea fullWidth rows={4} variant="secondary" /></TextField>; if (referenceFields.has(field.name)) return <XinyuanAutocomplete key={field.name} label={field.label || field.name} options={fieldOptions(field.name)} selectedKeys={new Set(selectedValues)} selectionMode="multiple" onSelectionChange={(next) => setForm((current) => ({ ...current, [field.name]: next === 'all' ? fieldOptions(field.name).map((item) => item.value) : Array.from(next || []) }))} />; if (field.name === 'level' || field.name === 'effective_status') return <XinyuanAutocomplete key={field.name} label={field.label || field.name} options={fieldOptions(field.name)} selectedKey={value || ''} onSelectionChange={(next) => setForm((current) => ({ ...current, [field.name]: String(next) }))} />; if (field.type === 'date' || ['public_date', 'effective_date'].includes(field.name)) return <TextField fullWidth key={field.name} value={dateInputValue(value)} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><Input fullWidth type="date" variant="secondary" /></TextField>; return <TextField fullWidth key={field.name} value={Array.isArray(value) ? value.join('、') : value || ''} onChange={(next) => setForm((current) => ({ ...current, [field.name]: next }))}><Label>{field.label || field.name}</Label><Input fullWidth variant="secondary" /></TextField>; })}</div></XinyuanModal></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, Checkbox, Chip, Dropdown, Input, Label, Spinner, Tabs, TextArea, TextField, Tooltip, toast } from '@heroui/react'; import { Button, Checkbox, Chip, Dropdown, Input, Label, Spinner, Tabs, TextArea, TextField, Tooltip, toast } from '@heroui/react';
import dagre from 'dagre'; import dagre from 'dagre';
import JSZip from 'jszip'; import JSZip from 'jszip';
...@@ -9,7 +11,6 @@ import { ...@@ -9,7 +11,6 @@ import {
ArrowPathIcon, ArrowPathIcon,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Delete,
Download, Download,
Edit, Edit,
Expand, Expand,
...@@ -27,6 +28,7 @@ import { attachGraphAncestors } from '@/services/regulationGraphData'; ...@@ -27,6 +28,7 @@ import { attachGraphAncestors } from '@/services/regulationGraphData';
import { isRegulationHistory, isSameRegulation } from '@/services/regulationHistory'; import { isRegulationHistory, isSameRegulation } from '@/services/regulationHistory';
import { createGraphXmindArchive } from '@/services/regulationGraphXmind'; import { createGraphXmindArchive } from '@/services/regulationGraphXmind';
import GraphNode from './GraphNode'; import GraphNode from './GraphNode';
import GraphNodeDetails from './GraphNodeDetails';
import { getGraphRecordId, getNextGraphRecordNode, getSelectedGraphRecordId } from './graphSelection'; import { getGraphRecordId, getNextGraphRecordNode, getSelectedGraphRecordId } from './graphSelection';
import { findGraphDropTarget, REGULATION_DRAG_TYPE } from './graphDrop'; import { findGraphDropTarget, REGULATION_DRAG_TYPE } from './graphDrop';
import { getGraphBranchIds, isGraphDeleteShortcut, setGraphBranchRemoved } from './graphRemoval'; import { getGraphBranchIds, isGraphDeleteShortcut, setGraphBranchRemoved } from './graphRemoval';
...@@ -251,8 +253,9 @@ export default function RegulationGraph() { ...@@ -251,8 +253,9 @@ export default function RegulationGraph() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [notice, setNotice] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [, setNotice] = useFeedbackState('success');
const [contextMenu, setContextMenu] = useState(null); const [contextMenu, setContextMenu] = useState(null);
const [nodeDialog, setNodeDialog] = useState(null); const [nodeDialog, setNodeDialog] = useState(null);
const [nodeForm, setNodeForm] = useState({ title: '', content: '' }); const [nodeForm, setNodeForm] = useState({ title: '', content: '' });
...@@ -285,6 +288,8 @@ export default function RegulationGraph() { ...@@ -285,6 +288,8 @@ export default function RegulationGraph() {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
const [nextGraph, nextArchives, nextRegulations, nextRevisions] = await Promise.all([loadGraph(), loadGraphArchives(), queryAll(REGULATION_SUBJECT, {}), queryAll(REVISION_SUBJECT, { filter: { 'metadata.status': { $in: ['起草中', '已提交', '已发文'] } } })]); const [nextGraph, nextArchives, nextRegulations, nextRevisions] = await Promise.all([loadGraph(), loadGraphArchives(), queryAll(REGULATION_SUBJECT, {}), queryAll(REVISION_SUBJECT, { filter: { 'metadata.status': { $in: ['起草中', '已提交', '已发文'] } } })]);
setRegulations(nextRegulations); setRegulations(nextRegulations);
setRevisions(nextRevisions); setRevisions(nextRevisions);
...@@ -292,15 +297,11 @@ export default function RegulationGraph() { ...@@ -292,15 +297,11 @@ export default function RegulationGraph() {
setArchives([...new Set(['当前版本', ...nextArchives.filter((value) => value !== '当前版本')])]); setArchives([...new Set(['当前版本', ...nextArchives.filter((value) => value !== '当前版本')])]);
setError(''); setError('');
} catch (loadError) { } catch (loadError) {
setLoadFailed(true);
setError(loadError?.message || '拓扑图加载失败,请稍后重试。'); setError(loadError?.message || '拓扑图加载失败,请稍后重试。');
} finally { setLoading(false); } } finally { setLoading(false); }
}, [applyGraph]); }, [applyGraph, setError]);
useEffect(() => { refresh(); }, [refresh]); useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
if (!notice) return undefined;
const timer = window.setTimeout(() => setNotice(''), 4000);
return () => window.clearTimeout(timer);
}, [notice]);
const openContextMenu = useCallback((event, nodeId = null) => { const openContextMenu = useCallback((event, nodeId = null) => {
event.preventDefault(); event.preventDefault();
...@@ -490,7 +491,7 @@ export default function RegulationGraph() { ...@@ -490,7 +491,7 @@ export default function RegulationGraph() {
const focusRecordNode = useCallback((record) => { const focusRecordNode = useCallback((record) => {
const node = getNextGraphRecordNode(record, graph.nodes, selectedId, selectedRecordId); const node = getNextGraphRecordNode(record, graph.nodes, selectedId, selectedRecordId);
if (!node) { if (!node) {
toast.warning('该制度在当前拓扑图中暂无关联节点。'); toast.warning('该制度在当前拓扑图中暂无关联节点。', { timeout: 8000 });
return; return;
} }
focusNode(node, getGraphRecordId(record)); focusNode(node, getGraphRecordId(record));
...@@ -608,7 +609,7 @@ export default function RegulationGraph() { ...@@ -608,7 +609,7 @@ export default function RegulationGraph() {
if (archive !== '当前版本' || saving) return; if (archive !== '当前版本' || saving) return;
const target = getDropTarget(event); const target = getDropTarget(event);
if (!target) { if (!target) {
toast.warning('请拖到目标节点右侧的连接区域。'); toast.warning('请拖到目标节点右侧的连接区域。', { timeout: 8000 });
return; return;
} }
try { try {
...@@ -645,6 +646,8 @@ export default function RegulationGraph() { ...@@ -645,6 +646,8 @@ export default function RegulationGraph() {
setSelectedGroupIds([]); setSelectedGroupIds([]);
setSelectedStatuses([]); setSelectedStatuses([]);
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
const archivedGraph = await loadGraphArchive(value); const archivedGraph = await loadGraphArchive(value);
// The current version can have a stale/empty graph record after a fresh // The current version can have a stale/empty graph record after a fresh
// deployment. Reuse the same regulation-reference fallback as the first // deployment. Reuse the same regulation-reference fallback as the first
...@@ -655,6 +658,7 @@ export default function RegulationGraph() { ...@@ -655,6 +658,7 @@ export default function RegulationGraph() {
applyGraph(enrichGraph(nextGraph, regulations, revisions)); applyGraph(enrichGraph(nextGraph, regulations, revisions));
setError(''); setError('');
} catch (loadError) { } catch (loadError) {
setLoadFailed(true);
setError(loadError?.message || '归档拓扑图加载失败。'); setError(loadError?.message || '归档拓扑图加载失败。');
} finally { } finally {
setLoading(false); setLoading(false);
...@@ -662,13 +666,12 @@ export default function RegulationGraph() { ...@@ -662,13 +666,12 @@ export default function RegulationGraph() {
}; };
const listNodes = activePanel === 'all' ? allNodes : activePanel === 'pending' ? pendingNodes : activePanel === 'removed' ? removedNodes : filteredNodes; const listNodes = activePanel === 'all' ? allNodes : activePanel === 'pending' ? pendingNodes : activePanel === 'removed' ? removedNodes : filteredNodes;
if (loadFailed && !loading) return <RequestFailure title="拓扑图加载失败" onRetry={() => archive === '当前版本' ? refresh() : loadArchive(archive)} />;
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>;
return ( return (
<div className="flex h-[calc(100dvh-7.5rem)] min-h-0 flex-col gap-4 overflow-hidden" onClick={() => setContextMenu(null)}> <div className="flex h-[calc(100dvh-7.5rem)] min-h-0 flex-col gap-4 overflow-hidden" onClick={() => setContextMenu(null)}>
{error ? <div role="alert" className="rounded-xl border border-danger/30 bg-danger/5 p-3 text-sm text-danger">{error}</div> : null}
{notice ? <div role="status" className="rounded-xl border border-success/30 bg-success/5 p-3 text-sm text-success">{notice}</div> : null}
<div className="relative flex min-h-0 flex-1 overflow-hidden rounded-2xl border border-separator bg-background shadow-sm" ref={canvasRef}> <div className="relative flex min-h-0 flex-1 overflow-hidden rounded-2xl border border-separator bg-background shadow-sm" ref={canvasRef}>
<aside className={`shrink-0 overflow-hidden border-e border-separator transition-[width] ${leftOpen ? 'w-72' : 'w-0'}`}><div className="flex h-full w-72 flex-col"><div className="border-b border-separator p-3"><TextField fullWidth value={searchText} onChange={setSearchText}><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></div><Tabs className="min-w-0" selectedKey={activePanel} variant="primary" onSelectionChange={(key) => setActivePanel(String(key))}><Tabs.ListContainer className="w-full min-w-0 overflow-hidden border-b border-separator px-2"><Tabs.List className="grid w-full min-w-0 grid-cols-4" style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', width: '100%' }} aria-label="拓扑图节点列表"><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="all">全部<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="pending">待分类<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="nodes">节点<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="removed">删除节点<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer></Tabs><div className="min-h-0 flex-1 overflow-y-auto p-2">{listNodes.map((node) => { const isRecordList = activePanel === 'all' || activePanel === 'pending'; const isSelected = isRecordList ? highlightedRecordId !== null && getGraphRecordId(node) === highlightedRecordId : selectedId === node.id; const title = isRecordList ? metadataOf(node).regulation_name || node.title : node.title; const type = isRecordList ? node.__pendingStatus || (metadataOf(node).type === '新增' ? 'addition' : metadataOf(node).type === '废止' ? 'forbidden' : 'revise') : nodeStatus(node); return <button draggable={isRecordList && archive === '当前版本' && !saving} onDragStart={(event) => { event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.setData(REGULATION_DRAG_TYPE, JSON.stringify(node)); }} onDragEnd={clearDropPreview} className={`flex min-h-11 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-surface-secondary ${isSelected ? 'bg-accent/10 text-accent' : ''}`} aria-current={isSelected ? 'true' : undefined} key={node._id || node.id || node.name} onClick={() => isRecordList ? focusRecordNode(node) : focusNode(node)}><span className="min-w-0 flex-1 truncate">{title}</span><Chip color={STATUS_OPTIONS.find((item) => item.value === type)?.color || 'default'} size="sm" variant="soft"><Chip.Label>{isRecordList ? (type === 'active' ? '现行有效' : `待${metadataOf(node).type || '处理'}`) : nodeTypeLabel(node.type)}</Chip.Label></Chip></button>; })}{listNodes.length === 0 ? <p className="p-4 text-center text-sm text-muted">暂无节点</p> : null}</div></div></aside> <aside className={`min-w-0 shrink overflow-hidden border-e border-separator transition-[width] ${leftOpen ? 'w-72' : 'w-0'}`}><div className="flex h-full w-full min-w-0 flex-col"><div className="border-b border-separator p-3"><TextField fullWidth value={searchText} onChange={setSearchText}><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></div><Tabs className="min-w-0" selectedKey={activePanel} variant="primary" onSelectionChange={(key) => setActivePanel(String(key))}><Tabs.ListContainer className="w-full min-w-0 overflow-hidden border-b border-separator px-2"><Tabs.List className="grid w-full min-w-0 grid-cols-4" style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', width: '100%' }} aria-label="拓扑图节点列表"><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="all">全部<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="pending">待分类<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="nodes">节点<Tabs.Indicator /></Tabs.Tab><Tabs.Tab className="min-w-0 whitespace-nowrap px-1 text-xs" style={{ minWidth: 0, whiteSpace: 'nowrap', paddingInline: 4 }} id="removed">删除节点<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer></Tabs><div className="min-h-0 flex-1 overflow-y-auto p-2">{listNodes.map((node) => { const isRecordList = activePanel === 'all' || activePanel === 'pending'; const isSelected = isRecordList ? highlightedRecordId !== null && getGraphRecordId(node) === highlightedRecordId : selectedId === node.id; const title = isRecordList ? metadataOf(node).regulation_name || node.title : node.title; const type = isRecordList ? node.__pendingStatus || (metadataOf(node).type === '新增' ? 'addition' : metadataOf(node).type === '废止' ? 'forbidden' : 'revise') : nodeStatus(node); return <button draggable={isRecordList && archive === '当前版本' && !saving} onDragStart={(event) => { event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.setData(REGULATION_DRAG_TYPE, JSON.stringify(node)); }} onDragEnd={clearDropPreview} className={`flex min-h-11 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-surface-secondary ${isSelected ? 'bg-accent/10 text-accent' : ''}`} aria-current={isSelected ? 'true' : undefined} key={node._id || node.id || node.name} onClick={() => isRecordList ? focusRecordNode(node) : focusNode(node)}><span className="min-w-0 flex-1 truncate">{title}</span><Chip color={STATUS_OPTIONS.find((item) => item.value === type)?.color || 'default'} size="sm" variant="soft"><Chip.Label>{isRecordList ? (type === 'active' ? '现行有效' : `待${metadataOf(node).type || '处理'}`) : nodeTypeLabel(node.type)}</Chip.Label></Chip></button>; })}{listNodes.length === 0 ? <p className="p-4 text-center text-sm text-muted">暂无节点</p> : null}</div></div></aside>
<main ref={graphCanvasRef} className="relative min-h-0 min-w-0 flex-1 bg-surface-secondary/30" onDragOver={previewRecordDrop} onDragLeave={leaveRecordDrop} onDrop={dropPendingRevision}><div className="absolute inset-x-3 top-3 z-10 flex flex-wrap items-center justify-between gap-2"><div className="flex flex-wrap items-center gap-2 rounded-xl border border-separator bg-background/95 p-2 shadow-sm backdrop-blur"><XinyuanAutocomplete className="w-40" aria-label="归档版本" options={archives.map((value) => ({ value, label: value }))} selectedKey={archive} onSelectionChange={(value) => loadArchive(String(value))} /><ProcessCascade tree={processTree} value={selectedGroupIds} onChange={setSelectedGroupIds} /><Checkbox isSelected={showCurrent} variant="secondary" onChange={setShowCurrent}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control></Checkbox.Content><Label>显示现行</Label></Checkbox><Checkbox isSelected={showRemoved} variant="secondary" onChange={setShowRemoved}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control></Checkbox.Content><Label>显示已移除</Label></Checkbox>{showCurrent ? <div className="flex items-center gap-1">{STATUS_OPTIONS.map((option) => <button className={`rounded-md px-2 py-1 text-xs ${selectedStatuses.includes(option.value) ? 'bg-accent/10 text-accent' : 'text-muted hover:bg-surface-secondary'}`} key={option.value} onClick={() => setSelectedStatuses((current) => current.includes(option.value) ? current.filter((value) => value !== option.value) : [...current, option.value])}>{option.label}</button>)}</div> : null}</div><div className="flex items-center gap-1 rounded-xl border border-separator bg-background/95 p-1 shadow-sm backdrop-blur"><Tooltip delay={0}><Button isIconOnly aria-label="恢复视图" size="sm" variant="tertiary" onPress={restoreView}><Reload size={16} /></Button><Tooltip.Content>恢复视图</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="展开全部" size="sm" variant="tertiary" onPress={() => setCollapsedIds(new Set())}><Expand size={16} /></Button><Tooltip.Content>展开全部</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="折叠全部" size="sm" variant="tertiary" onPress={() => setCollapsedIds(defaultCollapsedIds(graph.nodes))}><ArrowPathIcon size={16} /></Button><Tooltip.Content>折叠到第一层</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="全屏查看" size="sm" variant="tertiary" onPress={toggleFullscreen}><Fullscreen size={16} /></Button><Tooltip.Content>全屏查看</Tooltip.Content></Tooltip></div> <main ref={graphCanvasRef} className="relative min-h-0 min-w-0 flex-1 bg-surface-secondary/30" onDragOver={previewRecordDrop} onDragLeave={leaveRecordDrop} onDrop={dropPendingRevision}><div className="absolute inset-x-3 top-3 z-10 flex flex-wrap items-center justify-between gap-2"><div className="flex flex-wrap items-center gap-2 rounded-xl border border-separator bg-background/95 p-2 shadow-sm backdrop-blur"><XinyuanAutocomplete className="w-40" aria-label="归档版本" options={archives.map((value) => ({ value, label: value }))} selectedKey={archive} onSelectionChange={(value) => loadArchive(String(value))} /><ProcessCascade tree={processTree} value={selectedGroupIds} onChange={setSelectedGroupIds} /><Checkbox isSelected={showCurrent} variant="secondary" onChange={setShowCurrent}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control></Checkbox.Content><Label>显示现行</Label></Checkbox><Checkbox isSelected={showRemoved} variant="secondary" onChange={setShowRemoved}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control></Checkbox.Content><Label>显示已移除</Label></Checkbox>{showCurrent ? <div className="flex items-center gap-1">{STATUS_OPTIONS.map((option) => <button className={`rounded-md px-2 py-1 text-xs ${selectedStatuses.includes(option.value) ? 'bg-accent/10 text-accent' : 'text-muted hover:bg-surface-secondary'}`} key={option.value} onClick={() => setSelectedStatuses((current) => current.includes(option.value) ? current.filter((value) => value !== option.value) : [...current, option.value])}>{option.label}</button>)}</div> : null}</div><div className="flex items-center gap-1 rounded-xl border border-separator bg-background/95 p-1 shadow-sm backdrop-blur"><Tooltip delay={0}><Button isIconOnly aria-label="恢复视图" size="sm" variant="tertiary" onPress={restoreView}><Reload size={16} /></Button><Tooltip.Content>恢复视图</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="展开全部" size="sm" variant="tertiary" onPress={() => setCollapsedIds(new Set())}><Expand size={16} /></Button><Tooltip.Content>展开全部</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="折叠全部" size="sm" variant="tertiary" onPress={() => setCollapsedIds(defaultCollapsedIds(graph.nodes))}><ArrowPathIcon size={16} /></Button><Tooltip.Content>折叠到第一层</Tooltip.Content></Tooltip><Tooltip delay={0}><Button isIconOnly aria-label="全屏查看" size="sm" variant="tertiary" onPress={toggleFullscreen}><Fullscreen size={16} /></Button><Tooltip.Content>全屏查看</Tooltip.Content></Tooltip></div>
</div><div className="relative h-full min-h-0 w-full" onContextMenu={(event) => event.preventDefault()}>{visibleNodes.length ? <ReactFlowProvider><ReactFlow nodes={flowNodes} edges={visibleEdges} deleteKeyCode={null} nodeTypes={NODE_TYPES} fitView onInit={(instance) => { flowRef.current = instance; instance.fitView({ padding: 0.22 }); }} onNodeClick={(_, node) => selectNode(nodeById.get(String(node.id)))} onNodeDoubleClick={(_, node) => { const current = nodeById.get(String(node.id)); const relatedId = current?.relatedRegulation || current?.relatedReviseRegulation; if (relatedId) history.push(`/internal_regulation/${relatedId}`); }} onNodeDragStop={(_, node) => { setDraggedPositions((current) => ({ ...current, [node.id]: node.position })); setGraph((current) => ({ ...current, nodes: current.nodes.map((item) => item.id === node.id ? { ...item, position: node.position } : item) })); }} onNodeContextMenu={(event, node) => openContextMenu(event, String(node.id))} onPaneContextMenu={openContextMenu} onMoveStart={() => setContextMenu(null)} onNodeDragStart={() => setContextMenu(null)} onPaneClick={() => { setContextMenu(null); clearNodeSelection(); }} proOptions={{ hideAttribution: true }}><MiniMap pannable zoomable /><Controls showInteractive={false} /><Background color="#e2e8f0" gap={24} /></ReactFlow></ReactFlowProvider> : <div className="flex h-full min-h-0 items-center justify-center text-sm text-muted">暂无可展示的拓扑节点</div>} </div><div className="relative h-full min-h-0 w-full" onContextMenu={(event) => event.preventDefault()}>{visibleNodes.length ? <ReactFlowProvider><ReactFlow nodes={flowNodes} edges={visibleEdges} deleteKeyCode={null} nodeTypes={NODE_TYPES} fitView onInit={(instance) => { flowRef.current = instance; instance.fitView({ padding: 0.22 }); }} onNodeClick={(_, node) => selectNode(nodeById.get(String(node.id)))} onNodeDoubleClick={(_, node) => { const current = nodeById.get(String(node.id)); const relatedId = current?.relatedRegulation || current?.relatedReviseRegulation; if (relatedId) history.push(`/internal_regulation/${relatedId}`); }} onNodeDragStop={(_, node) => { setDraggedPositions((current) => ({ ...current, [node.id]: node.position })); setGraph((current) => ({ ...current, nodes: current.nodes.map((item) => item.id === node.id ? { ...item, position: node.position } : item) })); }} onNodeContextMenu={(event, node) => openContextMenu(event, String(node.id))} onPaneContextMenu={openContextMenu} onMoveStart={() => setContextMenu(null)} onNodeDragStart={() => setContextMenu(null)} onPaneClick={() => { setContextMenu(null); clearNodeSelection(); }} proOptions={{ hideAttribution: true }}><MiniMap pannable zoomable /><Controls showInteractive={false} /><Background color="#e2e8f0" gap={24} /></ReactFlow></ReactFlowProvider> : <div className="flex h-full min-h-0 items-center justify-center text-sm text-muted">暂无可展示的拓扑节点</div>}
...@@ -698,8 +701,8 @@ export default function RegulationGraph() { ...@@ -698,8 +701,8 @@ export default function RegulationGraph() {
</div> </div>
) : null} ) : null}
<button aria-label={leftOpen ? '收起左侧节点列表' : '展开左侧节点列表'} className="absolute left-1 top-1/2 z-20 grid size-8 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-separator bg-background shadow-sm" onClick={() => setLeftOpen((value) => !value)}><ChevronRight className={leftOpen ? 'rotate-180' : ''} size={15} /></button></main> <button aria-label={leftOpen ? '收起左侧节点列表' : '展开左侧节点列表'} className="absolute left-1 top-1/2 z-20 grid size-8 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-separator bg-background shadow-sm" onClick={() => setLeftOpen((value) => !value)}><ChevronRight className={leftOpen ? 'rotate-180' : ''} size={15} /></button></main>
<aside aria-label="拓扑图操作与节点详情" className={`relative shrink-0 transition-[width] ${rightOpen ? 'w-80 border-s border-separator' : 'w-0'}`}> <aside aria-label="拓扑图操作与节点详情" className={`relative max-w-full shrink-0 transition-[width] ${rightOpen ? 'w-80 border-s border-separator' : 'w-0'}`}>
<div className={`absolute right-0 top-0 z-10 flex w-80 flex-col bg-background ${rightOpen ? 'bottom-0' : 'rounded-bl-xl border-s border-separator shadow-sm'}`}> <div className={`absolute right-0 top-0 z-10 flex flex-col bg-background ${rightOpen ? 'bottom-0 w-full' : 'w-80 max-w-[calc(100vw-2rem)] rounded-bl-xl border-s border-separator shadow-sm'}`}>
<div aria-label="拓扑图操作" role="group" className="grid shrink-0 grid-cols-3 items-center gap-2 border-b border-separator p-3"> <div aria-label="拓扑图操作" role="group" className="grid shrink-0 grid-cols-3 items-center gap-2 border-b border-separator p-3">
<Button aria-label="导入 XMind" className="h-9 min-w-0 gap-1.5 whitespace-nowrap px-2" fullWidth isDisabled={saving || archive !== '当前版本'} size="sm" variant="secondary" onPress={() => importInputRef.current?.click()}><Upload className="shrink-0" size={15} /><span>导入</span></Button> <Button aria-label="导入 XMind" className="h-9 min-w-0 gap-1.5 whitespace-nowrap px-2" fullWidth isDisabled={saving || archive !== '当前版本'} size="sm" variant="secondary" onPress={() => importInputRef.current?.click()}><Upload className="shrink-0" size={15} /><span>导入</span></Button>
<Dropdown> <Dropdown>
...@@ -714,32 +717,29 @@ export default function RegulationGraph() { ...@@ -714,32 +717,29 @@ export default function RegulationGraph() {
<Button className="h-9 min-w-0 gap-1.5 whitespace-nowrap px-2" fullWidth isDisabled={saving || archive !== '当前版本'} size="sm" variant="primary" onPress={save}><Save className="shrink-0" size={15} /><span>保存</span></Button> <Button className="h-9 min-w-0 gap-1.5 whitespace-nowrap px-2" fullWidth isDisabled={saving || archive !== '当前版本'} size="sm" variant="primary" onPress={save}><Save className="shrink-0" size={15} /><span>保存</span></Button>
</div> </div>
<input accept=".xmind,.json" aria-label="导入 XMind 文件" disabled={saving || archive !== '当前版本'} hidden ref={importInputRef} type="file" onChange={importGraph} /> <input accept=".xmind,.json" aria-label="导入 XMind 文件" disabled={saving || archive !== '当前版本'} hidden ref={importInputRef} type="file" onChange={importGraph} />
<div className={`min-h-0 flex-1 flex-col ${rightOpen ? 'flex' : 'hidden'}`}>{selectedNode ? <><div className="flex shrink-0 items-center justify-between border-b border-separator p-4"><div><p className="text-xs text-muted">{nodeTypeLabel(selectedNode.type)}</p><h2 className="mt-1 max-w-56 truncate font-semibold">{selectedNode.title}</h2></div><Button isIconOnly aria-label="清除选择" size="sm" variant="ghost" onPress={clearNodeSelection}>×</Button></div><div className="min-h-0 flex-1 overflow-y-auto p-4"><div className="space-y-4"><TextField fullWidth value={selectedNode.title} onChange={(value) => setNodeField('title', value)}><Label>标题</Label><Input fullWidth variant="secondary" /></TextField><TextField fullWidth value={selectedNode.content || ''} onChange={(value) => setNodeField('content', value)}><Label>备注</Label><TextArea fullWidth rows={5} variant="secondary" /></TextField><div><Label>节点状态</Label><div className="mt-2 flex flex-wrap gap-2">{selectedNode.removed ? <Chip color="danger" size="sm" variant="soft"><Chip.Label>已删除</Chip.Label></Chip> : null}<Chip color={STATUS_OPTIONS.find((item) => item.value === nodeStatus(selectedNode))?.color || 'default'} size="sm" variant="soft"><Chip.Label>{STATUS_OPTIONS.find((item) => item.value === nodeStatus(selectedNode))?.label || nodeTypeLabel(selectedNode.type)}</Chip.Label></Chip></div></div> <div className={`min-h-0 flex-1 flex-col ${rightOpen ? 'flex' : 'hidden'}`}>
<div> {selectedNode ? (
<Label>节点操作</Label> <GraphNodeDetails
<div className="mt-2 grid gap-2"> key={selectedNode.id}
<Button variant="secondary" onPress={() => toggleCollapsed(selectedNode.id)}>{collapsedIds.has(selectedNode.id) ? '展开子节点' : '折叠子节点'}</Button> node={selectedNode}
<Button isDisabled={archive !== '当前版本' || saving || selectedNode.removed} variant="tertiary" onPress={() => openAddNode(selectedNode.id, 'group')}><PlusIcon size={16} />添加流程节点</Button> typeLabel={nodeTypeLabel(selectedNode.type)}
{selectedNode.removed ? ( status={STATUS_OPTIONS.find((item) => item.value === nodeStatus(selectedNode)) || { label: nodeTypeLabel(selectedNode.type), color: 'default' }}
<Button isDisabled={archive !== '当前版本' || saving} variant="secondary" onPress={restoreSelectedNode}> isCollapsed={collapsedIds.has(selectedNode.id)}
<Reload size={16} />{selectedBranchIds.size > 1 ? '恢复节点及子节点' : '恢复节点'} isArchived={archive !== '当前版本'}
</Button> isSaving={saving}
hasDescendants={selectedBranchIds.size > 1}
onClearSelection={clearNodeSelection}
onFieldChange={setNodeField}
onToggleCollapsed={() => toggleCollapsed(selectedNode.id)}
onAddNode={() => openAddNode(selectedNode.id, 'group')}
onDeleteNode={deleteSelectedNode}
onRestoreNode={restoreSelectedNode}
onOpenRegulation={(id) => history.push(`/internal_regulation/${id}`)}
/>
) : ( ) : (
<> <div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted">点击中间节点查看编辑和流程信息</div>
<Button aria-keyshortcuts="Delete Backspace" isDisabled={archive !== '当前版本' || saving} variant="danger-soft" onPress={deleteSelectedNode}>
<Delete size={16} />删除节点
</Button>
<p className="text-xs text-muted">
{selectedBranchIds.size > 1 ? '删除会同时移除所有子节点,可在“删除节点”列表恢复。' : '选中后可按 Delete(Mac ⌫)删除,可在“删除节点”列表恢复。'}
</p>
</>
)} )}
{selectedNode.relatedRegulation || selectedNode.relatedReviseRegulation ? (
<Button variant="tertiary" onPress={() => history.push(`/internal_regulation/${selectedNode.relatedRegulation || selectedNode.relatedReviseRegulation}`)}><Edit size={16} />查看关联制度</Button>
) : null}
</div>
</div> </div>
<Tabs defaultSelectedKey="process" variant="primary"><Tabs.ListContainer className="border-b border-separator"><Tabs.List aria-label="节点信息"><Tabs.Tab id="process">流程<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id="regulation">制度<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id="history">历史<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer><Tabs.Panel className="pt-4" id="process"><div className="space-y-2 text-sm text-muted">{selectedNode.ancestors.filter((item) => item.type === 'groupNode').map((item) => <div className="flex items-center gap-1" key={item.id}><ChevronRight size={14} />{item.title}</div>)}{!selectedNode.ancestors.some((item) => item.type === 'groupNode') ? '暂无流程路径' : null}</div></Tabs.Panel><Tabs.Panel className="pt-4" id="regulation"><div className="space-y-2 text-sm text-muted">{selectedNode.ancestors.filter((item) => item.type === 'regulationNode').map((item) => <div className="flex items-center gap-1" key={item.id}><ChevronRight size={14} />{item.title}</div>)}{!selectedNode.ancestors.some((item) => item.type === 'regulationNode') ? '暂无制度路径' : null}</div></Tabs.Panel><Tabs.Panel className="pt-4" id="history"><div className="space-y-2 text-sm text-muted">{selectedNode.historyRegulation.map((item) => <button className="block text-left text-accent hover:underline" key={item._id || item.id || item.name} onClick={() => history.push(`/internal_regulation/${item._id || item.name}`)}>{metadataOf(item).regulation_name || item.title}</button>)}{!selectedNode.historyRegulation.length ? '暂无历史版本' : null}</div></Tabs.Panel></Tabs></div></div></> : <div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted">点击中间节点查看编辑和流程信息</div>}</div>
</div> </div>
</aside> </aside>
<button aria-label={rightOpen ? '收起右侧节点详情' : '展开右侧节点详情'} className="absolute right-1 top-1/2 z-20 grid size-8 translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-separator bg-background shadow-sm" onClick={() => setRightOpen((value) => !value)}><ChevronRight className={rightOpen ? '' : 'rotate-180'} size={15} /></button> <button aria-label={rightOpen ? '收起右侧节点详情' : '展开右侧节点详情'} className="absolute right-1 top-1/2 z-20 grid size-8 translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-separator bg-background shadow-sm" onClick={() => setRightOpen((value) => !value)}><ChevronRight className={rightOpen ? '' : 'rotate-180'} size={15} /></button>
......
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 { Button, Chip, Input, Label, Spinner, TextArea, TextField } from '@heroui/react'; import RequestFailure from '@/components/RequestFailure';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Chip, Input, Label, Spinner, TextArea, TextField, toast } from '@heroui/react';
import { Archive, Check, Close, Download, Edit, FileText, PaperClipIcon, Search, Upload } from '@/components/AppIcons'; import { Archive, Check, Close, Download, Edit, FileText, PaperClipIcon, Search, Upload } from '@/components/AppIcons';
import ListPagination from '@/components/ListPagination'; import ListPagination from '@/components/ListPagination';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
...@@ -45,7 +47,8 @@ export default function RevisionList({ options = {} }) { ...@@ -45,7 +47,8 @@ export default function RevisionList({ options = {} }) {
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
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 [editing, setEditing] = useState(null); const [editing, setEditing] = useState(null);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [publishOpen, setPublishOpen] = useState(false); const [publishOpen, setPublishOpen] = useState(false);
...@@ -58,12 +61,15 @@ export default function RevisionList({ options = {} }) { ...@@ -58,12 +61,15 @@ export default function RevisionList({ options = {} }) {
const load = async () => { const load = async () => {
try { try {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError('');
const result = await loadRevisions({ status, keyword, page, pageSize }); const result = await loadRevisions({ status, keyword, page, pageSize });
setRecords(result.records); setRecords(result.records);
setTotal(result.total); setTotal(result.total);
setTotalPages(result.totalPages); setTotalPages(result.totalPages);
setError(''); setError('');
} catch (loadError) { } catch (loadError) {
setLoadFailed(true);
setError(loadError?.message || '修订计划加载失败。'); setError(loadError?.message || '修订计划加载失败。');
} finally { setLoading(false); } } finally { setLoading(false); }
}; };
...@@ -84,18 +90,18 @@ export default function RevisionList({ options = {} }) { ...@@ -84,18 +90,18 @@ export default function RevisionList({ options = {} }) {
}; };
const saveEdit = async () => { const saveEdit = async () => {
if (!form.regulation_name.trim()) return; if (!form.regulation_name.trim()) return;
try { setSaving(true); if (editing) await updateRevision(editing.name, form); else await createRevision(form); setEditOpen(false); await load(); } catch (saveError) { setError(saveError?.message || '保存失败。'); } finally { setSaving(false); } try { setSaving(true); if (editing) await updateRevision(editing.name, form); else await createRevision(form); setEditOpen(false); toast.success('修订计划已保存。', { timeout: 4000 }); await load(); } catch (saveError) { setError(saveError?.message || '保存失败。'); } finally { setSaving(false); }
}; };
const submitStatus = async (record, nextStatus) => { const submitStatus = async (record, nextStatus) => {
try { setSaving(true); await setRevisionStatus(record.name, nextStatus); await load(); } catch (actionError) { setError(actionError?.message || '状态更新失败。'); } finally { setSaving(false); } try { setSaving(true); await setRevisionStatus(record.name, nextStatus); toast.success('修订状态已更新。', { timeout: 4000 }); await load(); } catch (actionError) { setError(actionError?.message || '状态更新失败。'); } finally { setSaving(false); }
}; };
const handlePublish = async () => { const handlePublish = async () => {
if (!editing || !publishForm.public_date) return; if (!editing || !publishForm.public_date) return;
try { setSaving(true); await publishRevision(editing, publishForm); setPublishOpen(false); await load(); } catch (publishError) { setError(publishError?.message || '发文失败。'); } finally { setSaving(false); } try { setSaving(true); await publishRevision(editing, publishForm); setPublishOpen(false); toast.success('发文成功。', { timeout: 4000 }); await load(); } catch (publishError) { setError(publishError?.message || '发文失败。'); } finally { setSaving(false); }
}; };
const handleArchive = async () => { const handleArchive = async () => {
if (!archiveTag.trim()) return; if (!archiveTag.trim()) return;
try { setSaving(true); await archivePublishedRegulations(archiveTag.trim()); setArchiveOpen(false); setArchiveTag(''); } catch (archiveError) { setError(archiveError?.message || '归档失败。'); } finally { setSaving(false); } try { setSaving(true); await archivePublishedRegulations(archiveTag.trim()); setArchiveOpen(false); setArchiveTag(''); toast.success('现行有效制度已归档。', { timeout: 4000 }); } catch (archiveError) { setError(archiveError?.message || '归档失败。'); } finally { setSaving(false); }
}; };
const onUpload = async (event) => { const onUpload = async (event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
...@@ -105,6 +111,7 @@ export default function RevisionList({ options = {} }) { ...@@ -105,6 +111,7 @@ export default function RevisionList({ options = {} }) {
const body = new FormData(); body.set('title', file.name.replace(/\.[^.]+$/, '')); body.append('file', file); const body = new FormData(); body.set('title', file.name.replace(/\.[^.]+$/, '')); body.append('file', file);
const uploaded = await createDocument(body); const uploaded = await createDocument(body);
setPublishForm((current) => ({ ...current, attachments: [...current.attachments, uploaded] })); setPublishForm((current) => ({ ...current, attachments: [...current.attachments, uploaded] }));
toast.success('附件上传成功。', { timeout: 4000 });
} catch (uploadError) { setError(uploadError?.message || '附件上传失败。'); } finally { setSaving(false); } } catch (uploadError) { setError(uploadError?.message || '附件上传失败。'); } finally { setSaving(false); }
}; };
const exportList = async () => { const exportList = async () => {
...@@ -132,11 +139,11 @@ export default function RevisionList({ options = {} }) { ...@@ -132,11 +139,11 @@ export default function RevisionList({ options = {} }) {
</div> </div>
<div className="flex w-full max-w-xl items-end gap-2"><div className="min-w-0 flex-1"><TextField 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></div><Button isDisabled={loading} variant="secondary" onPress={submitSearch}>查询</Button><Button variant="tertiary" onPress={exportList}><Download size={16} />下载</Button><Button variant="secondary" onPress={() => setArchiveOpen(true)}><Archive size={16} />归档</Button></div> <div className="flex w-full max-w-xl items-end gap-2"><div className="min-w-0 flex-1"><TextField 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></div><Button isDisabled={loading} variant="secondary" onPress={submitSearch}>查询</Button><Button variant="tertiary" onPress={exportList}><Download size={16} />下载</Button><Button variant="secondary" onPress={() => setArchiveOpen(true)}><Archive size={16} />归档</Button></div>
</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={load} /> : 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-[1120px] text-left text-sm"><thead className="bg-surface-secondary text-muted"><tr>{['类型', '制度名称', 'OA流程制度', '发起日期', '审批主体', '制定部门', '附件', '状态', '操作'].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 || {}; const attachments = Array.isArray(metadata.attachments) ? metadata.attachments : metadata.attachments ? [metadata.attachments] : []; return <tr className="hover:bg-surface-secondary/50" key={record._id || record.name}><td className="px-4 py-3"><Chip color={metadata.type === '修订' ? 'warning' : metadata.type === '废止' ? 'default' : 'accent'} size="sm" variant="soft"><Chip.Label>待{metadata.type || '-'}</Chip.Label></Chip></td><td className="px-4 py-3 font-medium">{metadata.regulation_name || record.title || '-'}</td><td className="max-w-48 truncate px-4 py-3 text-muted">{metadata.regulation_name_oa || metadata.regulation_name_oa_submitted || '-'}</td><td className="px-4 py-3">{formatDate(metadata.created_at)}</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">{attachments.length ? <div className="flex flex-col gap-1">{attachments.map((attachment, index) => { const href = typeof attachment === 'string' ? attachment : attachment.url; const title = typeof attachment === 'string' ? `附件 ${index + 1}` : attachment.title || attachment.name || `附件 ${index + 1}`; return href ? <a className="flex items-center gap-1 text-accent hover:underline" href={href} key={attachment._id || attachment.name || index} rel="noreferrer" target="_blank"><PaperClipIcon size={14} />{title}</a> : <span className="text-muted" key={attachment._id || attachment.name || index}>{title}</span>; })}</div> : '-'}</td><td className="px-4 py-3"><Chip color={statusColor(metadata.status)} size="sm" variant="soft"><Chip.Label>{metadata.status}</Chip.Label></Chip></td><td className="px-4 py-3"><div className="flex items-center gap-1">{status === '起草中' ? <><Button isIconOnly aria-label="取消修订" isDisabled={saving} size="sm" variant="tertiary" onPress={() => submitStatus(record, '已取消')}><Close size={16} /></Button><Button isIconOnly aria-label="提交审核" isDisabled={saving} size="sm" variant="tertiary" onPress={() => submitStatus(record, '已提交')}><Check size={16} /></Button><Button isIconOnly aria-label="编辑修订" size="sm" variant="tertiary" onPress={() => openEdit(record)}><Edit size={16} /></Button></> : null}{status === '已提交' ? <><Button isIconOnly aria-label="取消审核" isDisabled={saving} size="sm" variant="tertiary" onPress={() => submitStatus(record, '已取消')}><Close size={16} /></Button><Button isIconOnly aria-label="提交发文" isDisabled={saving} size="sm" variant="tertiary" onPress={() => { setEditing(record); setPublishForm({ public_date: '', attachments: [], content: '' }); setPublishOpen(true); }}><FileText size={16} /></Button></> : null}{status === '已取消' ? <Button isIconOnly aria-label="查看修订" size="sm" variant="tertiary" onPress={() => openEdit(record)}><Edit size={16} /></Button> : null}</div></td></tr>; })}</tbody></table></div> <div className="overflow-x-auto rounded-2xl border border-separator"><table className="w-full min-w-[1120px] text-left text-sm"><thead className="bg-surface-secondary text-muted"><tr>{['类型', '制度名称', 'OA流程制度', '发起日期', '审批主体', '制定部门', '附件', '状态', '操作'].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 || {}; const attachments = Array.isArray(metadata.attachments) ? metadata.attachments : metadata.attachments ? [metadata.attachments] : []; return <tr className="hover:bg-surface-secondary/50" key={record._id || record.name}><td className="px-4 py-3"><Chip color={metadata.type === '修订' ? 'warning' : metadata.type === '废止' ? 'default' : 'accent'} size="sm" variant="soft"><Chip.Label>待{metadata.type || '-'}</Chip.Label></Chip></td><td className="px-4 py-3 font-medium">{metadata.regulation_name || record.title || '-'}</td><td className="max-w-48 truncate px-4 py-3 text-muted">{metadata.regulation_name_oa || metadata.regulation_name_oa_submitted || '-'}</td><td className="px-4 py-3">{formatDate(metadata.created_at)}</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">{attachments.length ? <div className="flex flex-col gap-1">{attachments.map((attachment, index) => { const href = typeof attachment === 'string' ? attachment : attachment.url; const title = typeof attachment === 'string' ? `附件 ${index + 1}` : attachment.title || attachment.name || `附件 ${index + 1}`; return href ? <a className="flex items-center gap-1 text-accent hover:underline" href={href} key={attachment._id || attachment.name || index} rel="noreferrer" target="_blank"><PaperClipIcon size={14} />{title}</a> : <span className="text-muted" key={attachment._id || attachment.name || index}>{title}</span>; })}</div> : '-'}</td><td className="px-4 py-3"><Chip color={statusColor(metadata.status)} size="sm" variant="soft"><Chip.Label>{metadata.status}</Chip.Label></Chip></td><td className="px-4 py-3"><div className="flex items-center gap-1">{status === '起草中' ? <><Button isIconOnly aria-label="取消修订" isDisabled={saving} size="sm" variant="tertiary" onPress={() => submitStatus(record, '已取消')}><Close size={16} /></Button><Button isIconOnly aria-label="提交审核" isDisabled={saving} size="sm" variant="tertiary" onPress={() => submitStatus(record, '已提交')}><Check size={16} /></Button><Button isIconOnly aria-label="编辑修订" size="sm" variant="tertiary" onPress={() => openEdit(record)}><Edit size={16} /></Button></> : null}{status === '已提交' ? <><Button isIconOnly aria-label="取消审核" isDisabled={saving} size="sm" variant="tertiary" onPress={() => submitStatus(record, '已取消')}><Close size={16} /></Button><Button isIconOnly aria-label="提交发文" isDisabled={saving} size="sm" variant="tertiary" onPress={() => { setEditing(record); setPublishForm({ public_date: '', attachments: [], content: '' }); setPublishOpen(true); }}><FileText size={16} /></Button></> : null}{status === '已取消' ? <Button isIconOnly aria-label="查看修订" size="sm" variant="tertiary" onPress={() => openEdit(record)}><Edit size={16} /></Button> : null}</div></td></tr>; })}</tbody></table></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={editOpen} onOpenChange={setEditOpen} size="2xl" title={editing ? '编辑修订计划' : '创建修订计划'} footer={<div className="flex justify-end gap-2"><Button variant="tertiary" onPress={() => setEditOpen(false)}>取消</Button><Button isDisabled={saving || !form.regulation_name.trim()} variant="primary" onPress={saveEdit}>{saving ? '保存中...' : '保存'}</Button></div>}> <XinyuanModal isOpen={editOpen} onOpenChange={setEditOpen} size="2xl" title={editing ? '编辑修订计划' : '创建修订计划'} footer={<div className="flex justify-end gap-2"><Button variant="tertiary" onPress={() => setEditOpen(false)}>取消</Button><Button isDisabled={saving || !form.regulation_name.trim()} variant="primary" onPress={saveEdit}>{saving ? '保存中...' : '保存'}</Button></div>}>
<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={optionsFor('approve')} selectedKeys={new Set(form.approve || [])} selectionMode="multiple" onSelectionChange={(values) => setForm((current) => ({ ...current, approve: values === 'all' ? optionsFor('approve').map((item) => item.value) : Array.from(values || []) }))} /><XinyuanAutocomplete label="制定部门" options={optionsFor('departments')} selectedKeys={new Set(form.departments || [])} selectionMode="multiple" onSelectionChange={(values) => setForm((current) => ({ ...current, departments: values === 'all' ? optionsFor('departments').map((item) => item.value) : Array.from(values || []) }))} /><TextField className="sm:col-span-2" fullWidth value={form.comment} onChange={(value) => setForm((current) => ({ ...current, comment: value }))}><Label>备注</Label><TextArea fullWidth rows={4} variant="secondary" /></TextField></div> <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={optionsFor('approve')} selectedKeys={new Set(form.approve || [])} selectionMode="multiple" onSelectionChange={(values) => setForm((current) => ({ ...current, approve: values === 'all' ? optionsFor('approve').map((item) => item.value) : Array.from(values || []) }))} /><XinyuanAutocomplete label="制定部门" options={optionsFor('departments')} selectedKeys={new Set(form.departments || [])} selectionMode="multiple" onSelectionChange={(values) => setForm((current) => ({ ...current, departments: values === 'all' ? optionsFor('departments').map((item) => item.value) : Array.from(values || []) }))} /><TextField className="sm:col-span-2" fullWidth value={form.comment} onChange={(value) => setForm((current) => ({ ...current, comment: value }))}><Label>备注</Label><TextArea fullWidth rows={4} variant="secondary" /></TextField></div>
......
...@@ -40,16 +40,20 @@ ...@@ -40,16 +40,20 @@
- “全部 / 待分类”列表首次点击制度记录时定位第一个关联节点,连续点击同一条记录时按图谱节点顺序循环定位(A → B → C → A);只有一个关联节点时保持定位该节点。记录 `_id/name` 的直接关联节点合并后参与循环,只有没有直接关联时才按 `metadata.origin_regulation` 查找。优先在未移除节点中循环;全部关联节点已移除时才在已移除节点中循环,并自动开启“显示已移除”。点击其他制度、选中画布或“节点 / 删除节点”列表、重新加载或切换拓扑版本后,再点击该制度从第一个关联节点重新开始。循环过程仍只高亮点击的制度记录,自动展开目标祖先、保持缩放并打开详情;快速连续点击时取消尚未执行的旧定位回调,画布最终定位到最新选中的节点。 - “全部 / 待分类”列表首次点击制度记录时定位第一个关联节点,连续点击同一条记录时按图谱节点顺序循环定位(A → B → C → A);只有一个关联节点时保持定位该节点。记录 `_id/name` 的直接关联节点合并后参与循环,只有没有直接关联时才按 `metadata.origin_regulation` 查找。优先在未移除节点中循环;全部关联节点已移除时才在已移除节点中循环,并自动开启“显示已移除”。点击其他制度、选中画布或“节点 / 删除节点”列表、重新加载或切换拓扑版本后,再点击该制度从第一个关联节点重新开始。循环过程仍只高亮点击的制度记录,自动展开目标祖先、保持缩放并打开详情;快速连续点击时取消尚未执行的旧定位回调,画布最终定位到最新选中的节点。
- 当前所查看的拓扑版本没有关联节点时,点击制度记录显示 HeroUI 提示“该制度在当前拓扑图中暂无关联节点。”,保持画布、筛选和已选节点状态;待分类列表仍支持拖拽归类。 - 当前所查看的拓扑版本没有关联节点时,点击制度记录显示 HeroUI 提示“该制度在当前拓扑图中暂无关联节点。”,保持画布、筛选和已选节点状态;待分类列表仍支持拖拽归类。
- 画布内单击节点只选中节点并打开右侧详情,保持当前缩放和画布位置,不自动放大或居中;左侧列表点击和拖入制度后的定位可平移到目标节点,但保持操作前的缩放比例。“恢复视图”及缩放控件仍由用户主动调整视角。 - 画布内单击节点只选中节点并打开右侧详情,保持当前缩放和画布位置,不自动放大或居中;左侧列表点击和拖入制度后的定位可平移到目标节点,但保持操作前的缩放比例。“恢复视图”及缩放控件仍由用户主动调整视角。
- 当前版本选中节点后,可按 `Delete`(兼容 Mac 的 `⌫ / Backspace`)或点击右侧详情“节点操作 → 删除节点”。删除按节点 ID 将该节点及全部下级节点的 `removed`、`data.removed` 设为 `true`,保留节点引用、父子层级及连线;同一制度在其他分支的节点不受影响。删除后清除节点和记录选择、取消旧定位回调与拖入预览、移除被删除的流程筛选路径,并关闭“显示已移除”,让删除分支立即从画布隐藏;节点保留在支持搜索的“删除节点”列表。只有已删除关联的制度重新进入“待分类”,仍有未移除关联节点的制度保持已分类。 - 当前版本选中节点后,可按 `Delete`(兼容 Mac 的 `⌫ / Backspace`)或点击右侧详情底部“删除节点”。删除按节点 ID 将该节点及全部下级节点的 `removed`、`data.removed` 设为 `true`,保留节点引用、父子层级及连线;同一制度在其他分支的节点不受影响。删除后清除节点和记录选择、取消旧定位回调与拖入预览、移除被删除的流程筛选路径,并关闭“显示已移除”,让删除分支立即从画布隐藏;节点保留在支持搜索的“删除节点”列表。只有已删除关联的制度重新进入“待分类”,仍有未移除关联节点的制度保持已分类。
- 在“删除节点”列表选择节点后,右侧详情显示“已删除”,提供“恢复节点”或“恢复节点及子节点”。恢复将所选分支及其祖先的 `removed`、`data.removed` 设为 `false`,保留其他分支的删除状态;自动展开目标祖先、保持缩放定位并打开详情。删除和恢复均只修改当前内存图谱,点击“保存”后通过 `POST /record/import` 写入 `internal_regulation_graph.metadata.graph_info`;不删除制度或修订计划 Record,保存失败保留修改。 - 在“删除节点”列表选择节点后,右侧详情显示“已删除”,提供“恢复节点”或“恢复节点及子节点”。恢复将所选分支及其祖先的 `removed`、`data.removed` 设为 `false`,保留其他分支的删除状态;自动展开目标祖先、保持缩放定位并打开详情。删除和恢复均只修改当前内存图谱,点击“保存”后通过 `POST /record/import` 写入 `internal_regulation_graph.metadata.graph_info`;不删除制度或修订计划 Record,保存失败保留修改。
- 删除快捷键仅对当前页面已选中且未删除的节点生效;输入框、备注、搜索框、可编辑区域、选择器、菜单和弹窗内的 `Delete / Backspace` 保持原有编辑行为,带修饰键、输入法组合中或长按重复事件不触发节点删除。归档版本、加载/导入/保存期间、节点编辑弹窗打开时禁用删除和恢复;已删除节点不重复删除。点击画布空白处或详情“清除选择”会同时清除节点和记录选择,后续删除快捷键不操作之前的节点。React Flow 默认删除快捷键关闭,统一由上述逻辑处理。 - 删除快捷键仅对当前页面已选中且未删除的节点生效;输入框、备注、搜索框、可编辑区域、选择器、菜单和弹窗内的 `Delete / Backspace` 保持原有编辑行为,带修饰键、输入法组合中或长按重复事件不触发节点删除。归档版本、加载/导入/保存期间、节点编辑弹窗打开时禁用删除和恢复;已删除节点不重复删除。点击画布空白处或详情“清除选择”会同时清除节点和记录选择,后续删除快捷键不操作之前的节点。React Flow 默认删除快捷键关闭,统一由上述逻辑处理。
- 拓扑页高度锁定为当前视口可用高度,工具栏和画布保持在当前屏幕内;节点列表和详情区域在容器内部滚动,页面外层不因节点数量增长而整体向下延伸。 - 拓扑页高度锁定为当前视口可用高度,工具栏和画布保持在当前屏幕内;节点列表和详情区域在容器内部滚动,页面外层不因节点数量增长而整体向下延伸;窄窗口下节点列表可收缩,详情宽度不超过拓扑容器,避免右侧按钮或表单被裁切。
- 工具栏支持现行制度显示、已移除节点显示、现行/待新增/待修订/待废止筛选、当前版本和归档版本切换;归档版本只读,不能导入或保存。 - 工具栏支持现行制度显示、已移除节点显示、现行/待新增/待修订/待废止筛选、当前版本和归档版本切换;归档版本只读,不能导入或保存。
- 流程筛选使用多层级联路径,不将全部节点平铺到单层下拉;用户从第一层流程开始逐级展开并勾选路径。筛选保留选中路径、路径祖先和最后一个节点的子孙,不会因保留根节点路径而放行同一父级下的其他流程分支。 - 流程筛选使用多层级联路径,不将全部节点平铺到单层下拉;用户从第一层流程开始逐级展开并勾选路径。筛选保留选中路径、路径祖先和最后一个节点的子孙,不会因保留根节点路径而放行同一父级下的其他流程分支。
- “导入 / 导出 / 保存”是图谱级操作,常驻拓扑容器右上角,与节点详情共用右侧面板;操作栏在顶部,三个按钮等宽横向排列、统一高度与间距,图标和文字保持同一行;导出使用 HeroUI Button 作为下拉触发器,保存保留主操作样式。下方节点详情以分隔线隔开并独立滚动。未选中节点、清除选择或图谱为空时操作栏仍可见;收起右侧详情时仅隐藏下方详情并释放画布宽度,顶部操作栏保留在右上角。导入使用 HeroUI Button 打开 `.xmind/.json` 文件选择,导出菜单使用 HeroUI Dropdown 提供“制度列表(.csv)/ 拓扑图(.xmind)”,保存当前整张图谱;归档版本禁用导入和保存,保留导出;导入或保存期间禁用重复导入、保存。 - “导入 / 导出 / 保存”是图谱级操作,常驻拓扑容器右上角,与节点详情共用右侧面板;操作栏在顶部,三个按钮等宽横向排列、统一高度与间距,图标和文字保持同一行;导出使用 HeroUI Button 作为下拉触发器,保存保留主操作样式。下方节点详情以分隔线隔开并独立滚动。未选中节点、清除选择或图谱为空时操作栏仍可见;收起右侧详情时仅隐藏下方详情并释放画布宽度,顶部操作栏保留在右上角。导入使用 HeroUI Button 打开 `.xmind/.json` 文件选择,导出菜单使用 HeroUI Dropdown 提供“制度列表(.csv)/ 拓扑图(.xmind)”,保存当前整张图谱;归档版本禁用导入和保存,保留导出;导入或保存期间禁用重复导入、保存。
- XMind 导出基于当前所查看版本的完整内存图谱,包括尚未保存的修改、折叠/筛选隐藏和已移除节点;导出不触发保存请求,归档版本也可导出。单根图沿用原根节点,多根或缺失父节点的分支归入“制度拓扑图”统一根节点,按 `parent` 保留层级;非父子连线转为 XMind 关联线。制度主题使用 `《制度名称》`,节点备注写入 XMind Notes,制度状态和“已移除”写入标签;XMind 使用向右的逻辑图布局,不承诺复制画布的手工坐标、缩放或筛选状态。 - XMind 导出基于当前所查看版本的完整内存图谱,包括尚未保存的修改、折叠/筛选隐藏和已移除节点;导出不触发保存请求,归档版本也可导出。单根图沿用原根节点,多根或缺失父节点的分支归入“制度拓扑图”统一根节点,按 `parent` 保留层级;非父子连线转为 XMind 关联线。制度主题使用 `《制度名称》`,节点备注写入 XMind Notes,制度状态和“已移除”写入标签;XMind 使用向右的逻辑图布局,不承诺复制画布的手工坐标、缩放或筛选状态。
- XMind 文件在浏览器内生成,使用现有 JSZip 打包 `content.json/metadata.json/manifest.json`,下载名称为 `制度拓扑图-版本.xmind`。生成期间显示“正在导出”并禁用重复导出;无节点、重复节点 ID 或循环父子关系时显示明确错误,失败保留当前图谱。CSV 继续按原规则导出制度列表。 - XMind 文件在浏览器内生成,使用现有 JSZip 打包 `content.json/metadata.json/manifest.json`,下载名称为 `制度拓扑图-版本.xmind`。生成期间显示“正在导出”并禁用重复导出;无节点、重复节点 ID 或循环父子关系时显示明确错误,失败保留当前图谱。CSV 继续按原规则导出制度列表。
- 右侧详情支持编辑标题和备注、查看流程路径/制度路径/历史版本、折叠子节点和打开关联制度;“添加流程节点”位于详情的节点操作区,右键也可添加流程节点或备注。 - 点击画布或左侧列表中的节点打开右侧详情。详情标题栏固定展示节点类型、完整标题(较长时换行)和状态,已删除节点另显示“已删除”;点击关闭按钮清除节点和记录选择。顶部图谱操作栏和详情标题栏不随正文滚动。
- 详情正文按“基本信息 / 节点操作 / 关联信息”分组,在单一内容区域内滚动。基本信息使用 HeroUI 表单编辑标题与备注,备注默认三行并支持调整高度;修改实时更新当前图谱,点击顶部“保存”后持久化,保存失败保留编辑内容。归档版本标题和备注只读,保存期间禁用输入。
- 节点操作区将“展开/折叠子节点”和“添加流程节点”两列等宽排列;有制度或修订计划引用时,下方提供整行“查看关联制度”入口。归档版本、保存期间和已删除节点禁用添加;展开/折叠仍可调整查看范围。右键也可添加流程节点或备注。
- 关联信息区提供等宽“流程路径 / 制度路径 / 历史版本”三个 HeroUI Tab;路径按祖先顺序展示并支持长名称换行,历史条目进入对应制度详情,无数据时显示明确空状态。切换所选节点时,详情滚动位置和关联 Tab 重置到顶部及“流程路径”。
- 删除或恢复操作固定在详情底部,与常用操作分隔,并保留快捷键及恢复说明;内容滚动时始终可见。已删除节点显示“恢复节点”或“恢复节点及子节点”。归档版本显示只读提示,禁用删除、恢复;保存期间也禁用重复操作。删除、恢复状态变化及保存规则沿用上文,不修改制度 Record。
- 右键节点时,在鼠标点击位置显示“添加流程节点 / 添加备注”菜单;右键画布空白处只显示“添加流程节点”。菜单坐标以中间画布的实际定位容器为基准,不计入左侧列表宽度,不使用图谱缩放或平移后的节点坐标;菜单渲染后按实际宽高限制在画布内,靠近右侧或底部时自动向内调整,避免覆盖详情或被裁切。菜单项使用 HeroUI Button;点击外部、按 Esc、开始平移/缩放或拖动节点、窗口尺寸变化时关闭菜单,选择菜单项后按原流程打开节点编辑弹窗。 - 右键节点时,在鼠标点击位置显示“添加流程节点 / 添加备注”菜单;右键画布空白处只显示“添加流程节点”。菜单坐标以中间画布的实际定位容器为基准,不计入左侧列表宽度,不使用图谱缩放或平移后的节点坐标;菜单渲染后按实际宽高限制在画布内,靠近右侧或底部时自动向内调整,避免覆盖详情或被裁切。菜单项使用 HeroUI Button;点击外部、按 Esc、开始平移/缩放或拖动节点、窗口尺寸变化时关闭菜单,选择菜单项后按原流程打开节点编辑弹窗。
- 当前版本支持上传 `.xmind`(兼容 `content.json` 与 `content.xml`),会将 `《制度名称》` 主题匹配到制度记录,生成流程节点及连线,并同步 `graph_process` 分类。 - 当前版本支持上传 `.xmind`(兼容 `content.json` 与 `content.xml`),会将 `《制度名称》` 主题匹配到制度记录,生成流程节点及连线,并同步 `graph_process` 分类。
- “全部 / 待分类”的制度记录可拖到画布中任意未移除可见节点的右侧连接区域:区域向右延伸 220 个画布单位,上下各扩展 32 个单位;相邻区域重叠时优先选择离鼠标最近的节点。命中后高亮父节点、显示连接区域与横向连线预览,并提示“松开连接到「节点名称」”;缩放、平移及手动移动节点后仍按当前画布坐标判断。 - “全部 / 待分类”的制度记录可拖到画布中任意未移除可见节点的右侧连接区域:区域向右延伸 220 个画布单位,上下各扩展 32 个单位;相邻区域重叠时优先选择离鼠标最近的节点。命中后高亮父节点、显示连接区域与横向连线预览,并提示“松开连接到「节点名称」”;缩放、平移及手动移动节点后仍按当前画布坐标判断。
...@@ -60,7 +64,17 @@ ...@@ -60,7 +64,17 @@
## 加载、错误与限制 ## 加载、错误与限制
- Subject、Record、附件请求均显示 loading、success 或 error 状态;错误保留在当前页面,不使用演示数据或 localStorage 回退。 - Subject、Record、附件请求均显示 loading、success 或 error 状态;请求错误统一显示可关闭的浮层,加载失败保留失败空状态和读取重试,不使用演示数据或 localStorage 回退。
- 制度目录、修订计划和归档历史列表使用服务端分页;每页条数可选 `20 / 50 / 100`,切换条数后回到第 1 页。页码和页数读取 `/record/query` 返回的 `current_page` 与 `total_pages`,列表总数读取当前筛选条件对应的 `total_records`。 - 制度目录、修订计划和归档历史列表使用服务端分页;每页条数可选 `20 / 50 / 100`,切换条数后回到第 1 页。页码和页数读取 `/record/query` 返回的 `current_page` 与 `total_pages`,列表总数读取当前筛选条件对应的 `total_records`。
- 保存、提交、发文期间禁用重复提交按钮;失败时保留表单内容。 - 保存、提交、发文期间禁用重复提交按钮;失败时保留表单内容。
- 归档记录不修改现行制度;不存在的制度或修订计划显示明确空状态。 - 归档记录不修改现行制度;不存在的制度或修订计划显示明确空状态。
## 操作提示与失败重试
- 功能入口:登录、站点配置、应用切换、制度目录、修订计划编辑/提交/发文、现行制度归档、归档历史、制度详情,以及拓扑图加载、节点操作、导入、导出和保存。
- 接口成功、失败和降级警告统一通过应用根部 `AppFeedback` 的 HeroUI Toast 显示;浮层固定在视口顶部,层级 300,高于业务 Modal、确认框和移动侧栏,页面滚动或弹窗打开时均可看见并关闭。页面不再展示常驻操作横条。
- 成功默认 4 秒、错误和警告默认 8 秒后消失;悬停或键盘聚焦暂停计时。当前提示正文右侧的小号透明关闭图标始终可见,点击或键盘激活通过 HeroUI Toast 的 `close` slot 关闭;后排堆叠提示隐藏关闭按钮,避免误关闭。同一来源的新提示替换旧提示,相同错误再次发生时重新显示;清空反馈或离开对应页面时清理该来源提示。
- 制度详情在“编辑信息”弹窗点击“保存”,仅 `PUT /record/:name` 成功后关闭编辑弹窗并弹出“详情已保存。”,然后沿用原逻辑重新读取详情。提示挂在页面根部,关闭编辑弹窗或显示加载状态不使其消失;重新开始编辑、再次保存、切换记录或离开页面时清理上一条成功提示。写入失败只显示错误并保留弹窗与草稿,正在保存时不重复提交;修订详情与修订计划编辑弹窗保留现有保存成功提示。
- 关闭提示仅影响展示,不改变请求成功/失败状态、修订状态、筛选条件、页码、所选记录、编辑草稿或 `metadata`,也不会触发保存、发文、归档或图谱导入。保存/上传失败保留弹窗及填写内容,图谱保存失败保留内存节点和连线。
- 制度配置、制度目录、修订计划、归档历史、制度详情、修订详情及拓扑图加载失败时保留明确的失败空状态和“重新加载”,详细原因由 Toast 展示;不误显示为暂无数据。重试按当前名称、归档版本、筛选和分页读取资源,不重复执行写入操作。详情保存失败继续保留原编辑页面和弹框。
- 必填字段、确认说明、归档只读及节点未保存说明继续保留原业务规则;成功反馈不替代数据重新读取。现有 `internal_regulation/internal_regulation_revision/internal_regulation_archive/internal_regulation_graph` 字段及 API 协议保持不变。
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