Commit 4c7cbb86 authored by drigle's avatar drigle

feat: unify operation feedback and improve failure recovery

parent cd0dd754
......@@ -75,6 +75,14 @@
- DataGrid 使用原生 selection、排序、固定列、横向滚动、ActionBar、EmptyState 和 compound Pagination。
- 流程业务列表默认每页 20 条,分页条使用宽度 128px 的 HeroUI Autocomplete 提供 20/50/100 条选择;通知中心仍固定每页 10 条。
## 操作反馈浮层
- 参考问题整改 `513706c` 的全局反馈及 `bfc5862` 的关闭按钮与保存成功反馈,接口成功、失败和降级警告统一由根部 `AppFeedback` 的 HeroUI `Toast.Provider` 展示;登录页、流程监控页、通知页和应用切换弹窗共用一个 Provider,页面不再渲染接口反馈横条。
- 成功提示默认 4 秒、错误和警告默认 8 秒后自动消失。关闭按钮直接组合 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`),通过官方 `slot="close"` 接入 Toast 关闭行为,使用 `AppIcons.Close`,位于内容右侧并随正文参与布局;前排图标始终可见,后排堆叠提示隐藏关闭按钮,Hover 和焦点样式沿用官方主题。关闭提示不清除请求失败状态、表单草稿或弹窗。
- `src/design-system/feedback.css` 只约定 Toast 层级、顶部安全区与后排关闭按钮的可见性;层级 300 高于业务 Modal 和移动侧栏。不再放大带边框的默认关闭按钮,也不设置额外绝对位置或正文右侧占位;长文滚动和换行由 compound 内容组件承担,颜色、边框、圆角、阴影和 Hover 沿用 HeroUI 主题。
- “编辑模板监控配置”和“定义显示字段”弹窗仅在 `updateRecord` 成功后关闭并显示保存成功 Toast;重新打开编辑、再次保存或离开页面时清理该来源提示,模板实例页切换模板或重新加载时也清理提示。保存失败保留弹窗和草稿。
- 加载失败使用 HeroUI Pro EmptyState 和重新加载按钮;错误详情只出现在 Toast 中,提示关闭后仍可重试。同样的错误在每次重试失败时重新提示。
## 主题与资产
- 当前固定浅色内容画布与深藏蓝侧栏直接使用 `xy-logo-red.png`,不加底色、不滤镜、不反色、不替换 RSM 标识。
......
{
"name": "oa_process_monitor",
"version": "1.0.4",
"version": "1.0.5",
"build": "abc",
"description": "oa 流程监控",
"date": "2025-07-14 19:27:20",
......
import '@/global.less';
import '@/tailwind.css';
import { I18nProvider } from '@heroui/react';
import AppFeedback from '@/components/AppFeedback';
import { createElement } from 'react';
import { defineApp } from 'umi';
......@@ -12,6 +13,10 @@ export async function getInitialState() {
export default defineApp({
rootContainer(container) {
return createElement(I18nProvider, { locale: 'zh-CN' }, container);
return createElement(
I18nProvider,
{ locale: 'zh-CN' },
createElement(AppFeedback, null, container),
);
},
});
import { Button, Toast } from '@heroui/react';
import { Close } from '@/components/AppIcons';
export default function AppFeedback({ children }) {
return (
<>
{children}
<Toast.Provider
aria-label="操作提示"
className="app-feedback-region"
maxVisibleToasts={3}
placement="top"
width="min(30rem, calc(100vw - 2rem))"
>
{({ toast }) => (
<Toast className="app-feedback-toast gap-2" toast={toast} variant={toast.content.variant}>
<Toast.Indicator variant={toast.content.variant} />
<Toast.Content className="min-w-0">
<Toast.Title className="max-h-[40dvh] w-full overflow-y-auto whitespace-pre-wrap [overflow-wrap:anywhere]">
{toast.content.title}
</Toast.Title>
{toast.content.description ? (
<Toast.Description className="max-h-[40dvh] w-full overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
{toast.content.description}
</Toast.Description>
) : null}
{toast.content.actionProps ? <Toast.ActionButton {...toast.content.actionProps} /> : null}
</Toast.Content>
<Button
aria-label="关闭提示"
className="app-feedback-close shrink-0 self-start"
isIconOnly
size="sm"
slot="close"
variant="ghost"
>
<Close size={16} />
</Button>
</Toast>
)}
</Toast.Provider>
</>
);
}
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
......@@ -36,7 +35,6 @@ export default function AppShell({
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import useFeedbackState from '@/hooks/useFeedbackState';
import xinyuanLogo from '@/assets/xy-logo-red.png';
import {
APPLICATION_BASE_PATH,
......@@ -5,12 +6,11 @@ import {
APPLICATION_NAME,
} from '@/config/application';
import { getApplications } from '@/services/DataService';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons';
import { MagnifyingGlassIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Chip,
Modal,
......@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
......@@ -392,7 +392,7 @@ export default function ApplicationSwitcherDialog({
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
}, [fallbackApplications, open, setErrorMessage]);
return (
<Modal.Root>
......@@ -443,13 +443,6 @@ export default function ApplicationSwitcherDialog({
offset={12}
size={24}
>
{status === 'error' && errorMessage ? (
<Alert className="mb-4" status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? (
......
......@@ -8,13 +8,14 @@ import {
import { Button, Surface } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { FileText } from '@/components/AppIcons';
import useFeedbackState from '@/hooks/useFeedbackState';
import { useMemo } from 'react';
function resolveDetailValue(field, fieldMap, lookups) {
function resolveDetailValue(field, fieldMap, lookups, onDownload) {
if (field?.type === 'file') {
const files = Array.isArray(field?.value) ? field.value : [];
if (!files.length) return <span className="text-muted">暂无内容</span>;
return <div className="flex flex-wrap gap-2">{files.map((file) => <Button key={`${file.fileUrl || file.url}-${file.filename}`} size="sm" variant="secondary" onPress={() => downloadProcessAttachment(file)}>{file.filename || file.fileName || '附件'}</Button>)}</div>;
return <div className="flex flex-wrap gap-2">{files.map((file) => <Button key={`${file.fileUrl || file.url}-${file.filename}`} size="sm" variant="secondary" onPress={() => onDownload(file)}>{file.filename || file.fileName || '附件'}</Button>)}</div>;
}
const fieldDefinition = fieldMap[field?.label];
......@@ -35,6 +36,11 @@ function DetailField({ label, children, fullWidth = false }) {
}
export default function ProcessInstanceDetailContent({ data, loading = false, withSurface = false }) {
const [, setDownloadError] = useFeedbackState('error');
const handleDownloadAttachment = async (file) => {
try { await downloadProcessAttachment(file); }
catch (error) { setDownloadError(error?.message || '附件下载失败,请重试'); }
};
const record = data?.record;
const fieldsDefinition = data?.fieldsDefinition || [];
const members = data?.members || [];
......@@ -55,7 +61,7 @@ export default function ProcessInstanceDetailContent({ data, loading = false, wi
<DetailField label="审批者">{record?.metadata?.approver || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="所属部门">{record?.metadata?.department || <span className="text-muted">暂无内容</span>}</DetailField>
<DetailField label="显示条件">{relatedCondition?.key ? `${relatedCondition.key} = ${relatedCondition.value || '-'}` : '未配置'}</DetailField>
{details.map((field, index) => <DetailField key={`${field.label}-${index}`} label={field.label || `字段 ${index + 1}`} fullWidth={field?.type === 'file' || String(field?.value || '').length > 80}>{resolveDetailValue(field, fieldMap, lookups)}</DetailField>)}
{details.map((field, index) => <DetailField key={`${field.label}-${index}`} label={field.label || `字段 ${index + 1}`} fullWidth={field?.type === 'file' || String(field?.value || '').length > 80}>{resolveDetailValue(field, fieldMap, lookups, handleDownloadAttachment)}</DetailField>)}
</div>;
return withSurface ? <Surface className="rounded-2xl px-5 py-5 sm:px-6 sm:py-6" variant="secondary">{fieldGrid}</Surface> : fieldGrid;
......
/* Operation feedback stays above Modal and the mobile sidebar, outside page scrolling. */
.app-feedback-region.toast-region {
z-index: 300;
top: max(1rem, env(safe-area-inset-top));
}
/* Only the front toast is interactive; its ghost close button stays visible without hover. */
.app-feedback-toast:not([data-frontmost="true"]) .app-feedback-close {
visibility: hidden;
pointer-events: none;
}
import { toast } from '@heroui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
const FEEDBACK_OPTIONS = {
error: { title: '操作失败', variant: 'danger', timeout: 8000 },
success: { title: '操作成功', variant: 'success', timeout: 4000 },
warning: { title: '请注意', variant: 'warning', timeout: 8000 },
};
// Closing a toast leaves the request's failure state and form draft intact.
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 error text is identical.
toastKeyRef.current = toast(title, { description: nextMessage, variant, timeout });
}, [tone]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
};
}, []);
return [message, updateMessage];
}
import useFeedbackState from '@/hooks/useFeedbackState';
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import {
......@@ -7,7 +8,7 @@ import {
import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
import { Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi';
......@@ -55,7 +56,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => {
......@@ -205,14 +206,6 @@ export default function LoginPage() {
</div>
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
className="login-submit-button"
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon } from '@/components/AppIcons';
import { useUserInfo } from '@/hooks/user';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
......@@ -205,7 +205,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
......@@ -247,7 +247,7 @@ export default function NotificationListPage() {
return () => {
cancelled = true;
};
}, [category, keyword, page, readStatus, reloadKey]);
}, [category, keyword, page, readStatus, reloadKey, setError]);
useEffect(() => {
let cancelled = false;
......@@ -278,12 +278,12 @@ export default function NotificationListPage() {
setBusyKey(key);
try {
await operation();
toast.success(successMessage, { timeout: 3000 });
toast.success(successMessage, { timeout: 4000 });
refresh();
return true;
} catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', {
timeout: 4000,
timeout: 8000,
});
return false;
} finally {
......@@ -333,7 +333,7 @@ export default function NotificationListPage() {
refresh();
} catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', {
timeout: 4000,
timeout: 8000,
});
} finally {
setBusyKey('');
......@@ -418,14 +418,15 @@ export default function NotificationListPage() {
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="ghost" onPress={refresh}>
重试
</Button>
</Alert>
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>通知加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={refresh}>重试</Button>
</EmptyState.Content>
</EmptyState>
) : null}
{loading && notifications.length === 0 ? (
......
......@@ -2,33 +2,53 @@ import {
loadProcessInstanceDetail,
} from '@/pages/process_monitor/processMonitorService';
import ProcessInstanceDetailContent from '@/components/ProcessInstanceDetailContent';
import { Alert } from '@heroui/react';
import { Button } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'umi';
export default function ProcessDetailPage() {
const { application } = useParams();
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
const [record, setRecord] = useState(null);
const [fieldsDefinition, setFieldsDefinition] = useState([]);
const [members, setMembers] = useState([]);
const [departments, setDepartments] = useState([]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setErrorMessage('');
loadProcessInstanceDetail(application).then((result) => {
if (cancelled) return;
setRecord(result.record); setFieldsDefinition(result.fieldsDefinition); setMembers(result.members); setDepartments(result.departments); setErrorMessage('');
}).catch((error) => setErrorMessage(error?.message || '加载流程详情失败')).finally(() => setLoading(false));
}, [application]);
}).catch((error) => {
if (!cancelled) setErrorMessage(error?.message || '加载流程详情失败');
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [application, retryKey, setErrorMessage]);
const detailData = useMemo(() => ({ record, fieldsDefinition, members, departments }), [departments, fieldsDefinition, members, record]);
return (
<div className="rc-page-stack">
{errorMessage ? <Alert status="danger"><Alert.Indicator /><Alert.Content><Alert.Title>详情加载失败</Alert.Title><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-list-action-bar"><h2>流程实例信息</h2></div>
<ProcessInstanceDetailContent data={detailData} loading={loading} withSurface />
{!loading && errorMessage ? (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>流程详情加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Content>
</EmptyState>
) : <ProcessInstanceDetailContent data={detailData} loading={loading} withSurface />}
</div>
);
}
import useFeedbackState from '@/hooks/useFeedbackState';
import { useAuthorization } from '@/hooks/user';
import { Check, Close, DataStore, Delete, Edit, Eye, FileText, FilterControls, FolderOpen, PlusIcon, Search, Team } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Alert, AlertDialog, Button, Checkbox, Input, Label, Link, Modal, Pagination, Popover, SearchField, Surface, Tabs, Tooltip, toast } from '@heroui/react';
import { AlertDialog, Button, Checkbox, Input, Label, Link, Modal, Pagination, Popover, SearchField, Surface, Tabs, Tooltip, toast } from '@heroui/react';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
......@@ -92,7 +93,9 @@ export default function ProcessMonitorPage() {
const [departmentFilter, setDepartmentFilter] = useState('');
const [listPage, setListPage] = useState(0);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const [errorMessage, setErrorMessage] = useState('');
const [, setErrorMessage] = useFeedbackState('error');
const [, setSaveSuccess] = useFeedbackState('success');
const [loadFailed, setLoadFailed] = useState(false);
const [createGroupOpen, setCreateGroupOpen] = useState(false);
const [createGroupTitle, setCreateGroupTitle] = useState('');
const [addTemplateOpen, setAddTemplateOpen] = useState(false);
......@@ -107,6 +110,8 @@ export default function ProcessMonitorPage() {
const loadPage = async (preferredGroupName) => {
setLoading(true);
setLoadFailed(false);
setErrorMessage('');
try {
const result = await loadProcessMonitorOverview();
setGroups(result.groups);
......@@ -116,7 +121,7 @@ export default function ProcessMonitorPage() {
const nextActiveGroup = result.groups.find((group) => group.name === preferredGroupName) || result.groups.find((group) => group.name === activeGroupName) || result.groups[0] || null;
setActiveGroupName(nextActiveGroup?.name || '');
setErrorMessage('');
} catch (error) { setErrorMessage(error?.message || '加载流程监控数据失败'); }
} catch (error) { setLoadFailed(true); setErrorMessage(error?.message || '加载流程监控数据失败'); }
finally { setLoading(false); }
};
......@@ -133,6 +138,7 @@ export default function ProcessMonitorPage() {
useEffect(() => { setListPage(0); }, [activeGroupName, departmentFilter, templateKeyword, pageSize]);
const handleCreateGroup = async () => {
setErrorMessage('');
if (!createGroupTitle.trim()) { setErrorMessage('请先填写分组名称'); return; }
setSubmitting(true);
try {
......@@ -145,6 +151,7 @@ export default function ProcessMonitorPage() {
};
const handleAddTemplates = async () => {
setErrorMessage('');
if (!activeGroup) { setErrorMessage('请先创建或选择一个分组'); return; }
const selectedTemplates = pickerTemplates.filter((template) => selectedTemplateIds.includes(template._id));
if (!selectedTemplates.length) { setErrorMessage('请至少选择一个流程模板'); return; }
......@@ -158,21 +165,33 @@ export default function ProcessMonitorPage() {
};
const openEditDialog = (template) => {
setErrorMessage('');
setSaveSuccess('');
setEditingTemplate(template);
setTemplateForm({ title: template?.title || '', department: template?.metadata?.department || '', templateCode: template?.metadata?.template_code || '', conditionKey: template?.metadata?.conditions?.key || '', conditionValue: template?.metadata?.conditions?.value || '' });
setEditTemplateOpen(true);
};
const handleUpdateTemplate = async () => {
if (submitting) return;
setErrorMessage('');
setSaveSuccess('');
if (!editingTemplate?._id) return;
if (!templateForm.title.trim()) { setErrorMessage('模板标题不能为空'); return; }
setSubmitting(true);
try { await updateMonitoredTemplate(editingTemplate._id, templateForm); setEditTemplateOpen(false); setEditingTemplate(null); await loadPage(activeGroup?.name); toast.success('模板配置已更新'); }
try {
await updateMonitoredTemplate(editingTemplate._id, templateForm);
setEditTemplateOpen(false);
setEditingTemplate(null);
setSaveSuccess('模板配置已保存。');
await loadPage(activeGroup?.name);
}
catch (error) { setErrorMessage(error?.message || '模板更新失败'); }
finally { setSubmitting(false); }
};
const handleRemoveTemplate = async () => {
setErrorMessage('');
if (!activeGroup || !removeTarget) return;
setSubmitting(true);
try { const latestGroup = await loadProcessGroup(activeGroup.name); await removeMonitoredTemplate(latestGroup, removeTarget); setRemoveTarget(null); await loadPage(activeGroup.name); toast.success('已取消该流程模板监控'); }
......@@ -181,6 +200,7 @@ export default function ProcessMonitorPage() {
};
const handleRemoveGroup = async () => {
setErrorMessage('');
if (!removeGroupTarget) return;
const nextGroup = groups.find((group) => group.name !== removeGroupTarget.name) || null;
setSubmitting(true);
......@@ -208,7 +228,6 @@ export default function ProcessMonitorPage() {
return (
<div className="rc-page-stack">
{errorMessage ? <Alert status="danger"><Alert.Indicator /><Alert.Content><Alert.Title>操作未完成</Alert.Title><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-kpi-grid" data-count="4">
<Metric label="监控分组" value={groups.length} hint="按业务视角组织模板监控范围" icon={Team} tone="info" />
......@@ -240,8 +259,8 @@ export default function ProcessMonitorPage() {
</div>
<div className="rc-list-data-stack">
<section className="rc-list-data-grid rc-data-table-sticky"><DataGrid allowsColumnResize aria-label="流程监控模板列表" className="pm-data-grid" columns={listColumns} contentClassName="min-w-[1074px]" data={loading ? [] : pagedTemplates} getRowId={(template) => template._id || template.name} renderEmptyState={() => loading ? <div className="py-8 text-sm text-muted">正在加载流程监控列表...</div> : <DataEmptyState title={activeGroup ? '当前分组还没有监控模板' : '暂无可展示模板'} action={canManage && activeGroup ? <Button size="sm" variant="secondary" onPress={() => setAddTemplateOpen(true)}>添加监控模板</Button> : null} />} scrollContainerClassName="data-table-scroll" verticalAlign="middle" /></section>
{!loading ? <div className="pm-pagination-bar"><PagePagination page={listPage} pageSize={pageSize} total={filteredTemplates.length} onPageChange={setListPage} onPageSizeChange={(nextPageSize) => { if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) setPageSize(nextPageSize); }} /></div> : null}
<section className="rc-list-data-grid rc-data-table-sticky"><DataGrid allowsColumnResize aria-label="流程监控模板列表" className="pm-data-grid" columns={listColumns} contentClassName="min-w-[1074px]" data={loading || loadFailed ? [] : pagedTemplates} getRowId={(template) => template._id || template.name} renderEmptyState={() => loading ? <div className="py-8 text-sm text-muted">正在加载流程监控列表...</div> : loadFailed ? <DataEmptyState title="流程监控列表加载失败" action={<Button size="sm" variant="secondary" onPress={() => loadPage(activeGroupName)}>重新加载</Button>} /> : <DataEmptyState title={activeGroup ? '当前分组还没有监控模板' : '暂无可展示模板'} action={canManage && activeGroup ? <Button size="sm" variant="secondary" onPress={() => setAddTemplateOpen(true)}>添加监控模板</Button> : null} />} scrollContainerClassName="data-table-scroll" verticalAlign="middle" /></section>
{!loading && !loadFailed ? <div className="pm-pagination-bar"><PagePagination page={listPage} pageSize={pageSize} total={filteredTemplates.length} onPageChange={setListPage} onPageSizeChange={(nextPageSize) => { if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) setPageSize(nextPageSize); }} /></div> : null}
</div>
</section>
......
......@@ -2,7 +2,8 @@ import { Close, DataStore, Delete, Eye, FieldTime, FileText, Filter, FolderOpen,
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import { TitleInfo } from '@/components/XinyuanUi';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Alert, Button, Input, Label, Link, Modal, Pagination, SearchField, Tooltip, toast } from '@heroui/react';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Input, Label, Link, Modal, Pagination, SearchField, Tooltip, toast } from '@heroui/react';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
......@@ -18,7 +19,7 @@ import {
queryProcessInstances,
updateTemplateDisplayFields,
} from '@/pages/process_monitor/processMonitorService';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'umi';
const PAGE_SIZE = 20;
......@@ -52,13 +53,13 @@ function formatLookupValue(type, value, lookups) {
return values.map((item) => mapping[item] || item).join('、');
}
function renderFieldValue(fieldDefinition, record, lookups) {
function renderFieldValue(fieldDefinition, record, lookups, onDownload) {
const fieldDetail = findDetailField(record, fieldDefinition.display_name);
if (!fieldDetail) return '-';
if (fieldDetail.type === 'file') {
const files = Array.isArray(fieldDetail.value) ? fieldDetail.value : [];
if (!files.length) return '-';
return <div className="flex flex-wrap gap-2">{files.map((file) => <Button key={`${file.fileUrl || file.url}-${file.filename}`} size="sm" variant="secondary" onPress={() => downloadProcessAttachment(file)}>{file.filename || file.fileName || '附件'}</Button>)}</div>;
return <div className="flex flex-wrap gap-2">{files.map((file) => <Button key={`${file.fileUrl || file.url}-${file.filename}`} size="sm" variant="secondary" onPress={() => onDownload(file)}>{file.filename || file.fileName || '附件'}</Button>)}</div>;
}
if (fieldDefinition.type === 'member') return formatLookupValue('member', fieldDetail.value, lookups);
if (fieldDefinition.type === 'department') return formatLookupValue('department', fieldDetail.value, lookups);
......@@ -98,8 +99,13 @@ export default function ProcessTemplateListPage() {
const [searchParams] = useSearchParams();
const tabName = searchParams.get('tab') || '';
const [loading, setLoading] = useState(true);
const [instancesLoading, setInstancesLoading] = useState(false);
const [retryKey, setRetryKey] = useState(0);
const [downloading, setDownloading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [, setErrorMessage] = useFeedbackState('error');
const [, setSaveSuccess] = useFeedbackState('success');
const [metadataError, setMetadataError] = useFeedbackState('error');
const [instancesError, setInstancesError] = useFeedbackState('error');
const [template, setTemplate] = useState(null);
const [fieldsDefinition, setFieldsDefinition] = useState([]);
const [members, setMembers] = useState([]);
......@@ -114,20 +120,48 @@ export default function ProcessTemplateListPage() {
const [fieldDrafts, setFieldDrafts] = useState([]);
const [fieldSaving, setFieldSaving] = useState(false);
const loadTemplateMeta = async () => {
const result = await loadTemplateMonitorPage(processId);
setTemplate(result.template); setFieldsDefinition(result.fieldsDefinition); setMembers(result.members); setDepartments(result.departments); setConditions(result.conditions);
};
const loadInstances = async () => {
const result = await queryProcessInstances({ processId, page, pageSize, keyword, conditions });
setInstances(result.records); setTotal(result.total);
};
const listLoading = loading || instancesLoading;
const loadFailed = Boolean(metadataError || instancesError);
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([loadTemplateMeta(), loadInstances()]).then(() => setErrorMessage('')).catch((error) => setErrorMessage(error?.message || '加载流程实例失败')).finally(() => setLoading(false));
}, [processId]);
useEffect(() => { loadInstances().catch((error) => setErrorMessage(error?.message || '查询流程实例失败')); }, [page, pageSize, keyword, conditions]);
setMetadataError('');
setSaveSuccess('');
loadTemplateMonitorPage(processId).then((result) => {
if (cancelled) return;
setTemplate(result.template); setFieldsDefinition(result.fieldsDefinition); setMembers(result.members); setDepartments(result.departments); setConditions(result.conditions);
}).catch((error) => {
if (!cancelled) setMetadataError(error?.message || '加载流程模板失败');
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [processId, retryKey, setMetadataError, setSaveSuccess]);
useEffect(() => {
if (loading || metadataError) {
setInstancesLoading(false);
return;
}
let cancelled = false;
setInstancesLoading(true);
setInstancesError('');
queryProcessInstances({ processId, page, pageSize, keyword, conditions }).then((result) => {
if (cancelled) return;
setInstances(result.records); setTotal(result.total);
}).catch((error) => {
if (!cancelled) setInstancesError(error?.message || '查询流程实例失败');
}).finally(() => {
if (!cancelled) setInstancesLoading(false);
});
return () => { cancelled = true; };
}, [processId, page, pageSize, keyword, conditions, loading, metadataError, setInstancesError]);
const handleDownloadAttachment = useCallback(async (file) => {
try { await downloadProcessAttachment(file); }
catch (error) { setErrorMessage(error?.message || '附件下载失败,请重试'); }
}, [setErrorMessage]);
const lookups = useMemo(() => ({ memberLookup: buildMemberLookup(members), departmentLookup: buildDepartmentLookup(departments) }), [departments, members]);
const totalPages = Math.max(Math.ceil(total / pageSize), 1);
......@@ -137,27 +171,38 @@ export default function ProcessTemplateListPage() {
const tabQuery = tabName ? `?tab=${encodeURIComponent(tabName)}` : '';
navigate(`/process_monitor/${processId}/${record._id}${tabQuery}`);
}}>{record.title}</Link> },
...fieldsDefinition.map((field) => ({ id: `${field.display_name}-${field.type}`, header: field.display_name, width: 220, minWidth: 180, allowsResizing: false, cell: (record) => <span className="pm-data-grid-text">{renderFieldValue(field, record, lookups)}</span> })),
...fieldsDefinition.map((field) => ({ id: `${field.display_name}-${field.type}`, header: field.display_name, width: 220, minWidth: 180, allowsResizing: false, cell: (record) => <span className="pm-data-grid-text">{renderFieldValue(field, record, lookups, handleDownloadAttachment)}</span> })),
{ id: 'actions', header: '操作', width: 80, minWidth: 80, maxWidth: 80, allowsResizing: false, align: 'start', pinned: 'end', cell: (record) => <TableActionGroup><TableActionButton icon={Eye} label="查看" onPress={() => {
if (!record?._id) return;
const tabQuery = tabName ? `?tab=${encodeURIComponent(tabName)}` : '';
navigate(`/process_monitor/${processId}/${record._id}${tabQuery}`);
}} /></TableActionGroup> },
], [fieldsDefinition, lookups, navigate, processId, tabName]);
], [fieldsDefinition, handleDownloadAttachment, lookups, navigate, processId, tabName]);
const openFieldDialog = () => {
setErrorMessage('');
setSaveSuccess('');
const normalizedFields = normalizeDisplayFieldDefinitions(fieldsDefinition);
setFieldDrafts((normalizedFields.length ? normalizedFields : [{}]).map(createFieldDraft));
setFieldDialogOpen(true);
};
const saveFieldDefinitions = async () => {
if (fieldSaving) return;
setErrorMessage('');
setSaveSuccess('');
const normalizedFields = fieldDrafts.map((field) => ({ display_name: field.display_name?.trim() || '', type: field.type || 'text' })).filter((field) => field.display_name);
setFieldSaving(true);
try { await updateTemplateDisplayFields(processId, normalizedFields); setFieldsDefinition(normalizedFields); setFieldDialogOpen(false); toast.success('显示字段配置已更新'); }
try {
await updateTemplateDisplayFields(processId, normalizedFields);
setFieldsDefinition(normalizedFields);
setFieldDialogOpen(false);
setSaveSuccess('显示字段配置已保存。');
}
catch (error) { setErrorMessage(error?.message || '显示字段保存失败'); }
finally { setFieldSaving(false); }
};
const handleDownloadAll = async () => {
setErrorMessage('');
setDownloading(true);
try { const count = await downloadAllProcessAttachments(processId); count > 0 ? toast.success(`已触发 ${count} 个附件下载`) : toast.info('当前模板下没有可下载附件'); }
catch (error) { setErrorMessage(error?.message || '批量下载失败'); }
......@@ -167,7 +212,6 @@ export default function ProcessTemplateListPage() {
return (
<div className="rc-page-stack">
<div className="rc-detail-toolbar flex min-w-0 flex-wrap items-center justify-end gap-x-4 gap-y-3"><div className="rc-page-command-bar ml-auto flex shrink-0 justify-end"><div className="flex flex-nowrap gap-2"><Button isDisabled={loading} isPending={downloading} size="sm" variant="secondary" onPress={handleDownloadAll}>批量下载附件</Button><Button isDisabled={loading} size="sm" variant="primary" onPress={openFieldDialog}>定义显示字段</Button></div></div></div>
{errorMessage ? <Alert status="danger"><Alert.Indicator /><Alert.Content><Alert.Title>操作未完成</Alert.Title><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-kpi-grid" data-count="4"><Metric label="模板实例" value={total} hint="当前模板下命中的提交流程数量" icon={DataStore} tone="info" /><Metric label="显示字段" value={fieldsDefinition.length} hint="由模板配置决定表格展开的动态列" icon={FileText} tone="success" /><Metric label="条件筛选" value={conditions?.key ? '已启用' : '未启用'} hint={conditions?.key ? `${conditions.key} = ${conditions.value || '-'}` : '当前没有预设显示条件'} icon={Filter} tone="warning" /><Metric label="分页设置" value={pageSize} hint={`当前第 ${page} / ${totalPages} 页`} icon={FieldTime} tone="teal" /></div>
......@@ -175,8 +219,8 @@ export default function ProcessTemplateListPage() {
<div className="rc-list-action-bar"><div className="flex items-center gap-1.5"><h2>实例列表</h2><TitleInfo label="实例列表" content="按模板展示实际提交的流程实例,可通过操作列查看单条详情。" /></div></div>
<div className="flex flex-wrap items-center justify-between gap-3"><SearchField aria-label="搜索表单名称" className="w-full sm:w-96" value={keyword} onChange={(value) => { setPage(1); setKeyword(value); }}><SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder="搜索表单名称" /><SearchField.ClearButton aria-label="清空关键词"><Close size={14} /></SearchField.ClearButton></SearchField.Group></SearchField></div>
<div className="rc-list-data-stack">
<section className="rc-list-data-grid rc-data-table-sticky"><DataGrid allowsColumnResize aria-label="流程实例列表" className="pm-data-grid" columns={instanceColumns} contentClassName="min-w-[760px]" data={loading ? [] : instances} getRowId={(record) => record._id || record.name} renderEmptyState={() => loading ? <div className="py-8 text-sm text-muted">正在加载模板实例...</div> : <DataEmptyState />} scrollContainerClassName="data-table-scroll" verticalAlign="middle" /></section>
{!loading ? <div className="pm-pagination-bar"><PagePagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(nextPageSize) => { if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) { setPageSize(nextPageSize); setPage(1); } }} /></div> : null}
<section className="rc-list-data-grid rc-data-table-sticky"><DataGrid allowsColumnResize aria-label="流程实例列表" className="pm-data-grid" columns={instanceColumns} contentClassName="min-w-[760px]" data={listLoading || loadFailed ? [] : instances} getRowId={(record) => record._id || record.name} renderEmptyState={() => listLoading ? <div className="py-8 text-sm text-muted">正在加载模板实例...</div> : loadFailed ? <EmptyState size="sm"><EmptyState.Header><EmptyState.Title>流程实例列表加载失败</EmptyState.Title><EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description></EmptyState.Header><EmptyState.Content><Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>重新加载</Button></EmptyState.Content></EmptyState> : <DataEmptyState />} scrollContainerClassName="data-table-scroll" verticalAlign="middle" /></section>
{!listLoading && !loadFailed ? <div className="pm-pagination-bar"><PagePagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(nextPageSize) => { if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) { setPageSize(nextPageSize); setPage(1); } }} /></div> : null}
</div>
</section>
......
......@@ -208,3 +208,16 @@ Navbar 的 Bell 进入 `/notifications`。OA流程监控不在六个完整功能
通知中的站内 URL 只剥离 `/oa_process_monitor_new/` 后交给 Umi history;其他应用或外部
HTTP(S) 链接新窗口打开。`content` 始终作为纯文本渲染,不执行服务端 HTML。Navbar 首次加载
及后续每 60 秒调用 `/notice/customer/count`,真实 0 条时不显示徽标,100 及以上显示 `99+`。
## 10. 接口操作反馈与失败恢复
入口覆盖登录与验证码、分组总览、模板实例列表、流程详情、通知中心和 Navbar 应用切换弹窗。
- 新建分组、添加模板、编辑配置、取消监控、删除空分组、保存显示字段、附件下载及通知操作的成功或失败统一通过全局 HeroUI Toast 提示,不再在页面顶部追加常驻横条。Toast 位于业务弹窗和移动侧栏之上,页面滚动不影响其可见性。
- 成功提示默认 4 秒后消失;错误和降级警告默认 8 秒后消失,悬停或键盘聚焦暂停计时。当前提示右侧的关闭图标始终可见,使用 HeroUI 默认小号透明按钮(`ghost` / `sm` / `isIconOnly`),点击或键盘激活通过 Toast 的 `close` slot 关闭;后排堆叠提示隐藏关闭按钮,避免误关闭。关闭与超时仅收起提示,不修改接口结果、失败状态、输入内容、选择项或弹窗状态;失败后可在原弹窗修正内容再次提交,相同错误也会重新提示。
- 保存入口为分组总览的“编辑模板监控配置”和模板实例列表的“定义显示字段”弹窗。只有 `PUT /record/:name`(`updateRecord`)成功后才关闭弹窗,分别弹出“模板配置已保存。”或“显示字段配置已保存。”;显示字段同时更新当前列表列。模板配置在保存成功时立即反馈,随后重读列表;重读失败由加载错误和重试入口单独反馈。
- 保存成功提示通过 `useFeedbackState('success')` 按来源管理;重新打开编辑、再次保存或离开页面时清理上一条提示,模板实例页切换模板或重新加载时也清理。正在保存时不重复提交;模板标题为空或缺少记录 ID 时不提交、不提示成功。保存失败只提示错误并保留弹窗及草稿。写入对象为 `audit_related_progress`,核心字段仍为 `title`、`metadata.department`、`metadata.template_code`、`metadata.conditions` 和 `metadata.fields_definition`;流程实例详情维持第 5.3 节的查看流程。
- 分组总览、模板实例列表、流程详情和通知列表加载失败时保留 EmptyState 与重新加载入口,隐藏失败数据对应的列表或详情;关闭错误 Toast 后仍可重试,不把失败当作空数据。实例列表先读取模板与预设条件,再按当前关键字、页码、每页条数和条件查询实例;旧请求返回时不覆盖新请求结果。
- 模板显示字段保存或附件下载失败只反馈本次操作错误,不把已加载列表切换为加载失败态;保存失败保留字段草稿。单个附件下载失败同样显示可关闭的错误提示。
- 应用列表接口失败时通过警告 Toast 告知已回退当前账号的可访问应用;站点配置加载失败沿用原有默认配置回退并通过警告 Toast 提示。
- 本次仅调整反馈呈现与重试交互,记录写入仍使用 `metadata`,对象仍为 `process_monitor_group`、`audit_related_progress`、`audit_progress_info`、`oa_member` 和 `oa_department`;Record API、通知接口、字段规则和权限保持现有协议。
......@@ -4,6 +4,7 @@
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
@import "./design-system/feedback.css";
@source "./*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}";
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService';
import { Spinner } from '@heroui/react';
import dayjs from 'dayjs';
......@@ -19,7 +20,7 @@ export default function SiteWrapper() {
const [siteInfo, setSiteInfo] = useState(null);
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [loadError, setLoadError] = useFeedbackState('warning');
useEffect(() => {
let cancelled = false;
......@@ -55,7 +56,7 @@ export default function SiteWrapper() {
return () => {
cancelled = true;
};
}, []);
}, [setLoadError]);
if (loading) {
return (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment