Commit 28960d22 authored by drigle's avatar drigle

fix: 统一操作反馈并完善失败重试

parent 3963f7ff
......@@ -69,7 +69,7 @@
- 日期与时间字段需迁移到 HeroUI DatePicker 适配器,保留现有字符串格式和回调。
- Excel 导入仍使用可见原生文件按钮,必须迁移到 HeroUI Pro DropZone。
- 项目编辑/新建、导入、应用切换弹窗需统一遮罩、圆角、关闭按钮、Footer 和移动端按钮堆叠。
- 例行成功反馈和“暂无真实项目记录”提示不得占用页面布局,应统一为顶部居中、约 3-4 秒自动消失的 Toast;筛选请求加载期间不显示常驻文案,保持 Surface 高度稳定;需要用户修正的请求错误才保留上下文 Alert。
- 接口成功、失败和“暂无真实项目记录”等警告统一使用根部顶部居中 Toast,成功 4 秒、失败/警告 8 秒后消失。当前提示右侧使用始终可见的小号 HeroUI 透明关闭按钮,参与正文布局,后排提示隐藏关闭按钮;浮层高于业务弹窗。详情保存成功提示在刷新结束、编辑弹窗关闭时开始计时;筛选请求加载期间不显示常驻文案,保持 Surface 高度稳定。加载失败保留失败空状态与读取重试入口,表单/文件校验继续留在上下文中。
## P1 页面级迁移
......
......@@ -108,6 +108,9 @@ Layout route/path change + 60s timer + notification-state event
## Overlay 架构
- 应用根通过 `I18nProvider -> AppFeedback` 统一提供中文环境与 HeroUI Toast Portal;AppShell 不再自行挂载 Provider。`useFeedbackState` 保存业务失败状态并为每次反馈创建 Toast,相同错误重试仍重新提示,同一来源的新消息替换旧消息,清空或卸载时清理该来源 Toast。关闭/超时仅影响浮层展示,失败空状态、重试入口、草稿和筛选仍由页面持有。
- 共享 `feedback.css` 沿用问题整改 `bfc5862` 的窄范围交互合同,仅控制 Toast 层级、顶部安全区和后排关闭按钮可见性。前排关闭按钮直接组合官方 `Button ghost/sm/isIconOnly`、`AppIcons.Close` 和 `slot="close"`,随正文布局,保持可见并使用默认 Hover/焦点样式。接口成功/失败/降级警告使用 Toast;字段、周进度和导入文件校验继续保留在表单上下文中。
- 项目详情保存成功提示由工作台持有;保存接口成功、刷新流程结束并关闭编辑弹窗后再触发 4 秒成功 Toast,避免刷新耗时提前用尽展示时间。开始新建、编辑/预览其他记录或再次提交时清理上一条成功提示;保存失败保留弹窗草稿,仅报告错误。
- Modal/AlertDialog/Dropdown/Autocomplete/DatePicker/Tooltip 直接使用 HeroUI Portal 结构。
- Autocomplete 统一在共享 HeroUI-native 组合中使用当前 `react-stately` Select 的受控 `value/onChange` 契约:单选值为 ID 或 `null`,多选值为 ID 数组;官方 ClearButton、触发器显示和选项勾选均由该值驱动。远程用户字段只在业务边界输出原有 ID、`''`、ID 数组或 `[]`。`selectedKeys/onSelectionChange` 仅用于 DataGrid 等集合选择,不用于 Autocomplete 根。
- 内容 Shell 固定 `light`,Sidebar 通过共享暗色 token 渲染;Portal 从根继承主题,不提供主题切换状态。
......
......@@ -51,6 +51,7 @@
## 组件合同
- 操作反馈:根部 `AppFeedback` 统一挂载 HeroUI `Toast.Provider`,登录、AppShell 和所有业务弹窗共用一个 Provider。成功默认 4 秒、失败/警告默认 8 秒后消失,悬停或键盘聚焦暂停计时。与问题整改提交 `bfc5862` 保持一致,关闭按钮直接使用 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`)及 `AppIcons.Close`,通过官方 `slot="close"` 接入关闭行为,在正文右侧随内容参与布局;前排图标始终可见,后排堆叠提示隐藏关闭按钮。`src/design-system/feedback.css` 仅控制层级 300、顶部安全区与后排按钮可见性;浮层高于业务 Modal、确认框与移动侧栏,长文在正文内滚动,按钮使用默认 Hover/焦点样式,颜色、边框、圆角和阴影继续使用 HeroUI 主题。接口反馈不再渲染为页面横条,关闭提示不改变请求状态或表单草稿。
- 登录:约 28rem 卡片、36px Logo、卡内品牌、24px 字段组节奏、40px 字段、46px提交按钮。
- 应用切换:46rem/42rem,24px 圆角与 28px inset,手机一列、sm 起固定两列,36px `Surface tertiary + Avatar soft` identity well;分组来自 `site.group`,按后端顺序渲染并将未引用应用归入“其他应用”。
- 筛选:1-2 个字段不加容器;3 个以上使用 `Surface default rounded-3xl p-6`,前三项常用字段,其余放 HeroUI Disclosure。
......
{
"name": "project_manager_xy",
"version": "1.0.4",
"version": "1.0.5",
"build": "abc",
"description": "部门任务管理",
"date": "2025-07-14 19:27:20",
......
import '@/tailwind.css';
import AppFeedback from '@/components/AppFeedback';
import { I18nProvider } from '@heroui/react';
import { createElement } from 'react';
import { defineApp } from 'umi';
......@@ -11,6 +12,6 @@ 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';
......@@ -32,7 +31,6 @@ export default function AppShell({
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import useFeedbackState from '@/hooks/useFeedbackState';
import {
fetchSystemUsersByIds,
mergeUserRecords,
......@@ -29,6 +30,7 @@ export default function XinyuanUserAutocomplete({
const [selectedUsers, setSelectedUsers] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useFeedbackState('error');
const requestIdRef = useRef(0);
useEffect(() => {
......@@ -42,31 +44,42 @@ export default function XinyuanUserAutocomplete({
setSelectedUsers(records || []);
setOptions((current) => mergeUserRecords([...(current || []), ...(records || [])]));
}
}).catch(() => {
}).catch((requestError) => {
if (!cancelled) {
setSelectedUsers([]);
setError(requestError?.message || '已选用户信息加载失败,请重新搜索。');
}
});
return () => { cancelled = true; };
}, [selectedIds.join('|')]);
}, [selectedIds.join('|'), setError]);
useEffect(() => {
if (!isOpen) return undefined;
let cancelled = false;
const requestId = ++requestIdRef.current;
const timer = window.setTimeout(() => {
setIsLoading(true);
setError('');
searchSystemUsers(query)
.then((records) => {
if (requestId === requestIdRef.current) {
if (!cancelled && requestId === requestIdRef.current) {
setOptions(mergeUserRecords([...(selectedUsers || []), ...(records || [])]));
}
})
.catch((requestError) => {
if (!cancelled && requestId === requestIdRef.current) {
setError(requestError?.message || '用户列表加载失败,请重新搜索。');
}
})
.finally(() => {
if (requestId === requestIdRef.current) setIsLoading(false);
if (!cancelled && requestId === requestIdRef.current) setIsLoading(false);
});
}, 200);
return () => window.clearTimeout(timer);
}, [isOpen, query, selectedUsers]);
return () => {
cancelled = true;
window.clearTimeout(timer);
setIsLoading(false);
};
}, [isOpen, query, selectedUsers, setError]);
const mergedUsers = useMemo(
() => mergeUserRecords([...(selectedUsers || []), ...(options || [])]),
......@@ -92,6 +105,7 @@ export default function XinyuanUserAutocomplete({
return (
<XinyuanAutocomplete
aria-label={placeholder}
errorText={error}
inputValue={query}
isDisabled={isDisabled}
isLoading={isLoading}
......
/* Keep operation feedback 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 },
};
// Dismissing feedback 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;
// Every attempt reports feedback, including identical errors on a retry.
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 xinyuanLogo from '@/assets/xy-logo-red.png';
import { APPLICATION_DISPLAY_NAME, 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,
......@@ -308,7 +308,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;
......@@ -348,6 +348,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => {
if (!open) {
setApplicationSearch('');
setErrorMessage('');
return;
}
......@@ -388,7 +389,7 @@ export default function ApplicationSwitcherDialog({
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
}, [fallbackApplications, open, setErrorMessage]);
return (
<Modal.Root>
......@@ -439,13 +440,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 clsx from 'clsx';
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanDatePicker from '@/components/XinyuanDatePicker';
import XinyuanModal from '@/components/XinyuanModal';
......@@ -38,7 +39,6 @@ import {
Surface,
Tabs,
TextArea,
toast,
Tooltip,
} from '@heroui/react';
import { parseDate, parseDateTime } from '@internationalized/date';
......@@ -1781,7 +1781,6 @@ function ProjectTaskDialog({
task,
submitting,
deleting,
submitError,
onClose,
onSubmit,
onDelete,
......@@ -2328,10 +2327,6 @@ function ProjectTaskDialog({
</div>
</Surface>
{submitError ? (
<Alert status="danger"><Alert.Content><Alert.Description>{submitError}</Alert.Description></Alert.Content></Alert>
) : null}
<div>
<div>
<div className="text-sm text-muted">
......@@ -2394,7 +2389,9 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const [tasks, setTasks] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [, setWarning] = useFeedbackState('warning');
const [, setSuccess] = useFeedbackState('success');
const [filters, setFilters] = useState(emptyFilters);
const [queryFilters, setQueryFilters] = useState(emptyFilters);
const [filtersExpanded, setFiltersExpanded] = useState(false);
......@@ -2406,10 +2403,11 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const [previewTask, setPreviewTask] = useState(null);
const [dialogSubmitting, setDialogSubmitting] = useState(false);
const [dialogDeleting, setDialogDeleting] = useState(false);
const [dialogError, setDialogError] = useState('');
const [, setDialogError] = useFeedbackState('error');
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importPreviewRows, setImportPreviewRows] = useState([]);
const [importError, setImportError] = useState('');
const [, setImportRequestError] = useFeedbackState('error');
const [importLoading, setImportLoading] = useState(false);
const [importFileName, setImportFileName] = useState('');
const latestWorkspaceRequestId = useRef(0);
......@@ -2427,6 +2425,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
latestWorkspaceRequestId.current = requestId;
setLoading(true);
setError('');
setWarning('');
try {
const taskRecords = await fetchProjectTasks(activeFilters);
......@@ -2460,9 +2459,9 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setUsers(normalizedUsers);
setTasks(enrichedTasks);
if (enrichedTasks.length === 0) {
toast.warning('当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。', {
timeout: 3500,
});
setWarning('当前暂无项目记录,请先创建或导入项目。');
} else {
setWarning('');
}
} catch (requestError) {
if (requestId !== latestWorkspaceRequestId.current) {
......@@ -2548,11 +2547,6 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
};
}, [visibleTasks]);
const persistTask = async (values, currentTask) => {
await saveProjectTask(values, currentTask);
await loadWorkspace();
};
const handleImportTemplateDownload = () => {
downloadProjectTaskImportTemplate();
};
......@@ -2564,6 +2558,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setImportDialogOpen(false);
setImportPreviewRows([]);
setImportError('');
setImportRequestError('');
setImportFileName('');
};
......@@ -2575,6 +2570,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setImportLoading(true);
setImportError('');
setImportRequestError('');
setImportFileName(file.name);
try {
......@@ -2603,6 +2599,8 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setImportLoading(true);
setImportError('');
setImportRequestError('');
setSuccess('');
try {
await importRecords(
......@@ -2632,11 +2630,15 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
}),
})),
);
setSuccess(`已导入 ${validRows.length} 个项目。`);
await loadWorkspace();
handleImportDialogClose();
setImportDialogOpen(false);
setImportPreviewRows([]);
setImportError('');
setImportFileName('');
} catch (requestError) {
setImportError(requestError?.message || '导入失败,请稍后重试');
setImportRequestError(requestError?.message || '导入失败,请稍后重试');
} finally {
setImportLoading(false);
}
......@@ -2646,6 +2648,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setEditingTask(null);
setDialogDeleting(false);
setDialogError('');
setSuccess('');
setDialogOpen(true);
};
......@@ -2653,21 +2656,26 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setEditingTask(task || selectedTask || null);
setDialogDeleting(false);
setDialogError('');
setSuccess('');
setDialogOpen(true);
};
const openPreviewDialog = (task) => {
setSuccess('');
setPreviewTask(task || null);
};
const handleDialogSubmit = async (values) => {
setDialogSubmitting(true);
setDialogError('');
setSuccess('');
try {
await persistTask(values, editingTask);
await saveProjectTask(values, editingTask);
await loadWorkspace();
setDialogOpen(false);
setEditingTask(null);
setSuccess(editingTask ? '详情已保存。' : '项目已创建。');
} catch (requestError) {
setDialogError(requestError?.message || '保存失败,请稍后重试');
} finally {
......@@ -2684,9 +2692,11 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const deletingTaskId = getTaskRecordId(taskToDelete);
setDialogDeleting(true);
setDialogError('');
setSuccess('');
try {
await deleteProjectTask(taskToDelete);
setSuccess('项目已删除。');
await loadWorkspace();
if (selectedTaskId && deletingTaskId === selectedTaskId) {
setSelectedTaskId('');
......@@ -2818,16 +2828,24 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
</div>
</Disclosure.Content>
{error ? (
<Alert status="warning"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>
) : null}
</div>
</div>
</Disclosure>
</ToolbarCard>
<div className="rc-kpi-grid" data-count="4">
{!loading && error ? (
<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={() => loadWorkspace()}>重新加载</Button>
</EmptyState.Content>
</EmptyState>
) : null}
<div className={error ? 'hidden' : 'rc-kpi-grid'} data-count="4">
<StatKpi
label="项目总数"
value={stats.total}
......@@ -2854,7 +2872,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
</div>
{activeTab === 'gantt' && !loading ? (
{activeTab === 'gantt' && !loading && !error ? (
<ProjectGanttBoard
tasks={visibleTasks}
selectedTaskId={selectedTaskId}
......@@ -2863,7 +2881,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
) : null}
{activeTab === 'calendar' && hasVisibleTasks ? (
{activeTab === 'calendar' && hasVisibleTasks && !error ? (
<ProjectCalendarBoard
tasks={visibleTasks}
currentUserId={currentUser?.id || ''}
......@@ -2873,7 +2891,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
) : null}
{activeTab === 'list' && !loading ? (
{activeTab === 'list' && !loading && !error ? (
<ProjectListPanel
tasks={visibleTasks}
onSelectTask={setSelectedTaskId}
......@@ -2882,7 +2900,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
) : null}
{activeTab === 'calendar' && !loading && !hasVisibleTasks ? (
{activeTab === 'calendar' && !loading && !error && !hasVisibleTasks ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>当前暂无项目</EmptyState.Title>
......@@ -2897,7 +2915,6 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
viewOnly={!dialogOpen && Boolean(previewTask)}
submitting={dialogSubmitting}
deleting={dialogDeleting}
submitError={dialogError}
fieldOptions={fieldOptions}
onClose={() => {
if (dialogSubmitting || dialogDeleting) {
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
......@@ -144,7 +144,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);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
......@@ -189,7 +189,7 @@ export default function NotificationListPage() {
return () => {
cancelled = true;
};
}, [category, keyword, page, pageSize, readStatus, reloadKey]);
}, [category, keyword, page, pageSize, readStatus, reloadKey, setError]);
useEffect(() => {
const totalPagesForSize = Math.max(1, Math.ceil(total / pageSize));
......@@ -215,11 +215,11 @@ 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 });
toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 8000 });
return false;
} finally {
setBusyKey('');
......@@ -254,7 +254,7 @@ export default function NotificationListPage() {
}
refresh();
} catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 });
toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 8000 });
} finally {
setBusyKey('');
}
......@@ -299,7 +299,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 ? (
<div aria-label="通知加载中" className="flex min-h-64 items-center justify-center"><Spinner size="sm" /></div>
......
......@@ -56,6 +56,18 @@
## 4. 业务流程
### 操作提示与失败重试
- 功能入口:登录/验证码、站点配置、应用切换、资源统计、资源预定的查询/新建/编辑/启动/完成/删除/导入、系统用户远程搜索和通知中心。
- 接口成功、失败及降级警告统一显示根部 HeroUI Toast,位于视口顶部、业务弹窗/确认框/移动侧栏上方;页面滚动或弹窗打开时仍可看到并关闭,提示不会主动抢走表单焦点。
- 成功默认 4 秒、失败和警告默认 8 秒后消失,悬停或键盘聚焦暂停计时。当前提示右侧使用 HeroUI 默认小号透明图标按钮,始终可见,通过 Toast 的 `close` slot 支持点击和键盘关闭;后排堆叠提示隐藏关闭按钮,避免误关。同一来源的新消息替换旧消息,相同错误再次发生时重新提示,清空反馈或离开页面时清理该来源提示。
- 关闭或超时只改变提示展示,不把失败改为成功,不清空草稿、用户引用、周进度、导入预览、当前 Tab/月份或筛选,不重新执行保存、删除、导入或待办操作。必填、周进度和文件/行校验继续在对应表单内展示。
- 项目、资源统计和通知列表加载失败时展示失败空状态及“重新加载/重试”;关闭 Toast 后仍可重试,不显示“暂无数据”或零值统计冒充成功。项目重试沿用上次已提交的筛选,通知重试沿用当前分类、已读状态、关键词和页码;重试只读取数据。
- 项目保存/启动/完成/删除或批量导入成功后显示成功提示并刷新项目;导入完成后关闭导入弹窗。写入失败保留弹窗和当前输入/预览,可修正后再次提交;写入成功但刷新失败时单独提示查询错误,不重复执行写入。
- 项目预览进入编辑、甘特图/个人日历/清单进入编辑后,点击“保存项目”(含“启动项目/完成项目”)必须等 `saveProjectTask` 的创建或更新接口成功,才关闭编辑弹窗并显示成功提示:已有记录显示“详情已保存”,新建显示“项目已创建”。提示在刷新流程结束、弹窗关闭时开始 4 秒计时,由工作台持有,不因弹窗关闭而清除;刷新失败仍单独保留读取错误及重试入口。重新新建、编辑/预览其他记录或再次提交时清理旧成功提示;表单校验或保存失败时不显示成功提示并保留草稿。
- 系统用户搜索失败弹出错误提示,选择器保留字段级错误和已有引用 ID,可重新输入或重新打开搜索。站点配置或应用列表失败继续使用既有回退数据,并显示 warning;成功查询但没有项目时显示空数据警告,与加载失败区分。
- 数据仍使用 `pm_project_task.metadata`,用户引用只传 ID/ID 数组;提示关闭不更改业务状态、字段规则、接口 URL、请求参数或权限。
### 4.1 领导下发项目
1. 领导创建一条 `pm_project_task`
......@@ -290,5 +302,5 @@ const result = await queryUsers(0, 20, {
### 8.4 状态刷新与异常
- Shell 在首次加载、路由变化、通知状态事件和每 60 秒刷新未读数。
- 列表加载失败显示可重试 Alert;操作失败保留当前数据并显示 Toast。
- 列表加载失败显示失败空状态及“重试”,详细错误通过可关闭 Toast 展示;操作失败保留当前数据并显示 Toast。成功默认 4 秒、失败默认 8 秒后消失。
- 该功能不改变 `pm_project_task` 的查询、字段、校验、分页、保存回调或业务状态流。
......@@ -44,6 +44,7 @@ export async function loadProjectDashboardData(currentUsername = '') {
fieldOptions,
isDemoMode: false,
error: '',
warning: '',
};
}
......@@ -52,7 +53,8 @@ export async function loadProjectDashboardData(currentUsername = '') {
users,
fieldOptions,
isDemoMode: false,
error: '当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。',
error: '',
warning: '当前暂无项目记录,请先创建或导入项目。',
};
} catch (error) {
return {
......@@ -61,6 +63,7 @@ export async function loadProjectDashboardData(currentUsername = '') {
fieldOptions: {},
isDemoMode: false,
error: error?.message || '统计数据加载失败,请稍后重试。',
warning: '',
};
}
}
import useFeedbackState from '@/hooks/useFeedbackState';
import {
createResolvedXinyuanChartColorMap,
getXinyuanEChartsTheme,
......@@ -14,7 +15,7 @@ import { UserContext } from '@/wrapper/Auth';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
import { Alert, Card, Spinner, Surface } from '@heroui/react';
import { Button, Card, Spinner, Surface } from '@heroui/react';
import { useContext, useEffect, useMemo, useState } from 'react';
import {
formatDateLabel,
......@@ -171,22 +172,27 @@ export default function ResourceStatPage({ currentUsername = '' }) {
const [tasks, setTasks] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [, setWarning] = useFeedbackState('warning');
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError('');
setWarning('');
const result = await loadProjectDashboardData(effectiveCurrentUsername);
if (!cancelled) {
setTasks(result.tasks || []);
setUsers(result.users || []);
setError(result.error || '');
setWarning(result.warning || '');
setLoading(false);
}
})();
return () => { cancelled = true; };
}, [effectiveCurrentUsername]);
}, [effectiveCurrentUsername, reloadKey, setError, setWarning]);
const dashboard = useMemo(() => {
const total = tasks.length;
......@@ -260,14 +266,22 @@ export default function ResourceStatPage({ currentUsername = '' }) {
return <div className="flex min-h-80 items-center justify-center"><Spinner label="正在加载资源统计" /></div>;
}
if (error) {
return (
<EmptyState className="min-h-80" size="md">
<EmptyState.Header>
<EmptyState.Title>资源统计加载失败</EmptyState.Title>
<EmptyState.Description>请重新加载统计数据。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={() => setReloadKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Content>
</EmptyState>
);
}
return (
<div className="rc-page-stack">
{error ? (
<Alert status="warning">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
</Alert>
) : null}
<div className="rc-kpi-grid" data-count="3">
<StatKpi
label="我的关联项目"
......
......@@ -7,6 +7,7 @@
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
@import "./design-system/feedback.css";
@theme {
--font-inter:
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
......@@ -18,7 +19,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