From 00e2bc5acf77631570abccc5e3df30069a63b4c7 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 17:33:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E9=80=9A=E7=9F=A5=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E3=80=81=E7=99=BB=E5=BD=95=E8=BF=87=E6=9C=9F=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E3=80=81=E9=99=84=E4=BB=B6=E6=89=93=E5=BC=80=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E4=B8=8E=E5=AF=BC=E5=87=BA=E7=BB=9F=E4=B8=80=20loadin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通知中心改为游标分页 + 加载更多,历史通知不再被 50 条上限截断; 加载失败显示错误态与重试 - AI 流式请求 401 被登出时,登录页提示"登录已过期",不再无声踢出 - AI 附件/引用来源打开失败时给出明确错误提示,不再"点了没反应" - 新增 useDownload hook:统一导出/下载的防重复、loading 与成功/失败反馈; 接入学生/账单/房间/入住/费用五个页面的模板下载与导出按钮 - 学生页移除不检查响应状态的私有下载实现,统一走 downloadBlob --- .../components/AiChat/AiMessageContent.tsx | 23 +- apps/admin/src/components/AiChat/provider.ts | 2 + apps/admin/src/hooks/useDownload.ts | 46 ++++ apps/admin/src/pages/Bills/index.tsx | 13 +- .../src/pages/Expenses/ExpenseTablePanel.tsx | 6 + apps/admin/src/pages/Expenses/index.tsx | 32 ++- apps/admin/src/pages/Login/index.tsx | 10 +- apps/admin/src/pages/Notifications/index.tsx | 225 ++++++++++-------- .../pages/Occupancies/OccupanciesToolbar.tsx | 12 +- apps/admin/src/pages/Occupancies/index.tsx | 26 +- apps/admin/src/pages/Rooms/RoomsToolbar.tsx | 12 +- apps/admin/src/pages/Rooms/index.tsx | 19 +- .../src/pages/Students/StudentsToolbar.tsx | 6 + apps/admin/src/pages/Students/index.tsx | 41 ++-- 14 files changed, 320 insertions(+), 153 deletions(-) create mode 100644 apps/admin/src/hooks/useDownload.ts diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index 1c221a7..eb85fd6 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -13,6 +13,7 @@ import type { ThoughtChainItemType } from '@ant-design/x'; import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown'; import { Alert, Button, Flex, Input, Space, Typography } from 'antd'; import { useUserStore } from '../../store/user/userStore'; +import { message } from '../../ui/app-message'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; @@ -104,6 +105,24 @@ async function openSourceUrl(item: { url?: string }): Promise { window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); } +/** 打开附件,失败时给出明确提示(避免「点了没反应」) */ +async function handleOpenAttachment(attachment: AiAttachment): Promise { + try { + await openAttachment(attachment); + } catch (error: unknown) { + message.error(error instanceof Error ? error.message : '附件打开失败,请重试'); + } +} + +/** 打出来源链接,失败时给出明确提示 */ +async function handleOpenSource(item: { url?: string }): Promise { + try { + await openSourceUrl(item); + } catch (error: unknown) { + message.error(error instanceof Error ? error.message : '来源打开失败,请重试'); + } +} + function ToolChain({ tools }: { tools: AiToolRun[] }) { const items = useMemo( () => @@ -228,7 +247,7 @@ export const AiMessageContent: React.FC = ({ byte={attachment.size} size="small" icon={attachmentIcon(attachment)} - onClick={() => void openAttachment(attachment)} + onClick={() => void handleOpenAttachment(attachment)} /> )); @@ -352,7 +371,7 @@ export const AiMessageContent: React.FC = ({ void openSourceUrl(item as { url?: string })} + onClick={(item) => void handleOpenSource(item as { url?: string })} /> )} {(message.forms ?? []).map((form) => ( diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index 69b22f6..f37e3b5 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -80,6 +80,8 @@ export async function authenticatedFetch( } const response = await fetch(requestInput, { ...requestInit, headers }); if (response.status === 401) { + // 提示由登录页读取展示:直接弹 toast 会被跳转销毁 + sessionStorage.setItem('login_expired_hint', '1'); useUserStore.getState().logout(); usePermissionStore.getState().clearPermissions(); window.location.href = '/login'; diff --git a/apps/admin/src/hooks/useDownload.ts b/apps/admin/src/hooks/useDownload.ts new file mode 100644 index 0000000..5a3842a --- /dev/null +++ b/apps/admin/src/hooks/useDownload.ts @@ -0,0 +1,46 @@ +import { useCallback, useRef, useState } from 'react'; +import { downloadBlob } from '../utils/download'; +import { message } from '../ui/app-message'; + +export interface DownloadOptions { + /** 成功提示文案;默认「下载成功」 */ + successMsg?: string; + /** 失败提示文案;默认使用接口返回的错误信息 */ + errorMsg?: string; +} + +/** + * 统一下载/导出状态:防重复点击 + 成功/失败反馈。 + * + * 用法: + * const { downloading, run } = useDownload(); + * + + )} + + )} ); diff --git a/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx b/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx index 1651766..30e01c8 100644 --- a/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx +++ b/apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx @@ -27,6 +27,8 @@ export const OccupanciesToolbar: React.FC<{ onDepositAmountChange: (value: number) => void; onDownloadTemplate: () => void; onExport: () => void; + templateLoading?: boolean; + exportLoading?: boolean; }> = ({ viewMode, onChangeViewMode, @@ -42,6 +44,8 @@ export const OccupanciesToolbar: React.FC<{ onDepositAmountChange, onDownloadTemplate, onExport, + templateLoading, + exportLoading, }) => { return (
@@ -130,13 +134,19 @@ export const OccupanciesToolbar: React.FC<{ } + loading={templateLoading} onClick={onDownloadTemplate} > 下载模板 ) : null} {viewMode !== 'archived' ? ( - } onClick={onExport}> + } + loading={exportLoading} + onClick={onExport} + > 导出记录 ) : null} diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 76c65b1..ad9555c 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -25,6 +25,7 @@ import { useOccupancyMutations } from './useOccupancyMutations'; import { QueryErrorState } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; +import { useDownload } from '../../hooks/useDownload'; interface StudentLookupRow { id: number; @@ -452,6 +453,9 @@ const OccupanciesPage: React.FC = () => { [selectedRowKeys, viewMode], ); + const { downloading: templateDownloading, run: runTemplateDownload } = useDownload(); + const { downloading: exportDownloading, run: runExportDownload } = useDownload(); + return (
{ depositAmount={depositAmount} onDepositAmountChange={setDepositAmount} onDownloadTemplate={() => { - void import('../../utils/download').then(({ downloadBlob }) => - downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() => message.error('下载失败')), - ); - }} - onExport={() => { - void import('../../utils/download').then(({ downloadBlob }) => { - const params = viewMode === 'active' ? '?active=true' : ''; - const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx'; - downloadBlob('/occupancies/export' + params, filename).catch(() => message.error('导出失败')); + void runTemplateDownload('/occupancies/template', '入住名单导入模板.xlsx', { + successMsg: '模板已下载', + errorMsg: '下载失败', }); }} + templateLoading={templateDownloading} + onExport={() => { + const params = viewMode === 'active' ? '?active=true' : ''; + const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx'; + void runExportDownload('/occupancies/export' + params, filename, { + successMsg: '入住记录已导出', + errorMsg: '导出失败', + }); + }} + exportLoading={exportDownloading} /> {nextStepHint === 'billing' && ( ) => void; onDownloadTemplate: () => void; onExport: () => void; + templateLoading?: boolean; + exportLoading?: boolean; } export const RoomsToolbar: React.FC = ({ @@ -63,6 +65,8 @@ export const RoomsToolbar: React.FC = ({ onImport, onDownloadTemplate, onExport, + templateLoading, + exportLoading, }) => { return (
@@ -185,11 +189,17 @@ export const RoomsToolbar: React.FC = ({ } + loading={templateLoading} onClick={onDownloadTemplate} > 下载模板 - } onClick={onExport}> + } + loading={exportLoading} + onClick={onExport} + > 导出列表 diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 1aefcd8..3f8b47c 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -9,10 +9,10 @@ import { } from 'antd'; import type { UploadRequestOption } from '@rc-component/upload/lib/interface'; import api from '../../api'; -import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; +import { useDownload } from '../../hooks/useDownload'; import { selectArchiveRecords } from '../archive-view'; import { getErrorMessage } from '../../utils/error'; import { QueryErrorState } from '../../components/QueryState'; @@ -391,15 +391,22 @@ const RoomsPage: React.FC = () => { } }; + const { downloading: templateDownloading, run: runTemplateDownload } = useDownload(); + const { downloading: exportDownloading, run: runExportDownload } = useDownload(); + const handleDownloadTemplate = () => { - downloadBlob('/rooms/template', '房间导入模板.xlsx').catch(() => message.error('下载失败')); + void runTemplateDownload('/rooms/template', '房间导入模板.xlsx', { + successMsg: '模板已下载', + errorMsg: '下载失败', + }); }; const handleExport = () => { const params = showArchived ? '?includeArchived=true' : ''; - downloadBlob('/rooms/export' + params, '房间列表.xlsx').catch(() => - message.error('导出失败'), - ); + void runExportDownload('/rooms/export' + params, '房间列表.xlsx', { + successMsg: '房间列表已导出', + errorMsg: '导出失败', + }); }; const columns = useRoomColumns({ @@ -470,7 +477,9 @@ const RoomsPage: React.FC = () => { } }} onDownloadTemplate={handleDownloadTemplate} + templateLoading={templateDownloading} onExport={handleExport} + exportLoading={exportDownloading} /> {isError ? ( diff --git a/apps/admin/src/pages/Students/StudentsToolbar.tsx b/apps/admin/src/pages/Students/StudentsToolbar.tsx index c21053b..3f2af74 100644 --- a/apps/admin/src/pages/Students/StudentsToolbar.tsx +++ b/apps/admin/src/pages/Students/StudentsToolbar.tsx @@ -58,6 +58,8 @@ export interface StudentsToolbarProps { onUpdateImport: UploadProps['customRequest']; onDownloadTemplate: () => void; onExport: () => void; + templateLoading?: boolean; + exportLoading?: boolean; } export const StudentsToolbar: React.FC = ({ @@ -94,6 +96,8 @@ export const StudentsToolbar: React.FC = ({ onUpdateImport, onDownloadTemplate, onExport, + templateLoading, + exportLoading, }) => { const { modal } = App.useApp(); return ( @@ -273,6 +277,7 @@ export const StudentsToolbar: React.FC = ({ } + loading={templateLoading} onClick={onDownloadTemplate} > 下载模板 @@ -280,6 +285,7 @@ export const StudentsToolbar: React.FC = ({ } + loading={exportLoading} onClick={onExport} > 导出名单 diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 6bcb9d6..cdf59b6 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -6,8 +6,8 @@ import { } from 'antd'; import api from '../../api'; import { usePermission } from '../../hooks/usePermission'; -import { useUserStore } from '../../store/user/userStore'; import { useVisibleRefetch } from '../../hooks/usePageVisible'; +import { useDownload } from '../../hooks/useDownload'; import { QueryErrorState } from '../../components/QueryState'; import { NextStepHint } from '../../components/NextStepHint'; import { selectArchiveRecords } from '../archive-view'; @@ -312,26 +312,14 @@ const StudentsPage: React.FC = () => { [saveCellMutation], ); - const downloadApiFile = async (path: string, filename: string, errorMessage = '下载失败') => { - const baseURL = import.meta.env.PROD - ? '/api' - : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; - const token = useUserStore.getState().token; - try { - const res = await fetch(`${baseURL}${path}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - } catch (error: unknown) { - console.error(errorMessage, error); - message.error(errorMessage); - } + const { downloading: templateDownloading, run: runTemplateDownload } = useDownload(); + const { downloading: exportDownloading, run: runExportDownload } = useDownload(); + + const handleDownloadTemplate = () => { + void runTemplateDownload('/students/template', '学生导入模板.xlsx', { + successMsg: '模板已下载', + errorMsg: '模板下载失败', + }); }; const handleArchive = useCallback( @@ -423,10 +411,6 @@ const StudentsPage: React.FC = () => { } }; - const handleDownloadTemplate = () => { - void downloadApiFile('/students/template', '学生导入模板.xlsx'); - }; - const handleCreateStudentsImport = async ({ file, onSuccess, onError }: any) => { const formData = new FormData(); formData.append('file', file as File); @@ -490,7 +474,10 @@ const StudentsPage: React.FC = () => { if (filterClassId) params.set('classId', String(filterClassId)); if (filterTeacherId) params.set('teacherId', String(filterTeacherId)); const query = params.toString() ? `?${params.toString()}` : ''; - void downloadApiFile(`/students/export${query}`, '学生名单.xlsx', '导出失败'); + void runExportDownload(`/students/export${query}`, '学生名单.xlsx', { + successMsg: '名单已导出', + errorMsg: '导出失败', + }); }; const columns = useMemo( @@ -580,7 +567,9 @@ const StudentsPage: React.FC = () => { onCreateImport={handleCreateStudentsImport} onUpdateImport={handleUpdateExistingStudentsImport} onDownloadTemplate={handleDownloadTemplate} + templateLoading={templateDownloading} onExport={handleExport} + exportLoading={exportDownloading} /> {nextStepHint === 'class' && (