Commit 091d72a3 authored by drigle's avatar drigle

feat: unify operation feedback and improve process retry states

parent 473ca7aa
# 流程管理鑫元设计系统 # 流程管理鑫元设计系统
## 操作反馈(2026-10-08)
- 参考问题整改提交 `513706c`,接口反馈统一由应用根部的 `AppFeedback` 承载,登录和业务页面共用一个 HeroUI Toast Provider。
- Toast 通过 Portal 显示在视口顶部,层级为 300,高于业务 Modal、确认框和移动侧栏;不受页面滚动影响,不抢走表单焦点。
- 成功和信息提示默认 4 秒、错误和警告默认 8 秒后消失,悬停或键盘聚焦暂停计时。关闭按钮对齐问题整改提交 `bfc5862`:使用 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`)和 `AppIcons.Close`,通过 `slot="close"` 接入官方关闭行为,在内容右侧参与布局;前排关闭图标始终可见,后排隐藏。移除绝对定位、放大的边框按钮和正文右侧占位,Hover/焦点样式沿用 HeroUI 默认主题。
- 页面不再使用常驻横条呈现接口错误;读取失败保留 HeroUI Pro EmptyState 和重试入口,表单字段校验继续在对应控件附近呈现。
- `useFeedbackState` 保留请求失败状态;关闭提示只影响展示,相同错误重试时仍重新显示,同一来源的新提示替换旧提示,清空状态或组件卸载时清理该来源提示。
- 流程详情显式保存和标题修改成功后显示“详情已保存”,已有记录由 Header 的成功反馈状态统一管理,避免与通用画布保存提示重复;重新编辑、再次保存、取消或切换记录时清理旧提示。新建记录由通用画布保存入口发布同一文案,避免路由跳转卸载旧 Header 时清理成功提示。
## 2026-09-28 Shell 配色调整 ## 2026-09-28 Shell 配色调整
当前 AppShell 使用低饱和深藏蓝侧栏与中性灰白内容画布。`theme.css` 的 `--background` 和 `--xinyuan-sidebar-*` 是这组配色的唯一来源:侧栏背景为 `oklch(32% 0.037 255)`,页面画布为 `oklch(97.4% 0.002 255)`;侧栏文字为近白色,悬停项为稍亮的蓝色,选中项为提亮的蓝色底与白字。品牌主红 `#DA241A` 不变。侧栏红色 Logo 直接显示,不加底色。桌面与移动侧栏共用这些 Token,导航结构、交互及数据状态不变。 当前 AppShell 使用低饱和深藏蓝侧栏与中性灰白内容画布。`theme.css` 的 `--background` 和 `--xinyuan-sidebar-*` 是这组配色的唯一来源:侧栏背景为 `oklch(32% 0.037 255)`,页面画布为 `oklch(97.4% 0.002 255)`;侧栏文字为近白色,悬停项为稍亮的蓝色,选中项为提亮的蓝色底与白字。品牌主红 `#DA241A` 不变。侧栏红色 Logo 直接显示,不加底色。桌面与移动侧栏共用这些 Token,导航结构、交互及数据状态不变。
......
{ {
"name": "process_management_new", "name": "process_management_new",
"version": "1.0.1", "version": "1.0.5",
"build": "abc", "build": "abc",
"description": "merge master", "description": "merge master",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
import '@/global.less'; import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import AppFeedback from '@/components/AppFeedback';
import { I18nProvider } from '@heroui/react'; import { I18nProvider } from '@heroui/react';
import { createElement } from 'react'; import { createElement } from 'react';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
...@@ -12,6 +13,10 @@ export async function getInitialState() { ...@@ -12,6 +13,10 @@ export async function getInitialState() {
export default defineApp({ export default defineApp({
rootContainer(container) { 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>{toast.content.title}</Toast.Title>
{toast.content.description ? (
<Toast.Description className="max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words text-foreground">
{toast.content.description}
</Toast.Description>
) : null}
{toast.content.actionProps ? (
<Toast.ActionButton {...toast.content.actionProps} />
) : null}
</Toast.Content>
<Button
aria-label="关闭提示"
className="app-feedback-close shrink-0 self-start"
isIconOnly
size="sm"
slot="close"
variant="ghost"
>
<Close size={16} />
</Button>
</Toast>
)}
</Toast.Provider>
</>
);
}
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi'; import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar'; import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar'; import DashboardSidebar from './DashboardSidebar';
...@@ -36,7 +35,6 @@ export default function AppShell({ ...@@ -36,7 +35,6 @@ export default function AppShell({
<div className="rc-app-page">{children}</div> <div className="rc-app-page">{children}</div>
</div> </div>
</Sidebar.Main> </Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider> </Sidebar.Provider>
); );
} }
import useFeedbackState from '@/hooks/useFeedbackState';
import xinyuanLogo from '@/assets/xy-logo-red.png'; import xinyuanLogo from '@/assets/xy-logo-red.png';
import { import {
APPLICATION_BASE_PATH, APPLICATION_BASE_PATH,
...@@ -5,12 +6,11 @@ import { ...@@ -5,12 +6,11 @@ import {
APPLICATION_NAME, APPLICATION_NAME,
} from '@/config/application'; } from '@/config/application';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons'; import { MagnifyingGlassIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card'; import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group'; import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import { import {
Alert,
Avatar, Avatar,
Chip, Chip,
Modal, Modal,
...@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({
const [status, setStatus] = useState( const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle', fallbackApplications.length > 0 ? 'success' : 'idle',
); );
const [errorMessage, setErrorMessage] = useState(''); const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState(''); const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => { const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME; return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
...@@ -352,6 +352,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -352,6 +352,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setApplicationSearch(''); setApplicationSearch('');
setErrorMessage('');
return; return;
} }
...@@ -443,13 +444,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -443,13 +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 />
) : filteredApplicationSections.length > 0 ? ( ) : filteredApplicationSections.length > 0 ? (
......
import { EmptyState } from "@heroui-pro/react/empty-state";
import { Button } from "@heroui/react";
export default function FeedbackErrorState({ title = "加载失败", onRetry }) {
return (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>{title}</EmptyState.Title>
<EmptyState.Description>
请稍后重试,详细原因请查看操作提示。
</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button size="sm" variant="secondary" onPress={onRetry}>
重试
</Button>
</EmptyState.Content>
</EmptyState>
);
}
.process-page { .process-page {
color: var(--foreground); color: var(--foreground);
} }
.process-table .process-table__action-content {
justify-content: flex-start;
gap: 4px;
}
.process-page--group-list { .process-page--group-list {
margin-top: calc(-1 * var(--rc-page-padding-block-start)); margin-top: calc(-1 * var(--rc-page-padding-block-start));
} }
......
...@@ -47,9 +47,9 @@ const TOKEN = { ...@@ -47,9 +47,9 @@ const TOKEN = {
}; };
export const theme = { useToken: () => ({ token: TOKEN }) }; export const theme = { useToken: () => ({ token: TOKEN }) };
export const message = { export const message = {
success: (text) => toast.success(text), success: (text) => toast.success(text, { timeout: 4000 }),
error: (text) => toast.danger(text), error: (text) => toast.danger(text, { timeout: 8000 }),
info: (text) => toast.info(text), info: (text) => toast.info(text, { timeout: 4000 }),
}; };
const handleError = (error) => const handleError = (error) =>
message.error(error?.message || "操作失败,请重试"); message.error(error?.message || "操作失败,请重试");
...@@ -237,6 +237,7 @@ function EditableText({ children, editable, type, style }) { ...@@ -237,6 +237,7 @@ function EditableText({ children, editable, type, style }) {
type="text" type="text"
style={style} style={style}
onClick={() => { onClick={() => {
editable.onStart?.();
setValue(typeof children === "string" ? children : ""); setValue(typeof children === "string" ? children : "");
setEditing(true); setEditing(true);
}} }}
...@@ -918,6 +919,10 @@ export function ProTable({ ...@@ -918,6 +919,10 @@ export function ProTable({
(value, key) => value?.[key], (value, key) => value?.[key],
record record
); );
const getColumnWidthStyle = (column) =>
column.width
? { width: column.width, minWidth: column.width, maxWidth: column.width }
: undefined;
return ( return (
<Card> <Card>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
...@@ -964,7 +969,7 @@ export function ProTable({ ...@@ -964,7 +969,7 @@ export function ProTable({
{loading ? ( {loading ? (
<Spin /> <Spin />
) : ( ) : (
<Table> <Table className="process-table">
<Table.ScrollContainer> <Table.ScrollContainer>
<Table.Content aria-label="流程列表"> <Table.Content aria-label="流程列表">
<Table.Header> <Table.Header>
...@@ -972,6 +977,8 @@ export function ProTable({ ...@@ -972,6 +977,8 @@ export function ProTable({
<Table.Column <Table.Column
key={String(col.key || col.dataIndex || index)} key={String(col.key || col.dataIndex || index)}
isRowHeader={index === 0} isRowHeader={index === 0}
className={col.key === "action" ? "process-table__action-column" : undefined}
style={getColumnWidthStyle(col)}
> >
{col.title} {col.title}
</Table.Column> </Table.Column>
...@@ -984,9 +991,19 @@ export function ProTable({ ...@@ -984,9 +991,19 @@ export function ProTable({
id={record._id || record.name || String(index)} id={record._id || record.name || String(index)}
> >
{cols.map((col, cellIndex) => ( {cols.map((col, cellIndex) => (
<Table.Cell key={cellIndex}> <Table.Cell
key={cellIndex}
className={col.key === "action" ? "process-table__action-cell" : undefined}
style={getColumnWidthStyle(col)}
>
{col.render ? ( {col.render ? (
<Space> <Space
className={
col.key === "action"
? "process-table__action-content"
: undefined
}
>
{col.render( {col.render(
getValue(record, col.dataIndex), getValue(record, col.dataIndex),
record, record,
......
import FeedbackErrorState from "@/components/FeedbackErrorState";
import useFeedbackState from "@/hooks/useFeedbackState";
import { import {
Button, Button,
Modal, Modal,
...@@ -14,10 +16,12 @@ export default function RecordFields({ subjectConfig, subject, record }) { ...@@ -14,10 +16,12 @@ export default function RecordFields({ subjectConfig, subject, record }) {
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useFeedbackState("error");
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setLoading(true); setLoading(true);
setError("");
queryRecords({ queryRecords({
subject: "internal_regulation", subject: "internal_regulation",
filter: { "metadata.origin_regulation": record._id }, filter: { "metadata.origin_regulation": record._id },
...@@ -44,7 +48,7 @@ export default function RecordFields({ subjectConfig, subject, record }) { ...@@ -44,7 +48,7 @@ export default function RecordFields({ subjectConfig, subject, record }) {
return () => { return () => {
active = false; active = false;
}; };
}, [record._id]); }, [record._id, reloadKey, setError]);
const format = (field, value) => { const format = (field, value) => {
if (value == null || value.length === 0) return "-"; if (value == null || value.length === 0) return "-";
if (["reference", "document", "user"].includes(field.type)) if (["reference", "document", "user"].includes(field.type))
...@@ -93,7 +97,10 @@ export default function RecordFields({ subjectConfig, subject, record }) { ...@@ -93,7 +97,10 @@ export default function RecordFields({ subjectConfig, subject, record }) {
footer={null} footer={null}
> >
{error ? ( {error ? (
<p role="alert">{error}</p> <FeedbackErrorState
title="历史版本加载失败"
onRetry={() => setReloadKey((value) => value + 1)}
/>
) : loading ? ( ) : loading ? (
<Spin /> <Spin />
) : ( ) : (
......
import FeedbackErrorState from "@/components/FeedbackErrorState";
import useFeedbackState from "@/hooks/useFeedbackState";
import { import {
Modal, Modal,
Flex, Flex,
...@@ -18,28 +20,34 @@ const DOCUMENT_PADDING = 30; ...@@ -18,28 +20,34 @@ const DOCUMENT_PADDING = 30;
const RecordPreviewModal = (props) => { const RecordPreviewModal = (props) => {
const { record, onCancel } = props; const { record, onCancel } = props;
const [detail, setDetail] = useState(null); const [detail, setDetail] = useState(null);
const [error, setError] = useState(""); const [error, setError] = useFeedbackState("error");
const [reloadKey, setReloadKey] = useState(0);
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const { getSubjectConfig } = useAppLayout(); const { getSubjectConfig } = useAppLayout();
const subjectConfig = getSubjectConfig(record?.type); const subjectConfig = getSubjectConfig(record?.type);
useEffect(() => { useEffect(() => {
let cancelled = false;
(async () => { (async () => {
setError(""); setError("");
setDetail(null); setDetail(null);
try { try {
if (record) { if (record) {
const res = await loadRecord(record._id); const res = await loadRecord(record._id);
setDetail(res);
const subjectDetail = await loadSubject(record?.type); const subjectDetail = await loadSubject(record?.type);
if (cancelled) return;
setDetail(res);
setSubject(subjectDetail); setSubject(subjectDetail);
} }
} catch (error) { } catch (error) {
setError(error.message || "详情加载失败,请关闭后重试"); if (!cancelled) setError(error.message || "详情加载失败,请重试");
} }
})(); })();
}, [record]); return () => {
cancelled = true;
};
}, [record, reloadKey, setError]);
const documentMeta = useMemo(() => detail?.metadata?.document, [detail]); const documentMeta = useMemo(() => detail?.metadata?.document, [detail]);
const sections = useMemo(() => documentMeta?.sections || [], [documentMeta]); const sections = useMemo(() => documentMeta?.sections || [], [documentMeta]);
...@@ -243,7 +251,10 @@ const RecordPreviewModal = (props) => { ...@@ -243,7 +251,10 @@ const RecordPreviewModal = (props) => {
footer={null} footer={null}
> >
{error ? ( {error ? (
<p role="alert">{error}</p> <FeedbackErrorState
title="关联记录加载失败"
onRetry={() => setReloadKey((value) => value + 1)}
/>
) : !detail ? ( ) : !detail ? (
<Flex style={{ height: 400 }} align="center" justify="center"> <Flex style={{ height: 400 }} align="center" justify="center">
<Spin spinning={true} /> <Spin spinning={true} />
......
/* Feedback remains above business modals and the mobile sidebar. */
.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 must not clear the request's failure state or the form draft.
export default function useFeedbackState(tone = "error") {
const [message, setMessage] = useState("");
const toastKeyRef = useRef(null);
const isMountedRef = useRef(true);
const updateMessage = useCallback(
(nextMessage) => {
if (!isMountedRef.current) return;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
setMessage(nextMessage);
if (!nextMessage) return;
const { title, variant, timeout } =
FEEDBACK_OPTIONS[tone] || FEEDBACK_OPTIONS.error;
// Identical errors on successive attempts must still show fresh feedback.
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 xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png'; import xyLogoWhite from '@/assets/xy-logo-white.png';
import { import {
...@@ -7,7 +8,7 @@ import { ...@@ -7,7 +8,7 @@ import {
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react'; import { Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi'; import { history, useSearchParams } from 'umi';
...@@ -55,7 +56,7 @@ export default function LoginPage() { ...@@ -55,7 +56,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'));
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
...@@ -205,14 +206,6 @@ export default function LoginPage() { ...@@ -205,14 +206,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
className="login-submit-button" className="login-submit-button"
......
import { Button as RetryButton } from "@/components/process-ui/ProcessUi"; import FeedbackErrorState from "@/components/FeedbackErrorState";
import useFeedbackState from "@/hooks/useFeedbackState";
import { loadRecord } from "@/services/DataService"; import { loadRecord } from "@/services/DataService";
import { AppstoreOutlined } from "@ant-design/icons"; import { AppstoreOutlined } from "@ant-design/icons";
import { Spin } from "@/components/process-ui/ProcessUi"; import { Spin } from "@/components/process-ui/ProcessUi";
...@@ -214,7 +215,7 @@ function HistoryWrapper() { ...@@ -214,7 +215,7 @@ function HistoryWrapper() {
const { takeSnapshot, ...undoProps } = useUndoRedo(); const { takeSnapshot, ...undoProps } = useUndoRedo();
const [record, setRecord] = useState(null); const [record, setRecord] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(""); const [loadError, setLoadError] = useFeedbackState("error");
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const params = useParams(); const params = useParams();
...@@ -261,10 +262,7 @@ function HistoryWrapper() { ...@@ -261,10 +262,7 @@ function HistoryWrapper() {
editing={editing} editing={editing}
/> />
{loadError ? ( {loadError ? (
<div role="alert" className="p-5 text-danger"> <FeedbackErrorState title="流程画布加载失败" onRetry={loadFlowRecord} />
{loadError}
<RetryButton onClick={loadFlowRecord}>重试</RetryButton>
</div>
) : loading ? ( ) : loading ? (
<div style={{ height: 400 }}> <div style={{ height: 400 }}>
<Spin spinning={true} /> <Spin spinning={true} />
......
import { Spin, Button as RetryButton } from "@/components/process-ui/ProcessUi"; import { Spin } from "@/components/process-ui/ProcessUi";
import FeedbackErrorState from "@/components/FeedbackErrorState";
import useFeedbackState from "@/hooks/useFeedbackState";
import GraphEdge from "@/components/RecordFlowGraph/GraphEdge.jsx"; import GraphEdge from "@/components/RecordFlowGraph/GraphEdge.jsx";
import { import {
createRecord, createRecord,
...@@ -53,7 +55,7 @@ const ProcessFlowGraphContent = () => { ...@@ -53,7 +55,7 @@ const ProcessFlowGraphContent = () => {
const [view, setView] = useState("list"); const [view, setView] = useState("list");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(""); const [loadError, setLoadError] = useFeedbackState("error");
const [processForm] = Form.useForm(); const [processForm] = Form.useForm();
const { check } = useAuthorization(); const { check } = useAuthorization();
...@@ -325,13 +327,11 @@ const ProcessFlowGraphContent = () => { ...@@ -325,13 +327,11 @@ const ProcessFlowGraphContent = () => {
onTabChange={onTabChange} onTabChange={onTabChange}
tabActiveKey={activeTab} tabActiveKey={activeTab}
> >
{loadError && ( {loadError ? (
<div role="alert" className="text-danger"> <FeedbackErrorState title="流程面板加载失败" onRetry={loadGroups} />
{loadError} ) : loading ? (
<RetryButton onClick={loadGroups}>重试</RetryButton> <Spin />
</div> ) : (
)}
{loading && <Spin />}
<ProCard style={{ height: "calc(100vh - 180px)" }}> <ProCard style={{ height: "calc(100vh - 180px)" }}>
<ReactFlowProvider> <ReactFlowProvider>
<ReactFlow <ReactFlow
...@@ -361,6 +361,7 @@ const ProcessFlowGraphContent = () => { ...@@ -361,6 +361,7 @@ const ProcessFlowGraphContent = () => {
</ReactFlow> </ReactFlow>
</ReactFlowProvider> </ReactFlowProvider>
</ProCard> </ProCard>
)}
{ {
<ModalForm <ModalForm
key="add" key="add"
......
import { Button as RetryButton } from "@/components/process-ui/ProcessUi"; import FeedbackErrorState from "@/components/FeedbackErrorState";
import useFeedbackState from "@/hooks/useFeedbackState";
import GraphEdge from "@/components/RecordFlowGraph/GraphEdge.jsx"; import GraphEdge from "@/components/RecordFlowGraph/GraphEdge.jsx";
import { import {
createRecord, createRecord,
...@@ -70,7 +71,7 @@ export default function ProcessList() { ...@@ -70,7 +71,7 @@ export default function ProcessList() {
const [view, setView] = useState("list"); const [view, setView] = useState("list");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(""); const [loadError, setLoadError] = useFeedbackState("error");
const [processForm] = Form.useForm(); const [processForm] = Form.useForm();
const { check } = useAuthorization(); const { check } = useAuthorization();
const flowRef = useRef(null); const flowRef = useRef(null);
...@@ -392,7 +393,7 @@ export default function ProcessList() { ...@@ -392,7 +393,7 @@ export default function ProcessList() {
columns.push({ columns.push({
title: "操作", title: "操作",
key: "action", key: "action",
width: 140, width: 112,
valueType: "option", valueType: "option",
render: (text, item, _, action) => [ render: (text, item, _, action) => [
<Popconfirm <Popconfirm
...@@ -496,12 +497,9 @@ export default function ProcessList() { ...@@ -496,12 +497,9 @@ export default function ProcessList() {
], ],
}} }}
> >
{loadError && ( {loadError ? (
<div role="alert" className="text-danger"> <FeedbackErrorState title="流程列表加载失败" onRetry={loadGroups} />
{loadError} ) : (
<RetryButton onClick={loadGroups}>重试</RetryButton>
</div>
)}
<ProTable <ProTable
loading={loading} loading={loading}
rowKey="key" rowKey="key"
...@@ -541,6 +539,7 @@ export default function ProcessList() { ...@@ -541,6 +539,7 @@ export default function ProcessList() {
]) ])
} }
/> />
)}
{ {
<ModalForm <ModalForm
key="add" key="add"
......
import { useAuthorization } from "@/hooks/user"; import { useAuthorization } from "@/hooks/user";
import useFeedbackState from "@/hooks/useFeedbackState";
import { import {
ArrowLeftOutlined, ArrowLeftOutlined,
FileJpgOutlined, FileJpgOutlined,
...@@ -16,7 +17,7 @@ import { ...@@ -16,7 +17,7 @@ import {
Typography, Typography,
} from "@/components/process-ui/ProcessUi"; } from "@/components/process-ui/ProcessUi";
import { toJpeg, toPng } from "html-to-image"; import { toJpeg, toPng } from "html-to-image";
import React, { useContext } from "react"; import React, { useContext, useEffect } from "react";
import { import {
getRectOfNodes, getRectOfNodes,
getTransformForBounds, getTransformForBounds,
...@@ -43,6 +44,7 @@ const Header = ({ ...@@ -43,6 +44,7 @@ const Header = ({
fresh, fresh,
}) => { }) => {
const { saveFlow } = useProcessFlow(); const { saveFlow } = useProcessFlow();
const [, setSaveSuccess] = useFeedbackState("success");
const { getNodes, setNodes } = useReactFlow(); const { getNodes, setNodes } = useReactFlow();
const { takeSnapshot } = useContext(ProcessFlowContext); const { takeSnapshot } = useContext(ProcessFlowContext);
const { x, y } = useViewport(); const { x, y } = useViewport();
...@@ -50,6 +52,10 @@ const Header = ({ ...@@ -50,6 +52,10 @@ const Header = ({
const { token } = useToken(); const { token } = useToken();
const { admin } = useAuthorization(); const { admin } = useAuthorization();
useEffect(() => {
setSaveSuccess("");
}, [record?._id, setSaveSuccess]);
const exportItems = [ const exportItems = [
{ {
key: "jpg", key: "jpg",
...@@ -172,9 +178,17 @@ const Header = ({ ...@@ -172,9 +178,17 @@ const Header = ({
editable={ editable={
admin && { admin && {
triggerType: "text", triggerType: "text",
onStart: () => setSaveSuccess(""),
onChange: async (value) => { onChange: async (value) => {
await saveFlow(record?._id, value); setSaveSuccess("");
await saveFlow(
record?._id,
value,
Boolean(record?._id),
"详情已保存。"
);
update({ ...record, title: value }); update({ ...record, title: value });
if (record?._id) setSaveSuccess("详情已保存。");
}, },
autoSize: { minRows: 1, maxRows: 1 }, autoSize: { minRows: 1, maxRows: 1 },
} }
...@@ -243,6 +257,7 @@ const Header = ({ ...@@ -243,6 +257,7 @@ const Header = ({
<> <>
<Button <Button
onClick={async () => { onClick={async () => {
setSaveSuccess("");
trigger(false); trigger(false);
reset(); reset();
await fresh(); await fresh();
...@@ -253,9 +268,16 @@ const Header = ({ ...@@ -253,9 +268,16 @@ const Header = ({
<Button <Button
type="primary" type="primary"
onClick={async () => { onClick={async () => {
await saveFlow(record?._id, record?.title); setSaveSuccess("");
await saveFlow(
record?._id,
record?.title,
Boolean(record?._id),
"详情已保存。"
);
reset(); reset();
trigger(false); trigger(false);
if (record?._id) setSaveSuccess("详情已保存。");
}} }}
> >
保存 保存
...@@ -263,7 +285,13 @@ const Header = ({ ...@@ -263,7 +285,13 @@ const Header = ({
</> </>
)} )}
{!editing && admin && ( {!editing && admin && (
<Button type="primary" onClick={() => trigger(true)}> <Button
type="primary"
onClick={() => {
setSaveSuccess("");
trigger(true);
}}
>
编辑 编辑
</Button> </Button>
)} )}
......
...@@ -46,7 +46,7 @@ ...@@ -46,7 +46,7 @@
2. 管理员可进入编辑状态、修改标题、保存或取消。取消重新读取服务器记录并清理撤销记录。 2. 管理员可进入编辑状态、修改标题、保存或取消。取消重新读取服务器记录并清理撤销记录。
3. 形状、颜色、位置、大小、节点名称和备注继续写入原节点数据;保留 Shift 等比缩放、缩略图及旧快捷键。 3. 形状、颜色、位置、大小、节点名称和备注继续写入原节点数据;保留 Shift 等比缩放、缩略图及旧快捷键。
4. 连线工具保留线型、实虚线、起终点箭头和描述。 4. 连线工具保留线型、实虚线、起终点箭头和描述。
5. 保存使用原 `JSON.stringify({nodes,edges})`;已有记录 updateRecord,新记录 createRecord 后跳转详情。 5. 保存使用原 `JSON.stringify({nodes,edges})`;已有记录 updateRecord,新记录 createRecord 后跳转详情。只有写入成功才退出编辑、清理撤销记录并显示“详情已保存”成功提示;标题修改成功也显示该提示。失败显示错误并保留编辑状态及画布内容。
6. 导出保留 html-to-image 的 JPG/PNG 格式与图形边界计算。 6. 导出保留 html-to-image 的 JPG/PNG 格式与图形边界计算。
## 关系、标签、评论及预览 ## 关系、标签、评论及预览
...@@ -71,12 +71,38 @@ ...@@ -71,12 +71,38 @@
## 异常与限制 ## 异常与限制
### 操作提示与失败重试
- 功能入口:登录/验证码、站点配置、应用切换、流程列表、分组视图面板、流程详情画布、关联制度/法规预览、历史版本和通知中心。
- 读取、新建、修改、保存、复制、删除、标签/关系查询及通知操作的成功/失败反馈统一显示 HeroUI Toast;站点配置和应用列表回退使用 warning。应用根部挂载,视口顶部 Portal 层级高于业务弹框、确认框和移动侧栏,弹框打开或页面滚动时仍可看到并关闭提示。
- 成功/信息默认 4 秒、错误/警告默认 8 秒后消失;悬停或键盘聚焦暂停计时。当前提示右侧的关闭图标始终可见,使用 HeroUI 默认小号透明按钮,通过 `close` slot 支持点击和键盘关闭;后排堆叠提示隐藏关闭按钮,避免误关闭。相同错误再次发生时重新显示,同一读取来源的新提示替换旧提示,清空反馈或离开对应组件时清理该来源提示。
- 关闭提示只影响展示,不把失败改为成功,不清空表单草稿、选中记录或画布;必填校验与删除确认说明继续留在控件和确认框中。弹框提交失败保持打开,Loading 结束后可修正内容再次提交。
- 流程详情的“保存”和标题修改只在 `updateRecord`/`createRecord` 成功后显示“详情已保存”,不叠加通用“保存成功”提示;失败不显示成功。重新编辑、再次保存、取消、切换记录或离开详情时清理旧成功提示;画布自动保存继续沿用原静默调用,不增加提示。
- 已有记录的成功提示由 Header 管理;新建记录在 `createRecord` 返回后从通用保存入口发布,成功提示可跨过跳转到新记录详情的卸载过程。接口路径、请求顺序及 `title`/`metadata.flow` 协议不变。
- 流程列表、面板、画布、关联预览、历史版本和通知读取失败时显示失败空状态及“重试”;详细原因由浮层显示,避免错误地展示“暂无数据”或旧的流程图。重试只读取当前资源,不重复执行创建、保存、复制、删除或通知写入操作。
- 列表/面板重试沿用当前分组 `tab`;详情/预览重试沿用当前记录 `_id`/`name` 及 Subject,历史版本仍按 `metadata.origin_regulation` 查询;通知仍按分类、已读状态、关键词和页码查询。关闭提示不改动 `metadata.process_list`、`metadata.source`、`metadata.flow`、`metadata.group_flow` 或任何业务状态。
### 接口与业务限制
- 列表、画布读取失败显示错误并支持重试;弹窗提交失败保持打开,防止重复提交。 - 列表、画布读取失败显示错误并支持重试;弹窗提交失败保持打开,防止重复提交。
- 记录、Subject、标签查询复用现有 DataService 与鉴权请求层,401 沿用现有登录处理。 - 记录、Subject、标签查询复用现有 DataService 与鉴权请求层,401 沿用现有登录处理。
- 创建流程、更新分组等组合操作不是事务;保留旧顺序,失败后可能需要刷新核对已写入记录。 - 创建流程、更新分组等组合操作不是事务;保留旧顺序,失败后可能需要刷新核对已写入记录。
- 本次未修改服务端字段定义、权限配置和业务规则。`subjectsDefinition.md` 记录旧前端消费协议;完整 schema 以服务端为准。 - 本次未修改服务端字段定义、权限配置和业务规则。`subjectsDefinition.md` 记录旧前端消费协议;完整 schema 以服务端为准。
- 修正迁入代码中阻止运行的两处变量初始化问题:图导出尺寸提前读取;连线组件用实际 store selector 读取 nodesDraggable。 - 修正迁入代码中阻止运行的两处变量初始化问题:图导出尺寸提前读取;连线组件用实际 store selector 读取 nodesDraggable。
## 验证状态 ## 迁移阶段验证记录
迁移目录 43 个 JS/JSX 文件已通过语法、本地导入解析及禁止旧 UI 依赖检查。使用相同模拟上下文对比新旧处理函数,9 项用例通过:新增分组、列表流程新增/修改/复制/删除、面板流程新增/修改、画布新建/更新保存;API 参数和调用顺序相同,只归一化应用路由前缀。完整构建和浏览器交互测试尚未完成:离线依赖缓存不完整,联网安装被自动审批拒绝。当前 package.json 已补充所需依赖,但 node_modules 尚未恢复完整、锁文件仍待成功安装后更新。依赖/锁文件安装成功后需验证登录、分组、流程 CRUD、图编辑/导出、关系预览、标签、评论和普通用户权限,不应将静态检查等同于上线验收。 迁移目录 43 个 JS/JSX 文件已通过语法、本地导入解析及禁止旧 UI 依赖检查。使用相同模拟上下文对比新旧处理函数,9 项用例通过:新增分组、列表流程新增/修改/复制/删除、面板流程新增/修改、画布新建/更新保存;API 参数和调用顺序相同,只归一化应用路由前缀。完整构建和浏览器交互测试尚未完成:离线依赖缓存不完整,联网安装被自动审批拒绝。当前 package.json 已补充所需依赖,但 node_modules 尚未恢复完整、锁文件仍待成功安装后更新。依赖/锁文件安装成功后需验证登录、分组、流程 CRUD、图编辑/导出、关系预览、标签、评论和普通用户权限,不应将静态检查等同于上线验收。
## 2026-10-08 操作反馈验证(513706c 阶段)
- 当前依赖可用,`npm run build` 生产构建通过;上述依赖阻塞为迁移阶段的历史记录。
- 使用本地模拟接口验证:分组弹框保存失败时提示显示在遮罩上方,手动关闭不关闭弹框且保留输入;重复失败可重新提示;保存成功提示自动消失。
- 列表读取失败时显示失败空状态,关闭提示后仍保留重试,重试不会执行写入操作;键盘 Space 可关闭提示。应用切换查询失败时警告显示在弹框上方,并展示账号上的回退应用。
- 验证 375px 手机及 812×375 横屏显示,提示关闭按钮为 44×44px、始终可见,提示层级为 300,无页面横向溢出。此验证使用模拟数据,未执行真实业务写入。
## 2026-10-08 bfc5862 同步验证
- 生产构建通过;关闭按钮已改为官方 `ghost`/`sm` 图标按钮,替代上一阶段的 44×44px 绝对定位按钮。前排按钮可鼠标点击及键盘 Space 关闭,后排按钮为 `visibility: hidden` 且不可点击。
- 本地模拟 `PUT /record/:name` 失败时保留详情编辑状态且不显示成功;成功后退出编辑并只显示一条“详情已保存”,重新编辑清理旧成功提示。标题修改成功使用相同提示;新建流程 `POST /record` 成功跳转后提示仍保留。
- 保存仍传 `title` 和字符串化的 `metadata.flow`,自动保存的静默调用不变;验证未执行真实业务写入。
...@@ -77,7 +77,7 @@ export const useProcessFlow = () => { ...@@ -77,7 +77,7 @@ export const useProcessFlow = () => {
); );
const saveFlow = useCallback( const saveFlow = useCallback(
async (id, title, hideMessage) => { async (id, title, hideMessage, successMessage = "保存成功") => {
const flow = JSON.stringify({ const flow = JSON.stringify({
nodes: getNodes(), nodes: getNodes(),
edges: getEdges(), edges: getEdges(),
...@@ -90,7 +90,7 @@ export const useProcessFlow = () => { ...@@ -90,7 +90,7 @@ export const useProcessFlow = () => {
}, },
}); });
if (!hideMessage) { if (!hideMessage) {
message.success("保存成功"); message.success(successMessage);
} }
} else { } else {
const { _id } = await createRecord({ const { _id } = await createRecord({
...@@ -101,7 +101,7 @@ export const useProcessFlow = () => { ...@@ -101,7 +101,7 @@ export const useProcessFlow = () => {
}, },
}); });
if (!hideMessage) { if (!hideMessage) {
message.success("保存成功"); message.success(successMessage);
} }
history.replace(`/${params.subject}/${_id}`); history.replace(`/${params.subject}/${_id}`);
} }
......
import FeedbackErrorState from '@/components/FeedbackErrorState';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon } from '@/components/AppIcons'; import { Bell, Close, MagnifyingGlassIcon } from '@/components/AppIcons';
import { useUserInfo } from '@/hooks/user'; import { useUserInfo } from '@/hooks/user';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Card, Card,
...@@ -205,7 +206,7 @@ export default function NotificationListPage() { ...@@ -205,7 +206,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0); const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [busyKey, setBusyKey] = useState(''); const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -278,12 +279,12 @@ export default function NotificationListPage() { ...@@ -278,12 +279,12 @@ export default function NotificationListPage() {
setBusyKey(key); setBusyKey(key);
try { try {
await operation(); await operation();
toast.success(successMessage, { timeout: 3000 }); toast.success(successMessage, { timeout: 4000 });
refresh(); refresh();
return true; return true;
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', { toast.danger(operationError?.message || '操作失败,请稍后重试', {
timeout: 4000, timeout: 8000,
}); });
return false; return false;
} finally { } finally {
...@@ -333,7 +334,7 @@ export default function NotificationListPage() { ...@@ -333,7 +334,7 @@ export default function NotificationListPage() {
refresh(); refresh();
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { toast.danger(operationError?.message || '无法打开通知关联流程', {
timeout: 4000, timeout: 8000,
}); });
} finally { } finally {
setBusyKey(''); setBusyKey('');
...@@ -418,14 +419,7 @@ export default function NotificationListPage() { ...@@ -418,14 +419,7 @@ export default function NotificationListPage() {
</div> </div>
{error ? ( {error ? (
<Alert status="danger"> <FeedbackErrorState title="通知加载失败" onRetry={refresh} />
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="ghost" onPress={refresh}>
重试
</Button>
</Alert>
) : null} ) : null}
{loading && notifications.length === 0 ? ( {loading && notifications.length === 0 ? (
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
@import "./design-system/theme.css"; @import "./design-system/theme.css";
@import "./design-system/tokens.css"; @import "./design-system/tokens.css";
@import "./design-system/shell.css"; @import "./design-system/shell.css";
@import "./design-system/feedback.css";
@source "./*.{js,jsx,ts,tsx}"; @source "./*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}"; @source "./components/**/*.{js,jsx,ts,tsx}";
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService'; import { getSystemInfo } from '@/services/DataService';
import { Spinner } from '@heroui/react'; import { Spinner } from '@heroui/react';
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('warning');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment