Commit 36617df8 authored by drigle's avatar drigle

fix: unify operation feedback and loading error recovery

parent 86bbaf04
...@@ -48,7 +48,10 @@ ...@@ -48,7 +48,10 @@
- 预览态普通标量字段使用无边框静态值:标签为 `text-muted`,值为 `text-foreground`,空值显示“暂无内容”;不得用灰色 disabled Input/Autocomplete 冒充预览。编辑态才显示 HeroUI `secondary` 控件;权限禁用、锁定或只读字段在同一网格中继续显示静态只读值。 - 预览态普通标量字段使用无边框静态值:标签为 `text-muted`,值为 `text-foreground`,空值显示“暂无内容”;不得用灰色 disabled Input/Autocomplete 冒充预览。编辑态才显示 HeroUI `secondary` 控件;权限禁用、锁定或只读字段在同一网格中继续显示静态只读值。
- 预览与编辑共用两列字段网格和字段顺序;长文本、附件等内容跨两列。编辑态使用 Footer 的 HeroUI `secondary` 取消 + `primary` 保存,保存只提交一次,取消丢弃草稿,不改变原有校验、权限和请求时机。 - 预览与编辑共用两列字段网格和字段顺序;长文本、附件等内容跨两列。编辑态使用 Footer 的 HeroUI `secondary` 取消 + `primary` 保存,保存只提交一次,取消丢弃草稿,不改变原有校验、权限和请求时机。
- 预览态 Footer 使用 HeroUI `primary` 编辑按钮作为主操作;点击后在同一个 Modal 和编辑会话内切换为编辑态,保留当前记录标题、字段草稿和材料草稿,不关闭后重新打开;进入编辑态后显示 `secondary` 取消 + `primary` 保存,取消关闭并丢弃未保存草稿。 - 预览态 Footer 使用 HeroUI `primary` 编辑按钮作为主操作;点击后在同一个 Modal 和编辑会话内切换为编辑态,保留当前记录标题、字段草稿和材料草稿,不关闭后重新打开;进入编辑态后显示 `secondary` 取消 + `primary` 保存,取消关闭并丢弃未保存草稿。
- 例行成功反馈使用顶部居中的自动消失 Toast;需要处理的错误保留上下文 Alert。 - 接口成功、失败和操作校验统一使用顶部居中的 HeroUI Toast,禁止在页面或弹窗中渲染常驻的操作反馈横条。全局 `AppFeedback` 在应用根层挂载唯一 Toast Provider,登录页、业务页与弹窗共用;提示层级高于 Modal 和移动侧栏,不受页面滚动影响。
- 成功提示 4 秒后自动消失,错误与警告提示 8 秒后自动消失;关闭按钮使用 HeroUI `Button` 的 `ghost`、`isIconOnly`、`size="sm"` 与 Toast 原生 `slot="close"`,统一 16px `Close` 图标并参与内容布局,继承主题悬停和键盘焦点状态。最前方提示的关闭按钮始终可见,后方堆叠提示隐藏关闭按钮以避免误点;支持触屏和键盘。长错误内容可滚动阅读,提示最多同时展示 3 条。
- 详情编辑保存的成功提示由详情页持有,仅在更新接口成功后显示“详情已保存。”;编辑弹窗关闭后提示仍可见。进入新编辑会话、取消、再次保存或切换记录时清除旧成功提示,校验/请求失败和保存中的重复点击不触发成功提示。
- 关闭或自动隐藏提示只改变提示展示,不清除请求失败状态、表单草稿或已选记录;相同错误在重试失败后再次提示。数据加载失败使用 HeroUI Pro EmptyState 和重新加载入口,不能冒充无数据。操作失败不隐藏已加载数据;静态业务限制说明与字段错误仍可保留上下文展示。
- 应用切换严格继承 `model_analysis`:从 `/info -> site.group` 动态生成分组,保持配置顺序并显示真实数量;未归组应用进入“其他应用”,无配置时使用“全部应用”。Modal 固定 46rem/42rem、28px inset、手机一列、`sm` 起两列,间距 12px;加载骨架使用相同网格,桌面不得切成三列,长列表仅在 Modal Body 内滚动。 - 应用切换严格继承 `model_analysis`:从 `/info -> site.group` 动态生成分组,保持配置顺序并显示真实数量;未归组应用进入“其他应用”,无配置时使用“全部应用”。Modal 固定 46rem/42rem、28px inset、手机一列、`sm` 起两列,间距 12px;加载骨架使用相同网格,桌面不得切成三列,长列表仅在 Modal Body 内滚动。
## 字段后缀图标 ## 字段后缀图标
......
{ {
"name": "law_reference_new", "name": "law_reference_new",
"version": "1.0.1", "version": "1.0.3",
"build": "law-reference", "build": "law-reference",
"description": "内部制度与外部法规", "description": "内部制度与外部法规",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
import '@/tailwind.css'; import '@/tailwind.css';
import '@/global.less'; import '@/global.less';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { createElement } from 'react';
import AppFeedback from '@/components/AppFeedback';
export async function getInitialState() { export async function getInitialState() {
return { return {
...@@ -8,4 +10,8 @@ export async function getInitialState() { ...@@ -8,4 +10,8 @@ export async function getInitialState() {
}; };
} }
export default defineApp({}); export function rootContainer(container) {
return createElement(AppFeedback, null, container);
}
export default defineApp({ rootContainer });
import { Button, Toast } from '@heroui/react';
import { Close } from '@/components/AppIcons';
export default function AppFeedback({ children }) {
return (
<>
{children}
<Toast.Provider
aria-label="操作提示"
className="app-feedback-region"
maxVisibleToasts={3}
placement="top"
width="min(30rem, calc(100vw - 2rem))"
>
{({ toast }) => (
<Toast className="app-feedback-toast gap-2" toast={toast} variant={toast.content.variant}>
<Toast.Indicator variant={toast.content.variant} />
<Toast.Content className="min-w-0">
<Toast.Title className="whitespace-pre-wrap break-words">{toast.content.title}</Toast.Title>
{toast.content.description ? (
<Toast.Description className="max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words text-foreground">
{toast.content.description}
</Toast.Description>
) : null}
{toast.content.actionProps ? <Toast.ActionButton {...toast.content.actionProps} /> : null}
</Toast.Content>
<Button
aria-label="关闭提示"
className="app-feedback-close shrink-0 self-start"
isIconOnly
size="sm"
slot="close"
variant="ghost"
>
<Close size={16} />
</Button>
</Toast>
)}
</Toast.Provider>
</>
);
}
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi'; import { history } from 'umi';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog'; import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
...@@ -49,7 +48,6 @@ export default function AppShell({ ...@@ -49,7 +48,6 @@ export default function AppShell({
userInfo={sidebarProps.userInfo} userInfo={sidebarProps.userInfo}
onOpenChange={setIsApplicationDialogOpen} onOpenChange={setIsApplicationDialogOpen}
/> />
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider> </Sidebar.Provider>
); );
} }
import { Input, TextArea } from '@heroui/react'; import { Input, TextArea, toast } from '@heroui/react';
import { useState } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import DocumentService from '@/services/DocumentService'; import DocumentService from '@/services/DocumentService';
import { uploadDocument } from '@/services/DataService'; import { uploadDocument } from '@/services/DataService';
...@@ -273,6 +274,7 @@ export function LawRecordFieldValue({ field, value, className = '' }) { ...@@ -273,6 +274,7 @@ export function LawRecordFieldValue({ field, value, className = '' }) {
} }
export function LawRecordFieldEditor({ field, value, onChange, edit = false }) { export function LawRecordFieldEditor({ field, value, onChange, edit = false }) {
const [uploading, setUploading] = useState(false);
const label = getLawFieldLabel(field); const label = getLawFieldLabel(field);
const disabled = edit && isLawPrimaryField(field); const disabled = edit && isLawPrimaryField(field);
const options = normalizeLawOptions(field?.settings?.options || []); const options = normalizeLawOptions(field?.settings?.options || []);
...@@ -301,26 +303,37 @@ export function LawRecordFieldEditor({ field, value, onChange, edit = false }) { ...@@ -301,26 +303,37 @@ export function LawRecordFieldEditor({ field, value, onChange, edit = false }) {
return ( return (
<FileUploadField <FileUploadField
ariaLabel={label} ariaLabel={label}
disabled={disabled} disabled={disabled || uploading}
fileName={formatLawFieldValue(field, value, '')} fileName={formatLawFieldValue(field, value, '')}
multiple={field?.multiple !== false} multiple={field?.multiple !== false}
triggerLabel={uploading ? '正在上传…' : '选择文件'}
onFilesChange={async (files) => { onFilesChange={async (files) => {
const uploaded = await Promise.all(files.map((file) => uploadDocument(file))); if (!files.length || uploading) return;
if (field?.mode === 'document' && files[0]) { setUploading(true);
const extension = files[0].name?.split('.').pop()?.toLowerCase(); try {
if (extension === 'doc' || extension === 'docx') { const uploaded = await Promise.all(files.map((file) => uploadDocument(file)));
try { if (field?.mode === 'document' && files[0]) {
const documentModel = await DocumentService.parse(files[0]); const extension = files[0].name?.split('.').pop()?.toLowerCase();
if (documentModel) { if (extension === 'doc' || extension === 'docx') {
onChange(documentModel); try {
return; const documentModel = await DocumentService.parse(files[0]);
if (documentModel) {
onChange(documentModel);
toast.success('文档上传成功', { timeout: 4000 });
return;
}
} catch {
// Preserve the uploaded file value when document parsing is unavailable.
} }
} catch {
// Preserve the uploaded file value when document parsing is unavailable.
} }
} }
onChange(field?.multiple === false ? uploaded[0] : uploaded);
toast.success('附件上传成功', { timeout: 4000 });
} catch (error) {
toast.danger(error?.message || '附件上传失败,请稍后重试', { timeout: 8000 });
} finally {
setUploading(false);
} }
onChange(field?.multiple === false ? uploaded[0] : uploaded);
}} }}
/> />
); );
......
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Button } from '@heroui/react';
export default function LoadErrorState({ 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.Footer>
<Button variant="secondary" onPress={onRetry}>重新加载</Button>
</EmptyState.Footer>
) : null}
</EmptyState>
);
}
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import XinyuanAutocomplete from './XinyuanAutocomplete'; import XinyuanAutocomplete from './XinyuanAutocomplete';
...@@ -7,7 +8,7 @@ export default function ReferenceSearchSelect({ label, value, onChange, loadOpti ...@@ -7,7 +8,7 @@ export default function ReferenceSearchSelect({ label, value, onChange, loadOpti
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState(() => initialRecords.filter((item) => item?._id).map(optionFrom)); const [options, setOptions] = useState(() => initialRecords.filter((item) => item?._id).map(optionFrom));
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const selected = new Set(multiple ? value || [] : value ? [value] : []); const selected = new Set(multiple ? value || [] : value ? [value] : []);
useEffect(() => { useEffect(() => {
......
/* Operation feedback stays above dialogs and outside page scrolling. */
.app-feedback-region.toast-region {
z-index: 300;
top: max(1rem, env(safe-area-inset-top));
}
/* Only the front toast is interactive; its ghost close button stays visible without hover. */
.app-feedback-toast:not([data-frontmost="true"]) .app-feedback-close {
visibility: hidden;
pointer-events: none;
}
import { toast } from '@heroui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
const FEEDBACK_OPTIONS = {
error: { title: '操作失败', variant: 'danger', timeout: 8000 },
success: { title: '操作成功', variant: 'success', timeout: 4000 },
warning: { title: '请注意', variant: 'warning', timeout: 8000 },
};
// Closing a toast preserves the request's failure state and the form draft.
export default function useFeedbackState(tone = 'error') {
const [message, setMessage] = useState('');
const toastKeyRef = useRef(null);
const isMountedRef = useRef(true);
const updateMessage = useCallback((nextMessage) => {
if (!isMountedRef.current) return;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
setMessage(nextMessage);
if (!nextMessage) return;
const { title, variant, timeout } = FEEDBACK_OPTIONS[tone] || FEEDBACK_OPTIONS.error;
// A retry must report feedback even when the server returns the same error.
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 { Alert, Button, Card, Input, Spinner } from '@heroui/react'; import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Card, Input, Spinner, toast } from '@heroui/react';
import LoadErrorState from '@/components/LoadErrorState';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
import { DataTable, DataTablePagination } from '@/components/DataTable'; import { DataTable, DataTablePagination } from '@/components/DataTable';
...@@ -23,7 +25,8 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con ...@@ -23,7 +25,8 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [refresh, setRefresh] = useState(0); const [refresh, setRefresh] = useState(0);
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [deleting, setDeleting] = useState(null); const [deleting, setDeleting] = useState(null);
...@@ -31,13 +34,13 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con ...@@ -31,13 +34,13 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setError(''); setRecords([]); setLoading(true); setLoadFailed(false); setError(''); setRecords([]);
(async () => { (async () => {
try { try {
const record = await loadRecord(recordName); const record = await loadRecord(recordName);
const response = await queryRecords({ subject: resultSubject, page, page_size: pageSize, filter: { 'metadata.related_indicator': record._id, ...(query ? { title: { $regex: query } } : {}) } }); const response = await queryRecords({ subject: resultSubject, page, page_size: pageSize, filter: { 'metadata.related_indicator': record._id, ...(query ? { title: { $regex: query } } : {}) } });
if (!cancelled) { setIndicator(record); setRecords(response?.records || []); setTotal(response?.total_records ?? 0); } if (!cancelled) { setIndicator(record); setRecords(response?.records || []); setTotal(response?.total_records ?? 0); }
} catch (requestError) { if (!cancelled) setError(requestError?.message || '执行结果加载失败'); } } catch (requestError) { if (!cancelled) { setLoadFailed(true); setError(requestError?.message || '执行结果加载失败'); } }
finally { if (!cancelled) setLoading(false); } finally { if (!cancelled) setLoading(false); }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
...@@ -51,6 +54,7 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con ...@@ -51,6 +54,7 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con
const rows = selected.metadata.execution_data.map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, value && typeof value === 'object' ? JSON.stringify(value) : value]))); const rows = selected.metadata.execution_data.map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, value && typeof value === 'object' ? JSON.stringify(value) : value])));
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), '执行结果'); XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), '执行结果');
XLSX.writeFile(workbook, `${indicator?.title || '指标'}执行结果.xlsx`); XLSX.writeFile(workbook, `${indicator?.title || '指标'}执行结果.xlsx`);
toast.success('导出成功', { timeout: 4000 });
} catch (requestError) { setError(requestError?.message || '导出失败'); } } catch (requestError) { setError(requestError?.message || '导出失败'); }
finally { setPending(false); } finally { setPending(false); }
}; };
...@@ -60,6 +64,7 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con ...@@ -60,6 +64,7 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con
try { try {
await removeRecord(deleting.name || deleting._id); await removeRecord(deleting.name || deleting._id);
setDeleting(null); setDeleting(null);
toast.success('执行结果已删除', { timeout: 4000 });
if (records.length === 1 && page > 0) setPage(page - 1); if (records.length === 1 && page > 0) setPage(page - 1);
else setRefresh((value) => value + 1); else setRefresh((value) => value + 1);
} catch (requestError) { setError(requestError?.message || '删除失败'); } } catch (requestError) { setError(requestError?.message || '删除失败'); }
...@@ -70,15 +75,14 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con ...@@ -70,15 +75,14 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con
return <div className="rc-page-stack"> return <div className="rc-page-stack">
<div className="flex flex-wrap items-center gap-3"><Button variant="secondary" onPress={() => history.push(`/${subjectName}`)}>返回指标列表</Button><h2 className="text-xl font-semibold">{indicator?.title || '指标'}执行结果</h2></div> <div className="flex flex-wrap items-center gap-3"><Button variant="secondary" onPress={() => history.push(`/${subjectName}`)}>返回指标列表</Button><h2 className="text-xl font-semibold">{indicator?.title || '指标'}执行结果</h2></div>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button variant="ghost" onPress={() => setRefresh((value) => value + 1)}>重试</Button></Alert> : null}
<Card><Card.Header><form className="flex w-full gap-2 sm:max-w-md" onSubmit={(event) => { event.preventDefault(); setQuery(keyword.trim()); setPage(0); }}><Input aria-label="搜索执行结果" className="min-w-0 flex-1" placeholder="搜索结果标题" value={keyword} onChange={(event) => setKeyword(event.target.value)} /><Button variant="primary" type="submit">搜索</Button></form></Card.Header> <Card><Card.Header><form className="flex w-full gap-2 sm:max-w-md" onSubmit={(event) => { event.preventDefault(); setQuery(keyword.trim()); setPage(0); }}><Input aria-label="搜索执行结果" className="min-w-0 flex-1" placeholder="搜索结果标题" value={keyword} onChange={(event) => setKeyword(event.target.value)} /><Button variant="primary" type="submit">搜索</Button></form></Card.Header>
<Card.Content className="space-y-4"> <Card.Content className="space-y-4">
{loading ? <div className="grid min-h-32 place-items-center"><Spinner /></div> : !error ? <DataTable ariaLabel="指标执行结果" rows={records} getRowKey={(item) => item._id} columns={[ {loading ? <div className="grid min-h-32 place-items-center"><Spinner /></div> : loadFailed ? <LoadErrorState title="执行结果加载失败" onRetry={() => setRefresh((value) => value + 1)} /> : <DataTable ariaLabel="指标执行结果" rows={records} getRowKey={(item) => item._id} columns={[
{ key: 'title', title: '标题' }, { key: 'title', title: '标题' },
{ key: 'time', title: '执行时间', render: (item) => formatTime(item.metadata?.execution_time) }, { key: 'time', title: '执行时间', render: (item) => formatTime(item.metadata?.execution_time) },
...(canManage ? [{ key: 'actions', title: '操作', isActions: true, width: 160, render: (item) => <div className="flex gap-2"><Button size="sm" variant="secondary" onPress={() => setSelected(item)}>查看</Button><Button size="sm" variant="danger-soft" onPress={() => setDeleting(item)}>删除</Button></div> }] : []), ...(canManage ? [{ key: 'actions', title: '操作', isActions: true, width: 160, render: (item) => <div className="flex gap-2"><Button size="sm" variant="secondary" onPress={() => setSelected(item)}>查看</Button><Button size="sm" variant="danger-soft" onPress={() => setDeleting(item)}>删除</Button></div> }] : []),
]} /> : null} ]} />}
{!error ? <DataTablePagination isDisabled={loading} page={page} pageSize={pageSize} pageSizeOptions={[20, 50, 100]} total={total} onPageChange={setPage} onPageSizeChange={(size) => { setPageSize(size); setPage(0); }} /> : null} {!loadFailed ? <DataTablePagination isDisabled={loading} page={page} pageSize={pageSize} pageSizeOptions={[20, 50, 100]} total={total} onPageChange={setPage} onPageSizeChange={(size) => { setPageSize(size); setPage(0); }} /> : null}
</Card.Content> </Card.Content>
</Card> </Card>
<XinyuanModal isOpen={Boolean(selected)} onOpenChange={(open) => { if (!open) setSelected(null); }} size="3xl" title={selected?.title || '执行结果'} footer={<Button variant="primary" onPress={() => setSelected(null)}>关闭</Button>}> <XinyuanModal isOpen={Boolean(selected)} onOpenChange={(open) => { if (!open) setSelected(null); }} size="3xl" title={selected?.title || '执行结果'} footer={<Button variant="primary" onPress={() => setSelected(null)}>关闭</Button>}>
...@@ -87,6 +91,6 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con ...@@ -87,6 +91,6 @@ export default function AuditIndicatorResultsPage({ subjectName, recordName, con
<p className="text-sm text-muted">执行时间:{formatTime(selected?.metadata?.execution_time)}</p> <p className="text-sm text-muted">执行时间:{formatTime(selected?.metadata?.execution_time)}</p>
</div> </div>
</XinyuanModal> </XinyuanModal>
<XinyuanModal isOpen={Boolean(deleting)} onOpenChange={(open) => { if (!open && !pending) setDeleting(null); }} title="删除执行结果" footer={<><Button variant="secondary" isDisabled={pending} onPress={() => setDeleting(null)}>取消</Button><Button variant="danger" isPending={pending} onPress={deleteResult}>确认删除</Button></>}><p>确定删除“{deleting?.title}”吗?</p>{error ? <p className="mt-3 text-danger" role="alert">{error}</p> : null}</XinyuanModal> <XinyuanModal isOpen={Boolean(deleting)} onOpenChange={(open) => { if (!open && !pending) setDeleting(null); }} title="删除执行结果" footer={<><Button variant="secondary" isDisabled={pending} onPress={() => setDeleting(null)}>取消</Button><Button variant="danger" isPending={pending} onPress={deleteResult}>确认删除</Button></>}><p>确定删除“{deleting?.title}”吗?</p></XinyuanModal>
</div>; </div>;
} }
import { Alert, Button, Card, Chip, Input, Spinner } from '@heroui/react'; import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Card, Chip, Input, Spinner } from '@heroui/react';
import LoadErrorState from '@/components/LoadErrorState';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
import { DataTable } from '@/components/DataTable'; import { DataTable } from '@/components/DataTable';
...@@ -26,8 +28,10 @@ export default function AuditIndicatorView({ subjectName, config }) { ...@@ -26,8 +28,10 @@ export default function AuditIndicatorView({ subjectName, config }) {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [loadingGroups, setLoadingGroups] = useState(true); const [loadingGroups, setLoadingGroups] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [notice, setNotice] = useState(''); const [groupsLoadFailed, setGroupsLoadFailed] = useState(false);
const [indicatorLoadFailed, setIndicatorLoadFailed] = useState(false);
const [, setNotice] = useFeedbackState('success');
const [refresh, setRefresh] = useState(0); const [refresh, setRefresh] = useState(0);
const [modal, setModal] = useState(null); const [modal, setModal] = useState(null);
const [draft, setDraft] = useState({}); const [draft, setDraft] = useState({});
...@@ -35,17 +39,17 @@ export default function AuditIndicatorView({ subjectName, config }) { ...@@ -35,17 +39,17 @@ export default function AuditIndicatorView({ subjectName, config }) {
const [unlinked, setUnlinked] = useState(null); const [unlinked, setUnlinked] = useState(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [executing, setExecuting] = useState(''); const [executing, setExecuting] = useState('');
const [formError, setFormError] = useState(''); const [, setFormError] = useFeedbackState('error');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoadingGroups(true); setError(''); setLoadingGroups(true); setGroupsLoadFailed(false); setError('');
loadRecords(groupSubject, 0, 100).then((response) => { loadRecords(groupSubject, 0, 100).then((response) => {
if (cancelled) return; if (cancelled) return;
const next = response?.records || []; const next = response?.records || [];
setGroups(next); setGroups(next);
setActive((current) => next.some((item) => (item.name || item._id) === current) ? current : next[0]?.name || next[0]?._id || ''); setActive((current) => next.some((item) => (item.name || item._id) === current) ? current : next[0]?.name || next[0]?._id || '');
}).catch((requestError) => { if (!cancelled) setError(requestError?.message || '分组加载失败'); }) }).catch((requestError) => { if (!cancelled) { setGroupsLoadFailed(true); setError(requestError?.message || '分组加载失败'); } })
.finally(() => { if (!cancelled) setLoadingGroups(false); }); .finally(() => { if (!cancelled) setLoadingGroups(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [groupSubject, refresh]); }, [groupSubject, refresh]);
...@@ -53,11 +57,12 @@ export default function AuditIndicatorView({ subjectName, config }) { ...@@ -53,11 +57,12 @@ export default function AuditIndicatorView({ subjectName, config }) {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setGroup(null); setItems([]); setGroup(null); setItems([]);
setIndicatorLoadFailed(false);
if (!active) { setLoading(false); return undefined; } if (!active) { setLoading(false); return undefined; }
setLoading(true); setError(''); setLoading(true); setError('');
loadIndicatorGroup(active, resultSubject).then((response) => { loadIndicatorGroup(active, resultSubject).then((response) => {
if (!cancelled) { setGroup(response.group); setItems(response.items); } if (!cancelled) { setGroup(response.group); setItems(response.items); }
}).catch((requestError) => { if (!cancelled) setError(requestError?.message || '指标加载失败'); }) }).catch((requestError) => { if (!cancelled) { setIndicatorLoadFailed(true); setError(requestError?.message || '指标加载失败'); } })
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [active, resultSubject, refresh]); }, [active, resultSubject, refresh]);
...@@ -118,16 +123,13 @@ export default function AuditIndicatorView({ subjectName, config }) { ...@@ -118,16 +123,13 @@ export default function AuditIndicatorView({ subjectName, config }) {
return <div className="rc-page-stack"> return <div className="rc-page-stack">
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="text-xl font-semibold">量化指标</h2>{canManage ? <Button variant="secondary" onPress={() => { setDraft({}); setFormError(''); setModal('group'); }}>新建指标分组</Button> : null}</div> <div className="flex flex-wrap items-center justify-between gap-3"><h2 className="text-xl font-semibold">量化指标</h2>{canManage ? <Button variant="secondary" onPress={() => { setDraft({}); setFormError(''); setModal('group'); }}>新建指标分组</Button> : null}</div>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="ghost" onPress={() => setRefresh((value) => value + 1)}>重试</Button></Alert> : null}
{notice ? <div className="text-sm text-success" role="status">{notice}</div> : null}
{loadingGroups ? <Spinner aria-label="加载指标分组" /> : <div className="flex flex-wrap gap-2" aria-label="指标分组">{groups.map((item) => <Button key={item.name || item._id} aria-pressed={active === (item.name || item._id)} variant={active === (item.name || item._id) ? 'primary' : 'secondary'} onPress={() => { setActive(item.name || item._id); setKeyword(''); }}>{item.title}<Chip size="sm">{item.metadata?.indicators?.length || 0}</Chip></Button>)}</div>} {loadingGroups ? <Spinner aria-label="加载指标分组" /> : <div className="flex flex-wrap gap-2" aria-label="指标分组">{groups.map((item) => <Button key={item.name || item._id} aria-pressed={active === (item.name || item._id)} variant={active === (item.name || item._id) ? 'primary' : 'secondary'} onPress={() => { setActive(item.name || item._id); setKeyword(''); }}>{item.title}<Chip size="sm">{item.metadata?.indicators?.length || 0}</Chip></Button>)}</div>}
<Card> <Card>
<Card.Header className="flex-row flex-wrap items-center gap-3"><Card.Title className="mr-auto">{group?.title || '指标列表'}</Card.Title><Input aria-label="搜索指标" className="w-full sm:w-64" placeholder="搜索指标名称" value={keyword} onChange={(event) => setKeyword(event.target.value)} />{canManage && group ? <Button variant="primary" isDisabled={Boolean(executing)} onPress={() => openEditor(null)}>新增指标</Button> : null}</Card.Header> <Card.Header className="flex-row flex-wrap items-center gap-3"><Card.Title className="mr-auto">{group?.title || '指标列表'}</Card.Title><Input aria-label="搜索指标" className="w-full sm:w-64" placeholder="搜索指标名称" value={keyword} onChange={(event) => setKeyword(event.target.value)} />{canManage && group ? <Button variant="primary" isDisabled={Boolean(executing)} onPress={() => openEditor(null)}>新增指标</Button> : null}</Card.Header>
<Card.Content><DataTable ariaLabel="量化指标" columns={columns} rows={items.filter((item) => String(item.metadata?.indicator_name || item.title || '').includes(keyword))} isLoading={loading || loadingGroups} getRowKey={(item) => item._id} emptyTitle={groups.length ? '暂无指标' : '暂无指标分组'} /></Card.Content> <Card.Content>{!loading && !loadingGroups && (groupsLoadFailed || indicatorLoadFailed) ? <LoadErrorState title="指标加载失败" onRetry={() => setRefresh((value) => value + 1)} /> : <DataTable ariaLabel="量化指标" columns={columns} rows={items.filter((item) => String(item.metadata?.indicator_name || item.title || '').includes(keyword))} isLoading={loading || loadingGroups} getRowKey={(item) => item._id} emptyTitle={groups.length ? '暂无指标' : '暂无指标分组'} />}</Card.Content>
</Card> </Card>
<XinyuanModal isOpen={Boolean(modal)} onOpenChange={(open) => { if (!open) closeModal(); }} title={modal === 'group' ? '新建指标分组' : modal === 'delete' ? '删除指标' : editing ? '编辑量化指标' : '新建量化指标'} footer={<><Button variant="secondary" isDisabled={saving} onPress={closeModal}>取消</Button><Button variant={modal === 'delete' ? 'danger' : 'primary'} isPending={saving} onPress={save}>{unlinked ? '重试关联' : modal === 'delete' ? '确认删除' : '保存'}</Button></>}> <XinyuanModal isOpen={Boolean(modal)} onOpenChange={(open) => { if (!open) closeModal(); }} title={modal === 'group' ? '新建指标分组' : modal === 'delete' ? '删除指标' : editing ? '编辑量化指标' : '新建量化指标'} footer={<><Button variant="secondary" isDisabled={saving} onPress={closeModal}>取消</Button><Button variant={modal === 'delete' ? 'danger' : 'primary'} isPending={saving} onPress={save}>{unlinked ? '重试关联' : modal === 'delete' ? '确认删除' : '保存'}</Button></>}>
<div className="space-y-4"> <div className="space-y-4">
{formError ? <Alert status="danger"><Alert.Content><Alert.Description>{formError}</Alert.Description></Alert.Content></Alert> : null}
{modal === 'delete' ? <p>确定删除“{editing?.metadata?.indicator_name || editing?.title}”吗?</p> : modal === 'group' ? <label className="grid gap-2 text-sm">分组名称 <Input aria-label="分组名称" required value={draft.group_name || ''} onChange={(event) => setDraft({ group_name: event.target.value })} /></label> : !unlinked ? <> {modal === 'delete' ? <p>确定删除“{editing?.metadata?.indicator_name || editing?.title}”吗?</p> : modal === 'group' ? <label className="grid gap-2 text-sm">分组名称 <Input aria-label="分组名称" required value={draft.group_name || ''} onChange={(event) => setDraft({ group_name: event.target.value })} /></label> : !unlinked ? <>
<label className="grid gap-2 text-sm">指标名称 *<Input aria-label="指标名称" required value={draft.indicator_name || ''} onChange={(event) => setDraft({ ...draft, indicator_name: event.target.value })} /></label> <label className="grid gap-2 text-sm">指标名称 *<Input aria-label="指标名称" required value={draft.indicator_name || ''} onChange={(event) => setDraft({ ...draft, indicator_name: event.target.value })} /></label>
<ReferenceSearchSelect label="责任人" isRequired value={draft.owner} initialRecords={[editing?.metadata?.owner]} loadOptions={LOAD_OWNERS} onChange={(owner) => setDraft({ ...draft, owner })} /> <ReferenceSearchSelect label="责任人" isRequired value={draft.owner} initialRecords={[editing?.metadata?.owner]} loadOptions={LOAD_OWNERS} onChange={(owner) => setDraft({ ...draft, owner })} />
......
import LoadErrorState from '@/components/LoadErrorState';
import useFeedbackState from '@/hooks/useFeedbackState';
import AuditIndicatorView from './AuditIndicatorView'; import AuditIndicatorView from './AuditIndicatorView';
import { Alert, Button, Card, Input, Spinner, TextArea } from '@heroui/react'; import { Button, Card, Input, Spinner, TextArea, toast } from '@heroui/react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
...@@ -16,7 +18,7 @@ function useSubjectRecords(subjectName, groupName) { ...@@ -16,7 +18,7 @@ function useSubjectRecords(subjectName, groupName) {
const [group, setGroup] = useState(null); const [group, setGroup] = useState(null);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const reload = async () => { const reload = async () => {
if (!groupName) return; if (!groupName) return;
setLoading(true); setError(''); setLoading(true); setError('');
...@@ -34,11 +36,12 @@ function useSubjectRecords(subjectName, groupName) { ...@@ -34,11 +36,12 @@ function useSubjectRecords(subjectName, groupName) {
function ProcessListView({ subjectName, config }) { function ProcessListView({ subjectName, config }) {
const groupSubject = config?.group_subject || 'process_flow_group'; const groupSubject = config?.group_subject || 'process_flow_group';
const [groups, setGroups] = useState([]); const [active, setActive] = useState(''); const [modal, setModal] = useState(false); const [draft, setDraft] = useState({}); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteError, setDeleteError] = useState(''); const [error, setError] = useState(''); const [groups, setGroups] = useState([]); const [active, setActive] = useState(''); const [modal, setModal] = useState(false); const [draft, setDraft] = useState({}); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [, setDeleteError] = useFeedbackState('error'); const [, setError] = useFeedbackState('error');
const [groupsLoadFailed, setGroupsLoadFailed] = useState(false);
const { group, items, loading, error: groupError, reload } = useSubjectRecords(subjectName, active); const { group, items, loading, error: groupError, reload } = useSubjectRecords(subjectName, active);
const loadGroups = async () => { try { const result = await loadRecords(groupSubject, 0, 100); const next = result?.records || []; setGroups(next); setActive((current) => current || next[0]?.name || ''); } catch (requestError) { setError(requestError?.message || '流程分组加载失败'); } }; const loadGroups = async () => { setGroupsLoadFailed(false); setError(''); try { const result = await loadRecords(groupSubject, 0, 100); const next = result?.records || []; setGroups(next); setActive((current) => current || next[0]?.name || ''); } catch (requestError) { setGroupsLoadFailed(true); setError(requestError?.message || '流程分组加载失败'); } };
useEffect(() => { loadGroups(); }, [groupSubject]); useEffect(() => { loadGroups(); }, [groupSubject]);
const save = async () => { setSaving(true); setError(''); try { if (draft.group_name !== undefined) await createRecord({ subject: groupSubject, title: draft.group_name, metadata: { group_name: draft.group_name, process_list: [] } }); else if (draft.editName) await updateRecord(draft.editName, { title: draft.title, metadata: { remark: draft.remark, source: draft.source || null } }); else { const created = await createRecord({ subject: subjectName, title: draft.title, metadata: { remark: draft.remark, source: draft.source || null } }); await updateRecord(group.name, { metadata: { ...group.metadata, process_list: [...(group.metadata?.process_list || []).map(valueOf), created._id] } }); } setModal(false); setDraft({}); await loadGroups(); await reload(); } catch (requestError) { setError(requestError?.message || '保存失败'); } finally { setSaving(false); } }; const save = async () => { setSaving(true); setError(''); try { if (draft.group_name !== undefined) await createRecord({ subject: groupSubject, title: draft.group_name, metadata: { group_name: draft.group_name, process_list: [] } }); else if (draft.editName) await updateRecord(draft.editName, { title: draft.title, metadata: { remark: draft.remark, source: draft.source || null } }); else { const created = await createRecord({ subject: subjectName, title: draft.title, metadata: { remark: draft.remark, source: draft.source || null } }); await updateRecord(group.name, { metadata: { ...group.metadata, process_list: [...(group.metadata?.process_list || []).map(valueOf), created._id] } }); } setModal(false); setDraft({}); toast.success('保存成功', { timeout: 4000 }); await loadGroups(); await reload(); } catch (requestError) { setError(requestError?.message || '保存失败'); } finally { setSaving(false); } };
const confirmDeleteProcess = async () => { const confirmDeleteProcess = async () => {
if (!group || !deleteTarget) return; if (!group || !deleteTarget) return;
setDeleting(true); setDeleteError(''); setDeleting(true); setDeleteError('');
...@@ -46,11 +49,12 @@ function ProcessListView({ subjectName, config }) { ...@@ -46,11 +49,12 @@ function ProcessListView({ subjectName, config }) {
await removeRecord(deleteTarget.name || deleteTarget._id); await removeRecord(deleteTarget.name || deleteTarget._id);
await updateRecord(group.name, { metadata: { ...group.metadata, process_list: (group.metadata?.process_list || []).map(valueOf).filter((id) => id !== (deleteTarget._id || deleteTarget.name)) } }); await updateRecord(group.name, { metadata: { ...group.metadata, process_list: (group.metadata?.process_list || []).map(valueOf).filter((id) => id !== (deleteTarget._id || deleteTarget.name)) } });
setDeleteTarget(null); setDeleteTarget(null);
toast.success('流程已删除', { timeout: 4000 });
await reload(); await reload();
} catch (requestError) { setDeleteError(requestError?.message || '删除失败'); } } catch (requestError) { setDeleteError(requestError?.message || '删除失败'); }
finally { setDeleting(false); } finally { setDeleting(false); }
}; };
return <div className="rc-page-stack"><h2 className="mb-4 text-xl font-semibold">流程管理</h2>{error || groupError ? <Alert status="danger"><Alert.Content><Alert.Description>{error || groupError}</Alert.Description></Alert.Content></Alert> : null}<GroupTabs groups={groups} active={active} onChange={setActive} onCreate={() => { setDraft({ group_name: '' }); setModal(true); }} label="流程分组" /><Card><Card.Header><Card.Title className="mr-auto">{group?.title || '请选择流程分组'}</Card.Title>{group ? <Button variant="primary" onPress={() => { setDraft({}); setModal(true); }}>新增流程</Button> : null}</Card.Header><Card.Content>{loading ? <div className="flex min-h-32 items-center justify-center"><Spinner /></div> : !items.length ? <div className="py-16 text-center text-sm text-muted">暂无流程</div> : <ol className="space-y-3">{items.map((item, index) => <li className="flex items-center gap-3 rounded-lg border border-default p-3" key={item.name || item._id}><span className="grid h-8 w-8 place-items-center rounded-full bg-accent/10 text-sm font-semibold text-accent">{index + 1}</span><div className="min-w-0 flex-1"><div className="truncate font-medium">{item.title || item.name}</div><div className="text-sm text-muted">{item.metadata?.remark || '暂无说明'}</div></div><Button size="sm" variant="secondary" onPress={() => { setDraft({ ...item.metadata, title: item.title, editName: item.name }); setModal(true); }}>编辑</Button><Button size="sm" variant="secondary" onPress={() => history.push(`/${subjectName}/${item.name || item._id}`)}>查看</Button><Button size="sm" variant="danger" onPress={() => { setDeleteError(''); setDeleteTarget(item); }}>删除</Button></li>)}</ol>}</Card.Content></Card><XinyuanModal isOpen={Boolean(deleteTarget)} onOpenChange={(open) => { if (!open && !deleting) setDeleteTarget(null); }} title="删除流程" footer={<><Button variant="secondary" isDisabled={deleting} onPress={() => setDeleteTarget(null)}>取消</Button><Button variant="danger" isPending={deleting} onPress={confirmDeleteProcess}>确认删除</Button></>}><p>确定删除“{titleOf(deleteTarget)}”吗?</p>{deleteError ? <p className="mt-3 text-danger" role="alert">{deleteError}</p> : null}</XinyuanModal><XinyuanModal isOpen={modal} onOpenChange={setModal} title={draft.group_name !== undefined ? '新建流程分组' : draft.editName ? '编辑流程' : '新建流程'} footer={<><Button variant="secondary" onPress={() => setModal(false)}>取消</Button><Button variant="primary" isPending={saving} onPress={save}>保存</Button></>}><div className="grid gap-4">{draft.group_name !== undefined ? <Input label="分组名称" value={draft.group_name} onChange={(event) => setDraft({ ...draft, group_name: event.target.value })} /> : <><Input label="流程名称" value={draft.title || ''} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /><TextArea label="流程说明" value={draft.remark || ''} onChange={(event) => setDraft({ ...draft, remark: event.target.value })} /></>}</div></XinyuanModal></div>; return <div className="rc-page-stack"><h2 className="mb-4 text-xl font-semibold">流程管理</h2><GroupTabs groups={groups} active={active} onChange={setActive} onCreate={() => { setDraft({ group_name: '' }); setModal(true); }} label="流程分组" /><Card><Card.Header><Card.Title className="mr-auto">{group?.title || '请选择流程分组'}</Card.Title>{group ? <Button variant="primary" onPress={() => { setDraft({}); setModal(true); }}>新增流程</Button> : null}</Card.Header><Card.Content>{loading ? <div className="flex min-h-32 items-center justify-center"><Spinner /></div> : groupsLoadFailed || groupError ? <LoadErrorState title="流程加载失败" onRetry={() => { loadGroups(); reload(); }} /> : !items.length ? <div className="py-16 text-center text-sm text-muted">暂无流程</div> : <ol className="space-y-3">{items.map((item, index) => <li className="flex items-center gap-3 rounded-lg border border-default p-3" key={item.name || item._id}><span className="grid h-8 w-8 place-items-center rounded-full bg-accent/10 text-sm font-semibold text-accent">{index + 1}</span><div className="min-w-0 flex-1"><div className="truncate font-medium">{item.title || item.name}</div><div className="text-sm text-muted">{item.metadata?.remark || '暂无说明'}</div></div><Button size="sm" variant="secondary" onPress={() => { setDraft({ ...item.metadata, title: item.title, editName: item.name }); setModal(true); }}>编辑</Button><Button size="sm" variant="secondary" onPress={() => history.push(`/${subjectName}/${item.name || item._id}`)}>查看</Button><Button size="sm" variant="danger" onPress={() => { setDeleteError(''); setDeleteTarget(item); }}>删除</Button></li>)}</ol>}</Card.Content></Card><XinyuanModal isOpen={Boolean(deleteTarget)} onOpenChange={(open) => { if (!open && !deleting) setDeleteTarget(null); }} title="删除流程" footer={<><Button variant="secondary" isDisabled={deleting} onPress={() => setDeleteTarget(null)}>取消</Button><Button variant="danger" isPending={deleting} onPress={confirmDeleteProcess}>确认删除</Button></>}><p>确定删除“{titleOf(deleteTarget)}”吗?</p></XinyuanModal><XinyuanModal isOpen={modal} onOpenChange={setModal} title={draft.group_name !== undefined ? '新建流程分组' : draft.editName ? '编辑流程' : '新建流程'} footer={<><Button variant="secondary" onPress={() => setModal(false)}>取消</Button><Button variant="primary" isPending={saving} onPress={save}>保存</Button></>}><div className="grid gap-4">{draft.group_name !== undefined ? <Input label="分组名称" value={draft.group_name} onChange={(event) => setDraft({ ...draft, group_name: event.target.value })} /> : <><Input label="流程名称" value={draft.title || ''} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /><TextArea label="流程说明" value={draft.remark || ''} onChange={(event) => setDraft({ ...draft, remark: event.target.value })} /></>}</div></XinyuanModal></div>;
} }
export default function ConfiguredSpecialViews({ view, subjectName, config }) { export default function ConfiguredSpecialViews({ view, subjectName, config }) {
......
import { Alert, Button, Card, Chip, Input, Spinner } from '@heroui/react'; import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Card, Chip, Input, Spinner } from '@heroui/react';
import LoadErrorState from '@/components/LoadErrorState';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import { history, useLocation } from 'umi'; import { history, useLocation } from 'umi';
import { APPLICATION_LAYOUT } from '@/config/ApplicationConfig'; import { APPLICATION_LAYOUT } from '@/config/ApplicationConfig';
...@@ -51,7 +53,7 @@ export default function GlobalSearchPage() { ...@@ -51,7 +53,7 @@ export default function GlobalSearchPage() {
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [selectedGroupName, setSelectedGroupName] = useState(''); const [selectedGroupName, setSelectedGroupName] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [searched, setSearched] = useState(false); const [searched, setSearched] = useState(false);
const selectedGroup = useMemo( const selectedGroup = useMemo(
...@@ -111,7 +113,7 @@ export default function GlobalSearchPage() { ...@@ -111,7 +113,7 @@ export default function GlobalSearchPage() {
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
{loading ? <div className="flex min-h-32 items-center justify-center"><Spinner /></div> : null} {loading ? <div className="flex min-h-32 items-center justify-center"><Spinner /></div> : null}
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null} {!loading && error ? <LoadErrorState title="搜索失败" onRetry={executeSearch} /> : null}
{!loading && !error && searched && !groups.length ? <div className="py-16 text-center text-sm text-muted">暂无搜索结果</div> : null} {!loading && !error && searched && !groups.length ? <div className="py-16 text-center text-sm text-muted">暂无搜索结果</div> : null}
{!loading && !error && groups.length ? ( {!loading && !error && groups.length ? (
<div className="global-search-layout"> <div className="global-search-layout">
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
Alert,
Button, Button,
Card, Card,
Chip, Chip,
Input, Input,
Separator, Separator,
Spinner, Spinner,
toast,
} from '@heroui/react'; } from '@heroui/react';
import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { history, useLocation, useParams, useSearchParams } from 'umi'; import { history, useLocation, useParams, useSearchParams } from 'umi';
...@@ -28,6 +29,7 @@ import HeroSwitch from '@/components/HeroSwitch'; ...@@ -28,6 +29,7 @@ import HeroSwitch from '@/components/HeroSwitch';
import PdfAttachmentViewer from '@/components/PdfAttachmentViewer'; import PdfAttachmentViewer from '@/components/PdfAttachmentViewer';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton'; import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import LoadErrorState from '@/components/LoadErrorState';
import { import {
LawRecordFieldEditor as EditField, LawRecordFieldEditor as EditField,
LawRecordFieldValue as DetailField, LawRecordFieldValue as DetailField,
...@@ -204,7 +206,7 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) { ...@@ -204,7 +206,7 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [removing, setRemoving] = useState(false); const [removing, setRemoving] = useState(false);
const [removeTarget, setRemoveTarget] = useState(null); const [removeTarget, setRemoveTarget] = useState(null);
const [removeError, setRemoveError] = useState(''); const [, setRemoveError] = useFeedbackState('error');
const uploadFiles = async (files) => { const uploadFiles = async (files) => {
if (!field || !files?.length) return; if (!field || !files?.length) return;
...@@ -213,6 +215,7 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) { ...@@ -213,6 +215,7 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) {
try { try {
const uploaded = await Promise.all(files.map((file) => uploadDocument(file))); const uploaded = await Promise.all(files.map((file) => uploadDocument(file)));
await onPatchMetadata(field, [...attachments, ...uploaded]); await onPatchMetadata(field, [...attachments, ...uploaded]);
toast.success('附件上传成功', { timeout: 4000 });
} catch (error) { } catch (error) {
setError(error?.message || '附件上传失败'); setError(error?.message || '附件上传失败');
} finally { } finally {
...@@ -228,6 +231,7 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) { ...@@ -228,6 +231,7 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) {
try { try {
await onPatchMetadata(field, attachments.filter((item) => (item?._id || item?.url || item?.name || item?.title) !== key)); await onPatchMetadata(field, attachments.filter((item) => (item?._id || item?.url || item?.name || item?.title) !== key));
setRemoveTarget(null); setRemoveTarget(null);
toast.success('附件已移除', { timeout: 4000 });
} catch (error) { } catch (error) {
setRemoveError(error?.message || '附件移除失败'); setRemoveError(error?.message || '附件移除失败');
} finally { } finally {
...@@ -276,7 +280,6 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) { ...@@ -276,7 +280,6 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) {
title="移除附件" title="移除附件"
> >
<p>确定移除“{removeTarget?.title || removeTarget?.name || removeTarget?.url || '该附件'}”吗?</p> <p>确定移除“{removeTarget?.title || removeTarget?.name || removeTarget?.url || '该附件'}”吗?</p>
{removeError ? <p className="mt-3 text-danger" role="alert">{removeError}</p> : null}
</XinyuanModal> </XinyuanModal>
</Card> </Card>
); );
...@@ -369,7 +372,9 @@ function LawRecordDetail() { ...@@ -369,7 +372,9 @@ function LawRecordDetail() {
const [deletingRecord, setDeletingRecord] = useState(false); const [deletingRecord, setDeletingRecord] = useState(false);
const [deleteRecordOpen, setDeleteRecordOpen] = useState(false); const [deleteRecordOpen, setDeleteRecordOpen] = useState(false);
const [historyItems, setHistoryItems] = useState([]); const [historyItems, setHistoryItems] = useState([]);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [, setSaveSuccess] = useFeedbackState('success');
const [reloadKey, setReloadKey] = useState(0);
const [collections, setCollections] = useState(() => loadStorageArray(COLLECTION_STORAGE_KEY)); const [collections, setCollections] = useState(() => loadStorageArray(COLLECTION_STORAGE_KEY));
const [reading, setReading] = useState(() => loadReadingPreferences()); const [reading, setReading] = useState(() => loadReadingPreferences());
const [findText, setFindText] = useState(searchParams.get('keyword') || ''); const [findText, setFindText] = useState(searchParams.get('keyword') || '');
...@@ -378,10 +383,11 @@ function LawRecordDetail() { ...@@ -378,10 +383,11 @@ function LawRecordDetail() {
const [matchIndex, setMatchIndex] = useState(0); const [matchIndex, setMatchIndex] = useState(0);
const [referenceRecords, setReferenceRecords] = useState([]); const [referenceRecords, setReferenceRecords] = useState([]);
const [referenceLoading, setReferenceLoading] = useState(false); const [referenceLoading, setReferenceLoading] = useState(false);
const [referenceError, setReferenceError] = useState(''); const [, setReferenceError] = useFeedbackState('error');
const contentRef = useRef(null); const contentRef = useRef(null);
useEffect(() => { useEffect(() => {
setSaveSuccess('');
if (!subjectName || !recordName || subjectName === 'search') { if (!subjectName || !recordName || subjectName === 'search') {
setError('记录路径缺少业务对象或记录标识'); setError('记录路径缺少业务对象或记录标识');
setLoading(false); setLoading(false);
...@@ -405,7 +411,7 @@ function LawRecordDetail() { ...@@ -405,7 +411,7 @@ function LawRecordDetail() {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [recordName, subjectName]); }, [recordName, subjectName, reloadKey]);
const historyConfig = useMemo(() => sideInfo.find((item) => item.type === 'history') || null, [sideInfo]); const historyConfig = useMemo(() => sideInfo.find((item) => item.type === 'history') || null, [sideInfo]);
...@@ -560,8 +566,10 @@ function LawRecordDetail() { ...@@ -560,8 +566,10 @@ function LawRecordDetail() {
}; };
const save = async () => { const save = async () => {
if (saving || !record) return;
setSaving(true); setSaving(true);
setError(''); setError('');
setSaveSuccess('');
try { try {
for (const field of editFields) { for (const field of editFields) {
if (isRequiredField(field)) { if (isRequiredField(field)) {
...@@ -590,6 +598,7 @@ function LawRecordDetail() { ...@@ -590,6 +598,7 @@ function LawRecordDetail() {
setRecord((current) => ({ ...current, title, content, metadata })); setRecord((current) => ({ ...current, title, content, metadata }));
setDraft(metadata); setDraft(metadata);
setEditing(false); setEditing(false);
setSaveSuccess('详情已保存。');
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '保存失败'); setError(requestError?.message || '保存失败');
} finally { } finally {
...@@ -598,18 +607,24 @@ function LawRecordDetail() { ...@@ -598,18 +607,24 @@ function LawRecordDetail() {
}; };
const deleteRecord = async () => { const deleteRecord = async () => {
if (!recordName) return; if (!recordName || deletingRecord) return;
setDeletingRecord(true);
setError('');
try { try {
await removeRecord(recordName); await removeRecord(recordName);
setDeleteRecordOpen(false); setDeleteRecordOpen(false);
toast.success('记录已删除', { timeout: 4000 });
history.push(`/${subjectName}`); history.push(`/${subjectName}`);
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '删除失败'); setError(requestError?.message || '删除失败');
} finally {
setDeletingRecord(false);
} }
}; };
const openEditModal = () => { const openEditModal = () => {
setError(''); setError('');
setSaveSuccess('');
setDraft(record?.metadata || {}); setDraft(record?.metadata || {});
setEditing(true); setEditing(true);
}; };
...@@ -618,6 +633,7 @@ function LawRecordDetail() { ...@@ -618,6 +633,7 @@ function LawRecordDetail() {
if (saving) return; if (saving) return;
setEditing(false); setEditing(false);
setError(''); setError('');
setSaveSuccess('');
setDraft(record?.metadata || {}); setDraft(record?.metadata || {});
}; };
...@@ -626,7 +642,7 @@ function LawRecordDetail() { ...@@ -626,7 +642,7 @@ function LawRecordDetail() {
} }
if (!record) { if (!record) {
return <Alert status="danger"><Alert.Content><Alert.Description>{error || '记录不存在'}</Alert.Description></Alert.Content></Alert>; return <LoadErrorState title="记录加载失败或不存在" onRetry={() => setReloadKey((value) => value + 1)} />;
} }
return ( return (
...@@ -702,13 +718,11 @@ function LawRecordDetail() { ...@@ -702,13 +718,11 @@ function LawRecordDetail() {
引用识别 引用识别
</HeroSwitch> </HeroSwitch>
{referenceLoading ? <span className="text-sm text-muted" role="status">识别中…</span> : null} {referenceLoading ? <span className="text-sm text-muted" role="status">识别中…</span> : null}
{referenceError ? <span className="text-sm text-danger" role="status">{referenceError}</span> : null}
</div> </div>
</div> </div>
) : null} ) : null}
</div> </div>
{error && !editing ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
<div className={sideInfo.length ? 'law-record-layout' : 'law-record-layout law-record-layout-single'}> <div className={sideInfo.length ? 'law-record-layout' : 'law-record-layout law-record-layout-single'}>
<main className="law-record-main"> <main className="law-record-main">
...@@ -799,7 +813,6 @@ function LawRecordDetail() { ...@@ -799,7 +813,6 @@ function LawRecordDetail() {
size="5xl" size="5xl"
title="编辑记录" title="编辑记录"
> >
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
<section aria-labelledby="law-record-edit-title"> <section aria-labelledby="law-record-edit-title">
<h2 className="sr-only" id="law-record-edit-title">编辑内容</h2> <h2 className="sr-only" id="law-record-edit-title">编辑内容</h2>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
...@@ -834,7 +847,6 @@ function LawRecordDetail() { ...@@ -834,7 +847,6 @@ function LawRecordDetail() {
title="删除记录" title="删除记录"
> >
<p>确定删除“{record?.title || record?.name}”吗?</p> <p>确定删除“{record?.title || record?.name}”吗?</p>
{error ? <p className="mt-3 text-danger" role="alert">{error}</p> : null}
</XinyuanModal> </XinyuanModal>
</div> </div>
); );
......
import LoadErrorState from '@/components/LoadErrorState';
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
Alert,
Button, Button,
Card, Card,
Chip, Chip,
...@@ -9,6 +10,7 @@ import { ...@@ -9,6 +10,7 @@ import {
Separator, Separator,
Spinner, Spinner,
Tooltip, Tooltip,
toast,
} from '@heroui/react'; } from '@heroui/react';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { history, useLocation, useParams } from 'umi'; import { history, useLocation, useParams } from 'umi';
...@@ -407,7 +409,8 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo ...@@ -407,7 +409,8 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo
const [localRefreshSignal, setLocalRefreshSignal] = useState(0); const [localRefreshSignal, setLocalRefreshSignal] = useState(0);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300); const debouncedKeyword = useDebouncedValue(keyword, 300);
const fields = useListFields(subject, config); const fields = useListFields(subject, config);
const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys]); const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys]);
...@@ -418,6 +421,7 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo ...@@ -418,6 +421,7 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoadFailed(false);
setLoading(true); setLoading(true);
setError(''); setError('');
queryRecords({ queryRecords({
...@@ -435,6 +439,7 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo ...@@ -435,6 +439,7 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo
if (cancelled) return; if (cancelled) return;
setRecords([]); setRecords([]);
setTotal(0); setTotal(0);
setLoadFailed(true);
setError(requestError?.message || '记录加载失败'); setError(requestError?.message || '记录加载失败');
}).finally(() => { }).finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
...@@ -452,6 +457,7 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo ...@@ -452,6 +457,7 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo
await Promise.all(records.filter((record) => selectedKeySet.has(recordKey(record))).map((record) => removeRecord(recordKey(record)))); await Promise.all(records.filter((record) => selectedKeySet.has(recordKey(record))).map((record) => removeRecord(recordKey(record))));
setSelectedKeys([]); setSelectedKeys([]);
setDeleteOpen(false); setDeleteOpen(false);
toast.success('已删除所选记录', { timeout: 4000 });
setLocalRefreshSignal((value) => value + 1); setLocalRefreshSignal((value) => value + 1);
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '批量删除失败'); setError(requestError?.message || '批量删除失败');
...@@ -466,6 +472,7 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo ...@@ -466,6 +472,7 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo
setError(''); setError('');
try { try {
await exportSubjectRecords(subject, config?.list?.sort); await exportSubjectRecords(subject, config?.list?.sort);
toast.success('导出成功', { timeout: 4000 });
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '导出失败'); setError(requestError?.message || '导出失败');
} finally { } finally {
...@@ -482,8 +489,8 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo ...@@ -482,8 +489,8 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo
<ToolbarIconButton label="新建记录" icon={PlusIcon} className="law-toolbar-button-primary" variant="primary" onPress={() => onCreate({})} /> <ToolbarIconButton label="新建记录" icon={PlusIcon} className="law-toolbar-button-primary" variant="primary" onPress={() => onCreate({})} />
</ListHeader> </ListHeader>
<Card.Content className="space-y-4"> <Card.Content className="space-y-4">
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null} {!loading && loadFailed ? <LoadErrorState title="记录加载失败" onRetry={onRefresh} /> : null}
{!error ? <DataTable {!loadFailed ? <DataTable
ariaLabel={`${subject.title || subject.name}记录`} ariaLabel={`${subject.title || subject.name}记录`}
columns={[ columns={[
...(subject.type !== 'embedded' && !fields.some((field) => field.name === 'title') ? [{ name: 'title', label: '标题' }] : []), ...(subject.type !== 'embedded' && !fields.some((field) => field.name === 'title') ? [{ name: 'title', label: '标题' }] : []),
...@@ -506,7 +513,7 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo ...@@ -506,7 +513,7 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo
onSortChange={({ column, direction }) => { setSortState({ field: { name: String(column) }, direction: direction === 'ascending' ? 'asc' : 'desc' }); setPage(0); }} onSortChange={({ column, direction }) => { setSortState({ field: { name: String(column) }, direction: direction === 'ascending' ? 'asc' : 'desc' }); setPage(0); }}
renderSelectionActions={() => <Button size="sm" variant="danger" isDisabled={loading} onPress={removeSelected}>删除已选</Button>} renderSelectionActions={() => <Button size="sm" variant="danger" isDisabled={loading} onPress={removeSelected}>删除已选</Button>}
/> : null} /> : null}
{!error ? ( {!loadFailed ? (
<DataTablePagination <DataTablePagination
isDisabled={loading} isDisabled={loading}
page={page} page={page}
...@@ -520,7 +527,6 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo ...@@ -520,7 +527,6 @@ function LawTableView({ subject, config, refreshSignal, onCreate, onEdit, onRemo
</Card.Content> </Card.Content>
<XinyuanModal isOpen={deleteOpen} onOpenChange={(open) => { if (!deleting) setDeleteOpen(open); }} title="删除已选记录" footer={<><Button variant="secondary" isDisabled={deleting} onPress={() => setDeleteOpen(false)}>取消</Button><Button variant="danger" isPending={deleting} onPress={confirmRemoveSelected}>确认删除</Button></>}> <XinyuanModal isOpen={deleteOpen} onOpenChange={(open) => { if (!deleting) setDeleteOpen(open); }} title="删除已选记录" footer={<><Button variant="secondary" isDisabled={deleting} onPress={() => setDeleteOpen(false)}>取消</Button><Button variant="danger" isPending={deleting} onPress={confirmRemoveSelected}>确认删除</Button></>}>
<p>确定删除已选的 {selectedKeys.length} 条记录吗?</p> <p>确定删除已选的 {selectedKeys.length} 条记录吗?</p>
{error ? <p className="mt-3 text-danger" role="alert">{error}</p> : null}
</XinyuanModal> </XinyuanModal>
</Card> </Card>
); );
...@@ -533,7 +539,8 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov ...@@ -533,7 +539,8 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300); const debouncedKeyword = useDebouncedValue(keyword, 300);
const fields = useListFields(subject, config); const fields = useListFields(subject, config);
...@@ -541,6 +548,7 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov ...@@ -541,6 +548,7 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoadFailed(false);
setLoading(true); setLoading(true);
setError(''); setError('');
queryRecords({ queryRecords({
...@@ -558,6 +566,7 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov ...@@ -558,6 +566,7 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov
if (cancelled) return; if (cancelled) return;
setRecords([]); setRecords([]);
setTotal(0); setTotal(0);
setLoadFailed(true);
setError(requestError?.message || '记录加载失败'); setError(requestError?.message || '记录加载失败');
}).finally(() => { }).finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
...@@ -570,6 +579,7 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov ...@@ -570,6 +579,7 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov
setError(''); setError('');
try { try {
await exportSubjectRecords(subject, config?.list?.sort); await exportSubjectRecords(subject, config?.list?.sort);
toast.success('导出成功', { timeout: 4000 });
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '导出失败'); setError(requestError?.message || '导出失败');
} finally { } finally {
...@@ -585,8 +595,8 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov ...@@ -585,8 +595,8 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov
<ToolbarIconButton label="新建记录" icon={PlusIcon} className="law-toolbar-button-primary" variant="primary" onPress={() => onCreate({})} /> <ToolbarIconButton label="新建记录" icon={PlusIcon} className="law-toolbar-button-primary" variant="primary" onPress={() => onCreate({})} />
</ListHeader> </ListHeader>
<Card.Content className="space-y-4"> <Card.Content className="space-y-4">
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null} {!loading && loadFailed ? <LoadErrorState title="记录加载失败" onRetry={onRefresh} /> : null}
{loading ? <div className="flex min-h-32 items-center justify-center"><Spinner /></div> : error ? null : !records.length ? <LawEmptyState description={debouncedKeyword ? '没有找到匹配的法规,请调整搜索条件。' : undefined} /> : ( {loading ? <div className="flex min-h-32 items-center justify-center"><Spinner /></div> : loadFailed ? null : !records.length ? <LawEmptyState description={debouncedKeyword ? '没有找到匹配的法规,请调整搜索条件。' : undefined} /> : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{records.map((record) => ( {records.map((record) => (
<Card className="rc-card-surface law-record-card" key={recordKey(record)}> <Card className="rc-card-surface law-record-card" key={recordKey(record)}>
...@@ -612,7 +622,7 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov ...@@ -612,7 +622,7 @@ function LawCardView({ subject, config, refreshSignal, onCreate, onEdit, onRemov
))} ))}
</div> </div>
)} )}
{!error ? ( {!loadFailed ? (
<DataTablePagination <DataTablePagination
isDisabled={loading} isDisabled={loading}
page={page} page={page}
...@@ -635,7 +645,7 @@ function LawColumnView({ subject, config, refreshSignal, onCreate, onEdit }) { ...@@ -635,7 +645,7 @@ function LawColumnView({ subject, config, refreshSignal, onCreate, onEdit }) {
const [graphNodes, setGraphNodes] = useState([]); const [graphNodes, setGraphNodes] = useState([]);
const [columnTotals, setColumnTotals] = useState({}); const [columnTotals, setColumnTotals] = useState({});
const [metaLoading, setMetaLoading] = useState(false); const [metaLoading, setMetaLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const levelOptions = fieldOptions(subject, 'level'); const levelOptions = fieldOptions(subject, 'level');
const statusOptions = fieldOptions(subject, 'effective_status'); const statusOptions = fieldOptions(subject, 'effective_status');
const tagTree = useMemo(() => buildGraphTagTree(graphNodes), [graphNodes]); const tagTree = useMemo(() => buildGraphTagTree(graphNodes), [graphNodes]);
...@@ -769,7 +779,6 @@ function LawColumnView({ subject, config, refreshSignal, onCreate, onEdit }) { ...@@ -769,7 +779,6 @@ function LawColumnView({ subject, config, refreshSignal, onCreate, onEdit }) {
</div> </div>
</div> </div>
</div> </div>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
<div className="law-column-board law-column-board-fixed" style={{ gridTemplateColumns: `repeat(${Math.max(levelOptions.length, 1)}, minmax(16rem, 1fr))` }}> <div className="law-column-board law-column-board-fixed" style={{ gridTemplateColumns: `repeat(${Math.max(levelOptions.length, 1)}, minmax(16rem, 1fr))` }}>
{levelOptions.length ? levelOptions.map((level) => ( {levelOptions.length ? levelOptions.map((level) => (
<LawColumnLevel <LawColumnLevel
...@@ -799,7 +808,7 @@ function LawColumnLevel({ subject, config, level, filterConfig, showDetail, refr ...@@ -799,7 +808,7 @@ function LawColumnLevel({ subject, config, level, filterConfig, showDetail, refr
const [expanded, setExpanded] = useState([]); const [expanded, setExpanded] = useState([]);
const [draggingKey, setDraggingKey] = useState(''); const [draggingKey, setDraggingKey] = useState('');
const [dragOverKey, setDragOverKey] = useState(''); const [dragOverKey, setDragOverKey] = useState('');
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const filterKey = JSON.stringify(filterConfig); const filterKey = JSON.stringify(filterConfig);
const canLoadMore = records.length < total; const canLoadMore = records.length < total;
const allRecordKeys = useMemo(() => records.map((record) => record?._id || record?.name).filter(Boolean), [records]); const allRecordKeys = useMemo(() => records.map((record) => record?._id || record?.name).filter(Boolean), [records]);
...@@ -851,7 +860,7 @@ function LawColumnLevel({ subject, config, level, filterConfig, showDetail, refr ...@@ -851,7 +860,7 @@ function LawColumnLevel({ subject, config, level, filterConfig, showDetail, refr
const handleScroll = (event) => { const handleScroll = (event) => {
const target = event.currentTarget; const target = event.currentTarget;
if (!canLoadMore || loading || loadingMore) return; if (!canLoadMore || loading || loadingMore || error) return;
if (target.clientHeight + target.scrollTop + 160 >= target.scrollHeight) { if (target.clientHeight + target.scrollTop + 160 >= target.scrollHeight) {
loadPage(page + 1); loadPage(page + 1);
} }
...@@ -870,8 +879,9 @@ function LawColumnLevel({ subject, config, level, filterConfig, showDetail, refr ...@@ -870,8 +879,9 @@ function LawColumnLevel({ subject, config, level, filterConfig, showDetail, refr
setRecords(nextRecords.map((record) => (orderMap[recordKey(record)] !== undefined ? { ...record, order: orderMap[recordKey(record)] } : record))); setRecords(nextRecords.map((record) => (orderMap[recordKey(record)] !== undefined ? { ...record, order: orderMap[recordKey(record)] } : record)));
try { try {
await reorderRecords(orderMap); await reorderRecords(orderMap);
toast.success('排序已保存', { timeout: 4000 });
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '排序保存失败'); toast.danger(requestError?.message || '排序保存失败', { timeout: 8000 });
loadPage(0, true); loadPage(0, true);
} }
}; };
...@@ -889,7 +899,7 @@ function LawColumnLevel({ subject, config, level, filterConfig, showDetail, refr ...@@ -889,7 +899,7 @@ function LawColumnLevel({ subject, config, level, filterConfig, showDetail, refr
</header> </header>
<div className="law-column-list" onScroll={handleScroll}> <div className="law-column-list" onScroll={handleScroll}>
{loading ? <div className="flex min-h-32 items-center justify-center"><Spinner size="sm" /></div> : null} {loading ? <div className="flex min-h-32 items-center justify-center"><Spinner size="sm" /></div> : null}
{!loading && error ? <div className="px-3 py-6 text-sm text-danger">{error}</div> : null} {!loading && error ? <LoadErrorState title="栏目加载失败" onRetry={() => loadPage(0, true)} /> : null}
{!loading && !error && records.map((record) => { {!loading && !error && records.map((record) => {
const key = record?._id || record?.name; const key = record?._id || record?.name;
const isActive = record?.metadata?.effective_status === '现行有效' || !record?.metadata?.effective_status; const isActive = record?.metadata?.effective_status === '现行有效' || !record?.metadata?.effective_status;
...@@ -1034,7 +1044,8 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate, ...@@ -1034,7 +1044,8 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate,
const [categoryList, setCategoryList] = useState([]); const [categoryList, setCategoryList] = useState([]);
const [categoryFields, setCategoryFields] = useState([]); const [categoryFields, setCategoryFields] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const layout = config?.list?.layout || 'list'; const layout = config?.list?.layout || 'list';
const words = config?.list?.words || []; const words = config?.list?.words || [];
const categoryEnabled = config?.list?.search?.category || false; const categoryEnabled = config?.list?.search?.category || false;
...@@ -1073,6 +1084,7 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate, ...@@ -1073,6 +1084,7 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate,
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoadFailed(false);
setLoading(true); setLoading(true);
setError(''); setError('');
const terms = Object.entries(filters).reduce((result, [name, filter]) => { const terms = Object.entries(filters).reduce((result, [name, filter]) => {
...@@ -1118,6 +1130,7 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate, ...@@ -1118,6 +1130,7 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate,
setRecords([]); setRecords([]);
setTotal(0); setTotal(0);
setFilterBuckets([]); setFilterBuckets([]);
setLoadFailed(true);
setError(requestError?.message || '搜索失败'); setError(requestError?.message || '搜索失败');
} }
}).finally(() => { }).finally(() => {
...@@ -1151,6 +1164,7 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate, ...@@ -1151,6 +1164,7 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate,
setError(''); setError('');
try { try {
await exportSubjectRecords(subject, config?.list?.sort); await exportSubjectRecords(subject, config?.list?.sort);
toast.success('导出成功', { timeout: 4000 });
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '下载失败'); setError(requestError?.message || '下载失败');
} finally { } finally {
...@@ -1198,7 +1212,7 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate, ...@@ -1198,7 +1212,7 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate,
</aside> </aside>
) : null} ) : null}
<div className="law-search-content"> <div className="law-search-content">
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null} {!loading && loadFailed ? <LoadErrorState title="记录加载失败" onRetry={onRefresh} /> : null}
<section className="law-search-results"> <section className="law-search-results">
<div className="law-search-result-header"> <div className="law-search-result-header">
<strong>搜索结果 ({total})</strong> <strong>搜索结果 ({total})</strong>
...@@ -1227,8 +1241,8 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate, ...@@ -1227,8 +1241,8 @@ function LawSearchView({ subject, config, application, refreshSignal, onCreate,
onToggle={(key) => setExpandedRecords((current) => (current.includes(key) ? current.filter((item) => item !== key) : [...current, key]))} onToggle={(key) => setExpandedRecords((current) => (current.includes(key) ? current.filter((item) => item !== key) : [...current, key]))}
/> />
) : null} ) : null}
{!loading && !error && !records.length ? <LawEmptyState description={submittedKeyword ? '没有找到匹配的法规,请调整关键词或筛选条件。' : undefined} className="law-search-empty-state" /> : null} {!loading && !loadFailed && !records.length ? <LawEmptyState description={submittedKeyword ? '没有找到匹配的法规,请调整关键词或筛选条件。' : undefined} className="law-search-empty-state" /> : null}
{!error ? ( {!loadFailed ? (
<div className="law-search-pagination"> <div className="law-search-pagination">
<DataTablePagination <DataTablePagination
isDisabled={loading} isDisabled={loading}
...@@ -1394,6 +1408,7 @@ function ImportActions({ config, subject, onImported, setError }) { ...@@ -1394,6 +1408,7 @@ function ImportActions({ config, subject, onImported, setError }) {
throw new Error('导入文件没有可识别的数据行'); throw new Error('导入文件没有可识别的数据行');
} }
await importRecords(subject.name, records); await importRecords(subject.name, records);
toast.success('目录导入成功', { timeout: 4000 });
onImported?.(); onImported?.();
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '导入目录失败'); setError(requestError?.message || '导入目录失败');
...@@ -1414,6 +1429,7 @@ function ImportActions({ config, subject, onImported, setError }) { ...@@ -1414,6 +1429,7 @@ function ImportActions({ config, subject, onImported, setError }) {
records.push(await buildDocumentImportRecord(file, subject, config)); records.push(await buildDocumentImportRecord(file, subject, config));
} }
await Promise.all(records.map((record) => createRecord(record))); await Promise.all(records.map((record) => createRecord(record)));
toast.success('文档导入成功', { timeout: 4000 });
onImported?.(); onImported?.();
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '导入文档失败'); setError(requestError?.message || '导入文档失败');
...@@ -1878,7 +1894,8 @@ export default function LawSubjectPage() { ...@@ -1878,7 +1894,8 @@ export default function LawSubjectPage() {
const listView = config?.list?.view || 'table'; const listView = config?.list?.view || 'table';
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [loadingSubject, setLoadingSubject] = useState(true); const [loadingSubject, setLoadingSubject] = useState(true);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [subjectReloadKey, setSubjectReloadKey] = useState(0);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [formRecord, setFormRecord] = useState(null); const [formRecord, setFormRecord] = useState(null);
const [draft, setDraft] = useState({}); const [draft, setDraft] = useState({});
...@@ -1905,7 +1922,7 @@ export default function LawSubjectPage() { ...@@ -1905,7 +1922,7 @@ export default function LawSubjectPage() {
if (!cancelled) setLoadingSubject(false); if (!cancelled) setLoadingSubject(false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [canLoadSubject, subjectName]); }, [canLoadSubject, subjectName, subjectReloadKey]);
const openCreate = (prefill = {}) => { const openCreate = (prefill = {}) => {
if (config?.list?.create?.path) { if (config?.list?.create?.path) {
...@@ -1937,6 +1954,7 @@ export default function LawSubjectPage() { ...@@ -1937,6 +1954,7 @@ export default function LawSubjectPage() {
} else { } else {
await createRecord(payload); await createRecord(payload);
} }
toast.success('保存成功', { timeout: 4000 });
setFormOpen(false); setFormOpen(false);
setFormRecord(null); setFormRecord(null);
setDraft({}); setDraft({});
...@@ -1956,6 +1974,7 @@ export default function LawSubjectPage() { ...@@ -1956,6 +1974,7 @@ export default function LawSubjectPage() {
try { try {
await removeRecord(name); await removeRecord(name);
setRemovingRecord(null); setRemovingRecord(null);
toast.success('记录已删除', { timeout: 4000 });
setRefreshSignal((value) => value + 1); setRefreshSignal((value) => value + 1);
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '删除失败'); setError(requestError?.message || '删除失败');
...@@ -1963,7 +1982,7 @@ export default function LawSubjectPage() { ...@@ -1963,7 +1982,7 @@ export default function LawSubjectPage() {
}; };
if (!canLoadSubject) { if (!canLoadSubject) {
return <Alert status="warning"><Alert.Content><Alert.Description>当前路径未指定业务对象</Alert.Description></Alert.Content></Alert>; return <LoadErrorState title="当前路径未指定业务对象" />;
} }
if (loadingSubject) { if (loadingSubject) {
...@@ -1971,7 +1990,7 @@ export default function LawSubjectPage() { ...@@ -1971,7 +1990,7 @@ export default function LawSubjectPage() {
} }
if (!subject) { if (!subject) {
return <Alert status="danger"><Alert.Content><Alert.Description>{error || '对象加载失败'}</Alert.Description></Alert.Content></Alert>; return <LoadErrorState title="对象加载失败" onRetry={() => setSubjectReloadKey((value) => value + 1)} />;
} }
if (listView === 'audit_indicator_list' || listView === 'process_list') { if (listView === 'audit_indicator_list' || listView === 'process_list') {
...@@ -1991,13 +2010,12 @@ export default function LawSubjectPage() { ...@@ -1991,13 +2010,12 @@ export default function LawSubjectPage() {
return ( return (
<div className="rc-page-stack"> <div className="rc-page-stack">
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
{listView === 'column' ? <LawColumnView {...viewProps} /> : null} {listView === 'column' ? <LawColumnView {...viewProps} /> : null}
{listView === 'card' ? <LawCardView {...viewProps} /> : null} {listView === 'card' ? <LawCardView {...viewProps} /> : null}
{listView === 'search' ? <LawSearchView {...viewProps} advanced /> : null} {listView === 'search' ? <LawSearchView {...viewProps} advanced /> : null}
{listView === 'search_view' ? <LawSearchView {...viewProps} /> : null} {listView === 'search_view' ? <LawSearchView {...viewProps} /> : null}
{!['column', 'card', 'search', 'search_view'].includes(listView) ? <LawTableView {...viewProps} /> : null} {!['column', 'card', 'search', 'search_view'].includes(listView) ? <LawTableView {...viewProps} /> : null}
<XinyuanModal isOpen={Boolean(removingRecord)} onOpenChange={(open) => { if (!open && !deleting) setRemovingRecord(null); }} title="删除记录" footer={<><Button variant="secondary" isDisabled={deleting} onPress={() => setRemovingRecord(null)}>取消</Button><Button variant="danger" isPending={deleting} onPress={confirmRemove}>确认删除</Button></>}><p>确定删除“{removingRecord?.title || removingRecord?.name}”吗?</p>{error ? <p className="mt-3 text-danger" role="alert">{error}</p> : null}</XinyuanModal> <XinyuanModal isOpen={Boolean(removingRecord)} onOpenChange={(open) => { if (!open && !deleting) setRemovingRecord(null); }} title="删除记录" footer={<><Button variant="secondary" isDisabled={deleting} onPress={() => setRemovingRecord(null)}>取消</Button><Button variant="danger" isPending={deleting} onPress={confirmRemove}>确认删除</Button></>}><p>确定删除“{removingRecord?.title || removingRecord?.name}”吗?</p></XinyuanModal>
<SubjectRecordModal <SubjectRecordModal
config={config} config={config}
draft={draft} draft={draft}
......
import useFeedbackState from '@/hooks/useFeedbackState';
import AppShell from '@/components/AppShell'; import AppShell from '@/components/AppShell';
import { Alert } from '@heroui/react'; import LoadErrorState from '@/components/LoadErrorState';
import { FileText, Search } from '@/components/AppIcons'; import { FileText, Search } from '@/components/AppIcons';
import { APPLICATION_NAME, APP_NAME } from '@/config/ApplicationConfig'; import { APPLICATION_NAME, APP_NAME } from '@/config/ApplicationConfig';
import { loadApplication } from '@/services/DataService'; import { loadApplication } from '@/services/DataService';
...@@ -47,11 +48,13 @@ export default function LawWorkbenchLayout() { ...@@ -47,11 +48,13 @@ export default function LawWorkbenchLayout() {
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [application, setApplication] = useState(null); const [application, setApplication] = useState(null);
const [applicationError, setApplicationError] = useState(''); const [applicationError, setApplicationError] = useFeedbackState('error');
const [reloadKey, setReloadKey] = useState(0);
const isLoadingApplication = !application && !applicationError; const isLoadingApplication = !application && !applicationError;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setApplicationError('');
loadApplication(APPLICATION_NAME) loadApplication(APPLICATION_NAME)
.then((result) => { .then((result) => {
if (cancelled) return; if (cancelled) return;
...@@ -64,7 +67,7 @@ export default function LawWorkbenchLayout() { ...@@ -64,7 +67,7 @@ export default function LawWorkbenchLayout() {
if (!cancelled) setApplicationError(error?.message || '应用配置加载失败'); if (!cancelled) setApplicationError(error?.message || '应用配置加载失败');
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [reloadKey]);
const menuGroups = useMemo(() => [{ const menuGroups = useMemo(() => [{
title: application?.title || APP_NAME, title: application?.title || APP_NAME,
...@@ -98,11 +101,7 @@ export default function LawWorkbenchLayout() { ...@@ -98,11 +101,7 @@ export default function LawWorkbenchLayout() {
> >
{applicationError ? ( {applicationError ? (
<div className="rc-page-stack"> <div className="rc-page-stack">
<Alert status="danger"> <LoadErrorState title="应用配置加载失败" onRetry={() => setReloadKey((value) => value + 1)} />
<Alert.Content>
<Alert.Description>{applicationError}</Alert.Description>
</Alert.Content>
</Alert>
</div> </div>
) : null} ) : null}
{application ? <Outlet /> : null} {application ? <Outlet /> : null}
......
import useFeedbackState from '@/hooks/useFeedbackState';
import xyLogoRed from '@/assets/identity/xy-logo-red.png'; import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png'; import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_BASE_PATH, APP_NAME } from '@/config/ApplicationConfig'; import { APP_BASE_PATH, APP_NAME } from '@/config/ApplicationConfig';
...@@ -5,7 +6,6 @@ import authService from '@/services/authService'; ...@@ -5,7 +6,6 @@ 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 { import {
Alert,
Button, Button,
Card, Card,
Input, Input,
...@@ -55,7 +55,7 @@ export default function LoginPage() { ...@@ -55,7 +55,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null); const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const redirect = normalizeRedirect(searchParams.get('redirect')); const redirect = normalizeRedirect(searchParams.get('redirect'));
...@@ -209,13 +209,6 @@ export default function LoginPage() { ...@@ -209,13 +209,6 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button <Button
fullWidth fullWidth
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import { history, useLocation, useParams } from '@umijs/max'; import { history, useLocation, useParams } from '@umijs/max';
import { Button, Spinner } from '@heroui/react'; import { Button, Spinner } from '@heroui/react';
...@@ -5,6 +6,7 @@ import { ArrowPathIcon } from '@/components/AppIcons'; ...@@ -5,6 +6,7 @@ import { ArrowPathIcon } from '@/components/AppIcons';
import HeroCheckbox from '@/components/HeroCheckbox'; import HeroCheckbox from '@/components/HeroCheckbox';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import RecordFlowGraph from '@/components/RecordFlowGraph/RecordFlowGraph'; import RecordFlowGraph from '@/components/RecordFlowGraph/RecordFlowGraph';
import LoadErrorState from '@/components/LoadErrorState';
import { loadRecordGraph } from '@/services/RecordGraphService'; import { loadRecordGraph } from '@/services/RecordGraphService';
import { ApplicationContext } from './LawWorkbenchLayout'; import { ApplicationContext } from './LawWorkbenchLayout';
...@@ -18,9 +20,9 @@ export default function RecordGraphPage() { ...@@ -18,9 +20,9 @@ export default function RecordGraphPage() {
const groupsKey = JSON.stringify(subjectConfig?.tag?.group?.record || []); const groupsKey = JSON.stringify(subjectConfig?.tag?.group?.record || []);
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [operationError, setOperationError] = useState(''); const [, setOperationError] = useFeedbackState('error');
const [success, setSuccess] = useState(''); const [, setSuccess] = useFeedbackState('success');
const [centered, setCentered] = useState(false); const [centered, setCentered] = useState(false);
const [refresh, setRefresh] = useState(0); const [refresh, setRefresh] = useState(0);
const filterConfig = { _id: query.get('node'), department: query.get('dep'), tag: query.get('tag') }; const filterConfig = { _id: query.get('node'), department: query.get('dep'), tag: query.get('tag') };
...@@ -59,8 +61,7 @@ export default function RecordGraphPage() { ...@@ -59,8 +61,7 @@ export default function RecordGraphPage() {
<HeroCheckbox isSelected={centered} onChange={setCentered}>居中</HeroCheckbox> <HeroCheckbox isSelected={centered} onChange={setCentered}>居中</HeroCheckbox>
<Button isIconOnly variant="secondary" aria-label="刷新关系图" isDisabled={loading} onPress={() => { setRefresh(value => value + 1); setOperationError(''); setSuccess(''); }}><ArrowPathIcon size={16} /></Button> <Button isIconOnly variant="secondary" aria-label="刷新关系图" isDisabled={loading} onPress={() => { setRefresh(value => value + 1); setOperationError(''); setSuccess(''); }}><ArrowPathIcon size={16} /></Button>
</div> </div>
{(error || operationError) && <div role="alert" className="flex items-center justify-center gap-3 p-3 text-danger">{error || operationError}<Button size="sm" variant="secondary" onPress={() => setRefresh(value => value + 1)}>重试</Button></div>} {!loading && error ? <LoadErrorState title="关系图加载失败" onRetry={() => setRefresh(value => value + 1)} /> : null}
{success && !operationError && <p role="status" className="px-4 py-2 text-sm text-success">{success}</p>}
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
{loading ? <div className="absolute inset-0 flex items-center justify-center gap-2" role="status"><Spinner />正在加载关系图…</div> : !error && data ? <> {loading ? <div className="absolute inset-0 flex items-center justify-center gap-2" role="status"><Spinner />正在加载关系图…</div> : !error && data ? <>
<RecordFlowGraph key={`${subjectName}/${filterConfig._id}/${filterConfig.department}/${filterConfig.tag}/${centered}/${refresh}`} <RecordFlowGraph key={`${subjectName}/${filterConfig._id}/${filterConfig.department}/${filterConfig.tag}/${centered}/${refresh}`}
......
...@@ -26,7 +26,16 @@ ...@@ -26,7 +26,16 @@
## 权限与异常 ## 权限与异常
请求统一复用 `record-api.md` 中的鉴权和错误处理。接口失败时保留当前页面草稿并在页面内显示错误;无记录时显示明确的空状态;路由无法解析 Subject 时不会发送 `undefined` 请求。 请求统一复用 `record-api.md` 中的鉴权和错误处理。接口失败时保留当前页面草稿,通过全局操作提示显示具体错误;无记录时显示明确的空状态;路由无法解析 Subject 时不会发送 `undefined` 请求。
### 接口与操作反馈(2026-10-08)
- 登录、应用配置与切换、法规列表与搜索、编辑/删除弹窗、附件上传/移除、目录/文档导入、导出、指标及执行结果、关系图和通知入口统一使用根层 HeroUI Toast,不再显示常驻成功/失败横条。提示位于屏幕顶部中央,层级高于弹窗和移动侧栏,页面滚动不会遮挡。
- 成功提示 4 秒、失败/警告提示 8 秒后自动消失;最前方提示提供始终可见的“关闭提示”按钮,使用 HeroUI `ghost` 图标按钮并参与提示内容布局,保留主题悬停和键盘焦点状态;后方堆叠提示隐藏关闭按钮,避免误点。最多同时显示 3 条,长错误信息可滚动阅读。相同请求重试后仍失败时再次显示提示。
- 保存、上传、移除和删除失败保留弹窗、草稿及选择,关闭提示不会关闭弹窗或触发成功流程。成功后按原流程刷新数据或返回列表,并显示相应操作反馈。
- 详情页通过顶部“编辑”进入编辑弹窗,点击“保存修改”后先校验当前 Subject 必填字段,再调用 `PUT /record/:name` 写入原有标题、正文和 `metadata`。保存期间禁用重复提交;接口成功后更新详情数据、关闭编辑弹窗,并由详情页显示“详情已保存。”成功提示,提示不会随弹窗关闭而消失。校验或接口失败时不显示成功提示,保留编辑弹窗和草稿。重新编辑、取消编辑、再次保存或切换记录时清除上一轮保存成功提示。
- 对象、列表、搜索、详情、指标、关系图和通知加载失败保留独立失败状态,显示 EmptyState 和“重新加载”入口;提示关闭后不会显示“暂无数据”或无限加载。导入/导出、保存等操作失败不隐藏已经加载的数据。重新加载时清除旧失败状态,成功后恢复原数据视图。
- 本次仅调整反馈和重试展示,沿用 `GET /subject/:name`、Record 查询/创建/更新/删除、搜索与通知的现有 service;业务对象和 metadata 字段协议不变。
## 六种配置视图与 streams-admin 对齐(2026-09-09) ## 六种配置视图与 streams-admin 对齐(2026-09-09)
......
import useFeedbackState from '@/hooks/useFeedbackState';
import xinyuanLogo from '@/assets/identity/xy-logo-red.png'; import xinyuanLogo from '@/assets/identity/xy-logo-red.png';
import { import {
APP_NAME, APP_NAME,
...@@ -5,12 +6,11 @@ import { ...@@ -5,12 +6,11 @@ import {
} from '@/config/ApplicationConfig'; } from '@/config/ApplicationConfig';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
import { EmptyStateSurface } from '@/components/DataTable'; import { EmptyStateSurface } from '@/components/DataTable';
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;
} }
...@@ -443,12 +444,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -443,12 +444,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 />
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons'; import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable'; import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Button, Card, Chip, SearchField, Spinner, Tabs, toast } from '@heroui/react'; import { Avatar, Button, Card, Chip, SearchField, Spinner, Tabs, toast } from '@heroui/react';
import LoadErrorState from '@/components/LoadErrorState';
import { useCallback, useContext, useEffect, useState } from 'react'; import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
import { import {
...@@ -75,7 +77,7 @@ export default function NotificationListPage() { ...@@ -75,7 +77,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [unread, setUnread] = useState(0); const [unread, setUnread] = 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);
...@@ -103,8 +105,8 @@ export default function NotificationListPage() { ...@@ -103,8 +105,8 @@ export default function NotificationListPage() {
const refresh = useCallback(() => setReloadKey((value) => value + 1), []); const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const run = useCallback(async (key, operation, message) => { const run = useCallback(async (key, operation, message) => {
setBusyKey(key); setBusyKey(key);
try { await operation(); toast.success(message, { timeout: 3000 }); refresh(); return true; } try { await operation(); toast.success(message, { timeout: 4000 }); refresh(); return true; }
catch (operationError) { toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 }); return false; } catch (operationError) { toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 8000 }); return false; }
finally { setBusyKey(''); } finally { setBusyKey(''); }
}, [refresh]); }, [refresh]);
...@@ -115,7 +117,7 @@ export default function NotificationListPage() { ...@@ -115,7 +117,7 @@ export default function NotificationListPage() {
if (target.external) window.open(target.href, '_blank', 'noopener,noreferrer'); if (target.external) window.open(target.href, '_blank', 'noopener,noreferrer');
else history.push(target.href); else history.push(target.href);
refresh(); refresh();
} catch (operationError) { toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 }); } } catch (operationError) { toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 8000 }); }
finally { setBusyKey(''); } finally { setBusyKey(''); }
}, [refresh, userInfo?._id]); }, [refresh, userInfo?._id]);
...@@ -135,7 +137,7 @@ export default function NotificationListPage() { ...@@ -135,7 +137,7 @@ export default function NotificationListPage() {
<Tabs.ListContainer><Tabs.List aria-label="通知状态">{READ_OPTIONS.map((option) => <Tabs.Tab id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer> <Tabs.ListContainer><Tabs.List aria-label="通知状态">{READ_OPTIONS.map((option) => <Tabs.Tab id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer>
</Tabs> </Tabs>
</div> </div>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="ghost" onPress={refresh}>重试</Button></Alert> : null} {!loading && error ? <LoadErrorState title="通知加载失败" onRetry={refresh} /> : null}
{loading && items.length === 0 ? <div className="flex min-h-64 items-center justify-center"><Spinner size="sm" /></div> : null} {loading && items.length === 0 ? <div className="flex min-h-64 items-center justify-center"><Spinner size="sm" /></div> : null}
{!loading && !error && items.length === 0 ? <EmptyState className="min-h-64" size="md"><EmptyState.Media><Bell size={22} /></EmptyState.Media><EmptyState.Header><EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title><EmptyState.Description>{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}</EmptyState.Description></EmptyState.Header></EmptyState> : null} {!loading && !error && items.length === 0 ? <EmptyState className="min-h-64" size="md"><EmptyState.Media><Bell size={22} /></EmptyState.Media><EmptyState.Header><EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title><EmptyState.Description>{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}</EmptyState.Description></EmptyState.Header></EmptyState> : null}
{items.length > 0 ? <div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>{items.map((item) => <NotificationItem category={category} currentUserId={userInfo?._id} isBusy={busyKey === item.name} key={item.name || item._id} notification={item} onAction={(notification, action) => run(notification.name, () => executeNotificationAction(notification, action), '待办已处理')} onMarkRead={(notification) => run(notification.name, () => markNotificationRead(notification.name), '已标记为已读')} onOpenTarget={openTarget} />)}</div> : null} {items.length > 0 ? <div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>{items.map((item) => <NotificationItem category={category} currentUserId={userInfo?._id} isBusy={busyKey === item.name} key={item.name || item._id} notification={item} onAction={(notification, action) => run(notification.name, () => executeNotificationAction(notification, action), '待办已处理')} onMarkRead={(notification) => run(notification.name, () => markNotificationRead(notification.name), '已标记为已读')} onOpenTarget={openTarget} />)}</div> : null}
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
@import "./design-system/theme.css"; @import "./design-system/theme.css";
@import "./design-system/tokens.css"; @import "./design-system/tokens.css";
@import "./design-system/shell.css"; @import "./design-system/shell.css";
@import "./design-system/feedback.css";
/* Keep Tailwind's watcher scoped to authored source. Umi's generated .umi /* Keep Tailwind's watcher scoped to authored source. Umi's generated .umi
* files and hot-update artifacts must not trigger a CSS rebuild loop. */ * files and hot-update artifacts must not trigger a CSS rebuild loop. */
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService'; import { getSystemInfo } from '@/services/DataService';
import { APP_NAME } from '@/config/ApplicationConfig'; import { APP_NAME } from '@/config/ApplicationConfig';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
...@@ -19,7 +20,7 @@ export default function SiteWrapper() { ...@@ -19,7 +20,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('error');
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