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 配色调整
当前 AppShell 使用低饱和深藏蓝侧栏与中性灰白内容画布。`theme.css` 的 `--background` 和 `--xinyuan-sidebar-*` 是这组配色的唯一来源:侧栏背景为 `oklch(32% 0.037 255)`,页面画布为 `oklch(97.4% 0.002 255)`;侧栏文字为近白色,悬停项为稍亮的蓝色,选中项为提亮的蓝色底与白字。品牌主红 `#DA241A` 不变。侧栏红色 Logo 直接显示,不加底色。桌面与移动侧栏共用这些 Token,导航结构、交互及数据状态不变。
......
{
"name": "process_management_new",
"version": "1.0.1",
"version": "1.0.5",
"build": "abc",
"description": "merge master",
"date": "2025-07-14 19:27:20",
......
import '@/global.less';
import '@/tailwind.css';
import AppFeedback from '@/components/AppFeedback';
import { I18nProvider } from '@heroui/react';
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>{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 { 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;
......@@ -352,6 +352,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => {
if (!open) {
setApplicationSearch('');
setErrorMessage('');
return;
}
......@@ -443,13 +444,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 ? (
......
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 {
color: var(--foreground);
}
.process-table .process-table__action-content {
justify-content: flex-start;
gap: 4px;
}
.process-page--group-list {
margin-top: calc(-1 * var(--rc-page-padding-block-start));
}
......
......@@ -47,9 +47,9 @@ const TOKEN = {
};
export const theme = { useToken: () => ({ token: TOKEN }) };
export const message = {
success: (text) => toast.success(text),
error: (text) => toast.danger(text),
info: (text) => toast.info(text),
success: (text) => toast.success(text, { timeout: 4000 }),
error: (text) => toast.danger(text, { timeout: 8000 }),
info: (text) => toast.info(text, { timeout: 4000 }),
};
const handleError = (error) =>
message.error(error?.message || "操作失败,请重试");
......@@ -237,6 +237,7 @@ function EditableText({ children, editable, type, style }) {
type="text"
style={style}
onClick={() => {
editable.onStart?.();
setValue(typeof children === "string" ? children : "");
setEditing(true);
}}
......@@ -918,6 +919,10 @@ export function ProTable({
(value, key) => value?.[key],
record
);
const getColumnWidthStyle = (column) =>
column.width
? { width: column.width, minWidth: column.width, maxWidth: column.width }
: undefined;
return (
<Card>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
......@@ -964,7 +969,7 @@ export function ProTable({
{loading ? (
<Spin />
) : (
<Table>
<Table className="process-table">
<Table.ScrollContainer>
<Table.Content aria-label="流程列表">
<Table.Header>
......@@ -972,6 +977,8 @@ export function ProTable({
<Table.Column
key={String(col.key || col.dataIndex || index)}
isRowHeader={index === 0}
className={col.key === "action" ? "process-table__action-column" : undefined}
style={getColumnWidthStyle(col)}
>
{col.title}
</Table.Column>
......@@ -984,9 +991,19 @@ export function ProTable({
id={record._id || record.name || String(index)}
>
{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 ? (
<Space>
<Space
className={
col.key === "action"
? "process-table__action-content"
: undefined
}
>
{col.render(
getValue(record, col.dataIndex),
record,
......
import FeedbackErrorState from "@/components/FeedbackErrorState";
import useFeedbackState from "@/hooks/useFeedbackState";
import {
Button,
Modal,
......@@ -14,10 +16,12 @@ export default function RecordFields({ subjectConfig, subject, record }) {
const [history, setHistory] = useState([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [error, setError] = useFeedbackState("error");
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let active = true;
setLoading(true);
setError("");
queryRecords({
subject: "internal_regulation",
filter: { "metadata.origin_regulation": record._id },
......@@ -44,7 +48,7 @@ export default function RecordFields({ subjectConfig, subject, record }) {
return () => {
active = false;
};
}, [record._id]);
}, [record._id, reloadKey, setError]);
const format = (field, value) => {
if (value == null || value.length === 0) return "-";
if (["reference", "document", "user"].includes(field.type))
......@@ -93,7 +97,10 @@ export default function RecordFields({ subjectConfig, subject, record }) {
footer={null}
>
{error ? (
<p role="alert">{error}</p>
<FeedbackErrorState
title="历史版本加载失败"
onRetry={() => setReloadKey((value) => value + 1)}
/>
) : loading ? (
<Spin />
) : (
......
import FeedbackErrorState from "@/components/FeedbackErrorState";
import useFeedbackState from "@/hooks/useFeedbackState";
import {
Modal,
Flex,
......@@ -18,28 +20,34 @@ const DOCUMENT_PADDING = 30;
const RecordPreviewModal = (props) => {
const { record, onCancel } = props;
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 { getSubjectConfig } = useAppLayout();
const subjectConfig = getSubjectConfig(record?.type);
useEffect(() => {
let cancelled = false;
(async () => {
setError("");
setDetail(null);
try {
if (record) {
const res = await loadRecord(record._id);
setDetail(res);
const subjectDetail = await loadSubject(record?.type);
if (cancelled) return;
setDetail(res);
setSubject(subjectDetail);
}
} 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 sections = useMemo(() => documentMeta?.sections || [], [documentMeta]);
......@@ -243,7 +251,10 @@ const RecordPreviewModal = (props) => {
footer={null}
>
{error ? (
<p role="alert">{error}</p>
<FeedbackErrorState
title="关联记录加载失败"
onRetry={() => setReloadKey((value) => value + 1)}
/>
) : !detail ? (
<Flex style={{ height: 400 }} align="center" justify="center">
<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 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 { Button as RetryButton } from "@/components/process-ui/ProcessUi";
import FeedbackErrorState from "@/components/FeedbackErrorState";
import useFeedbackState from "@/hooks/useFeedbackState";
import { loadRecord } from "@/services/DataService";
import { AppstoreOutlined } from "@ant-design/icons";
import { Spin } from "@/components/process-ui/ProcessUi";
......@@ -214,7 +215,7 @@ function HistoryWrapper() {
const { takeSnapshot, ...undoProps } = useUndoRedo();
const [record, setRecord] = useState(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState("");
const [loadError, setLoadError] = useFeedbackState("error");
const [editing, setEditing] = useState(false);
const params = useParams();
......@@ -261,10 +262,7 @@ function HistoryWrapper() {
editing={editing}
/>
{loadError ? (
<div role="alert" className="p-5 text-danger">
{loadError}
<RetryButton onClick={loadFlowRecord}>重试</RetryButton>
</div>
<FeedbackErrorState title="流程画布加载失败" onRetry={loadFlowRecord} />
) : loading ? (
<div style={{ height: 400 }}>
<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 {
createRecord,
......@@ -53,7 +55,7 @@ const ProcessFlowGraphContent = () => {
const [view, setView] = useState("list");
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState("");
const [loadError, setLoadError] = useFeedbackState("error");
const [processForm] = Form.useForm();
const { check } = useAuthorization();
......@@ -325,13 +327,11 @@ const ProcessFlowGraphContent = () => {
onTabChange={onTabChange}
tabActiveKey={activeTab}
>
{loadError && (
<div role="alert" className="text-danger">
{loadError}
<RetryButton onClick={loadGroups}>重试</RetryButton>
</div>
)}
{loading && <Spin />}
{loadError ? (
<FeedbackErrorState title="流程面板加载失败" onRetry={loadGroups} />
) : loading ? (
<Spin />
) : (
<ProCard style={{ height: "calc(100vh - 180px)" }}>
<ReactFlowProvider>
<ReactFlow
......@@ -361,6 +361,7 @@ const ProcessFlowGraphContent = () => {
</ReactFlow>
</ReactFlowProvider>
</ProCard>
)}
{
<ModalForm
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 {
createRecord,
......@@ -70,7 +71,7 @@ export default function ProcessList() {
const [view, setView] = useState("list");
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState("");
const [loadError, setLoadError] = useFeedbackState("error");
const [processForm] = Form.useForm();
const { check } = useAuthorization();
const flowRef = useRef(null);
......@@ -392,7 +393,7 @@ export default function ProcessList() {
columns.push({
title: "操作",
key: "action",
width: 140,
width: 112,
valueType: "option",
render: (text, item, _, action) => [
<Popconfirm
......@@ -496,12 +497,9 @@ export default function ProcessList() {
],
}}
>
{loadError && (
<div role="alert" className="text-danger">
{loadError}
<RetryButton onClick={loadGroups}>重试</RetryButton>
</div>
)}
{loadError ? (
<FeedbackErrorState title="流程列表加载失败" onRetry={loadGroups} />
) : (
<ProTable
loading={loading}
rowKey="key"
......@@ -541,6 +539,7 @@ export default function ProcessList() {
])
}
/>
)}
{
<ModalForm
key="add"
......
import { useAuthorization } from "@/hooks/user";
import useFeedbackState from "@/hooks/useFeedbackState";
import {
ArrowLeftOutlined,
FileJpgOutlined,
......@@ -16,7 +17,7 @@ import {
Typography,
} from "@/components/process-ui/ProcessUi";
import { toJpeg, toPng } from "html-to-image";
import React, { useContext } from "react";
import React, { useContext, useEffect } from "react";
import {
getRectOfNodes,
getTransformForBounds,
......@@ -43,6 +44,7 @@ const Header = ({
fresh,
}) => {
const { saveFlow } = useProcessFlow();
const [, setSaveSuccess] = useFeedbackState("success");
const { getNodes, setNodes } = useReactFlow();
const { takeSnapshot } = useContext(ProcessFlowContext);
const { x, y } = useViewport();
......@@ -50,6 +52,10 @@ const Header = ({
const { token } = useToken();
const { admin } = useAuthorization();
useEffect(() => {
setSaveSuccess("");
}, [record?._id, setSaveSuccess]);
const exportItems = [
{
key: "jpg",
......@@ -172,9 +178,17 @@ const Header = ({
editable={
admin && {
triggerType: "text",
onStart: () => setSaveSuccess(""),
onChange: async (value) => {
await saveFlow(record?._id, value);
setSaveSuccess("");
await saveFlow(
record?._id,
value,
Boolean(record?._id),
"详情已保存。"
);
update({ ...record, title: value });
if (record?._id) setSaveSuccess("详情已保存。");
},
autoSize: { minRows: 1, maxRows: 1 },
}
......@@ -243,6 +257,7 @@ const Header = ({
<>
<Button
onClick={async () => {
setSaveSuccess("");
trigger(false);
reset();
await fresh();
......@@ -253,9 +268,16 @@ const Header = ({
<Button
type="primary"
onClick={async () => {
await saveFlow(record?._id, record?.title);
setSaveSuccess("");
await saveFlow(
record?._id,
record?.title,
Boolean(record?._id),
"详情已保存。"
);
reset();
trigger(false);
if (record?._id) setSaveSuccess("详情已保存。");
}}
>
保存
......@@ -263,7 +285,13 @@ const Header = ({
</>
)}
{!editing && admin && (
<Button type="primary" onClick={() => trigger(true)}>
<Button
type="primary"
onClick={() => {
setSaveSuccess("");
trigger(true);
}}
>
编辑
</Button>
)}
......
......@@ -46,7 +46,7 @@
2. 管理员可进入编辑状态、修改标题、保存或取消。取消重新读取服务器记录并清理撤销记录。
3. 形状、颜色、位置、大小、节点名称和备注继续写入原节点数据;保留 Shift 等比缩放、缩略图及旧快捷键。
4. 连线工具保留线型、实虚线、起终点箭头和描述。
5. 保存使用原 `JSON.stringify({nodes,edges})`;已有记录 updateRecord,新记录 createRecord 后跳转详情。
5. 保存使用原 `JSON.stringify({nodes,edges})`;已有记录 updateRecord,新记录 createRecord 后跳转详情。只有写入成功才退出编辑、清理撤销记录并显示“详情已保存”成功提示;标题修改成功也显示该提示。失败显示错误并保留编辑状态及画布内容。
6. 导出保留 html-to-image 的 JPG/PNG 格式与图形边界计算。
## 关系、标签、评论及预览
......@@ -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 沿用现有登录处理。
- 创建流程、更新分组等组合操作不是事务;保留旧顺序,失败后可能需要刷新核对已写入记录。
- 本次未修改服务端字段定义、权限配置和业务规则。`subjectsDefinition.md` 记录旧前端消费协议;完整 schema 以服务端为准。
- 修正迁入代码中阻止运行的两处变量初始化问题:图导出尺寸提前读取;连线组件用实际 store selector 读取 nodesDraggable。
## 验证状态
## 迁移阶段验证记录
迁移目录 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 = () => {
);
const saveFlow = useCallback(
async (id, title, hideMessage) => {
async (id, title, hideMessage, successMessage = "保存成功") => {
const flow = JSON.stringify({
nodes: getNodes(),
edges: getEdges(),
......@@ -90,7 +90,7 @@ export const useProcessFlow = () => {
},
});
if (!hideMessage) {
message.success("保存成功");
message.success(successMessage);
}
} else {
const { _id } = await createRecord({
......@@ -101,7 +101,7 @@ export const useProcessFlow = () => {
},
});
if (!hideMessage) {
message.success("保存成功");
message.success(successMessage);
}
history.replace(`/${params.subject}/${_id}`);
}
......
import FeedbackErrorState from '@/components/FeedbackErrorState';
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 +206,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);
......@@ -278,12 +279,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 +334,7 @@ export default function NotificationListPage() {
refresh();
} catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', {
timeout: 4000,
timeout: 8000,
});
} finally {
setBusyKey('');
......@@ -418,14 +419,7 @@ 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>
<FeedbackErrorState title="通知加载失败" onRetry={refresh} />
) : null}
{loading && notifications.length === 0 ? (
......
......@@ -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;
......
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