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 })} />
......
This diff is collapsed.
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>
); );
......
This diff is collapsed.
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