fix(admin): 通知分页、登录过期提示、附件打开反馈与导出统一 loading
- 通知中心改为游标分页 + 加载更多,历史通知不再被 50 条上限截断; 加载失败显示错误态与重试 - AI 流式请求 401 被登出时,登录页提示"登录已过期",不再无声踢出 - AI 附件/引用来源打开失败时给出明确错误提示,不再"点了没反应" - 新增 useDownload hook:统一导出/下载的防重复、loading 与成功/失败反馈; 接入学生/账单/房间/入住/费用五个页面的模板下载与导出按钮 - 学生页移除不检查响应状态的私有下载实现,统一走 downloadBlob
This commit is contained in:
@@ -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<void> {
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}
|
||||
|
||||
/** 打开附件,失败时给出明确提示(避免「点了没反应」) */
|
||||
async function handleOpenAttachment(attachment: AiAttachment): Promise<void> {
|
||||
try {
|
||||
await openAttachment(attachment);
|
||||
} catch (error: unknown) {
|
||||
message.error(error instanceof Error ? error.message : '附件打开失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
/** 打出来源链接,失败时给出明确提示 */
|
||||
async function handleOpenSource(item: { url?: string }): Promise<void> {
|
||||
try {
|
||||
await openSourceUrl(item);
|
||||
} catch (error: unknown) {
|
||||
message.error(error instanceof Error ? error.message : '来源打开失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
function ToolChain({ tools }: { tools: AiToolRun[] }) {
|
||||
const items = useMemo<ThoughtChainItemType[]>(
|
||||
() =>
|
||||
@@ -228,7 +247,7 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
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<AiMessageContentProps> = ({
|
||||
<Sources
|
||||
items={sourceItems}
|
||||
title="引用来源"
|
||||
onClick={(item) => void openSourceUrl(item as { url?: string })}
|
||||
onClick={(item) => void handleOpenSource(item as { url?: string })}
|
||||
/>
|
||||
)}
|
||||
{(message.forms ?? []).map((form) => (
|
||||
|
||||
@@ -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';
|
||||
|
||||
46
apps/admin/src/hooks/useDownload.ts
Normal file
46
apps/admin/src/hooks/useDownload.ts
Normal file
@@ -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();
|
||||
* <Button loading={downloading} onClick={() => run('/students/export', '名单.xlsx')}>
|
||||
*/
|
||||
export function useDownload() {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
const run = useCallback(
|
||||
async (endpoint: string, filename: string, options?: DownloadOptions) => {
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setDownloading(true);
|
||||
try {
|
||||
await downloadBlob(endpoint, filename);
|
||||
message.success(options?.successMsg ?? '下载成功');
|
||||
} catch (error: unknown) {
|
||||
message.error(
|
||||
options?.errorMsg ?? (error instanceof Error ? error.message : '下载失败,请重试'),
|
||||
);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setDownloading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { downloading, run };
|
||||
}
|
||||
|
||||
export default useDownload;
|
||||
@@ -27,7 +27,7 @@ import PermissionButton from '../../components/PermissionButton';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { NextStepHint } from '../../components/NextStepHint';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { useDownload } from '../../hooks/useDownload';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
|
||||
import { newOperationId } from '../../utils/operation-id';
|
||||
@@ -233,11 +233,13 @@ const BillsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const { downloading: exportExcelDownloading, run: runExportExcel } = useDownload();
|
||||
|
||||
const handleExportExcel = () => {
|
||||
downloadBlob('/bills/export/excel', `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`).then(
|
||||
() => message.success('Excel 导出成功'),
|
||||
() => message.error('导出失败'),
|
||||
);
|
||||
void runExportExcel(`/bills/export/excel`, `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, {
|
||||
successMsg: 'Excel 导出成功',
|
||||
errorMsg: '导出失败',
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportPdf = useCallback(async (billId: number) => {
|
||||
@@ -469,6 +471,7 @@ const BillsPage: React.FC = () => {
|
||||
<PermissionButton
|
||||
permission="bill:export-excel"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={exportExcelDownloading}
|
||||
onClick={handleExportExcel}
|
||||
>
|
||||
导出Excel
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface ExpenseTablePanelProps {
|
||||
onImport: (formData: FormData) => Promise<any>;
|
||||
onTemplateDownload: () => void;
|
||||
onExport?: () => void;
|
||||
templateLoading?: boolean;
|
||||
exportLoading?: boolean;
|
||||
onAddUtility?: () => void;
|
||||
}
|
||||
|
||||
@@ -98,6 +100,8 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
onImport,
|
||||
onTemplateDownload,
|
||||
onExport,
|
||||
templateLoading,
|
||||
exportLoading,
|
||||
onAddUtility,
|
||||
}) => {
|
||||
const isRoom = kind === 'room';
|
||||
@@ -417,6 +421,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
<PermissionButton
|
||||
permission="expense:view"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={templateLoading}
|
||||
onClick={onTemplateDownload}
|
||||
>
|
||||
{isRoom ? '下载水电费模板' : '下载模板'}
|
||||
@@ -426,6 +431,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
<PermissionButton
|
||||
permission="expense:view"
|
||||
icon={<ExportOutlined />}
|
||||
loading={exportLoading}
|
||||
onClick={onExport}
|
||||
>
|
||||
导出
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { App, Button, Form, Space, Tabs } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { useDownload } from '../../hooks/useDownload';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import {
|
||||
expenseLookupsSchema,
|
||||
@@ -44,6 +44,12 @@ const ExpensesPage: React.FC = () => {
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const expenseViewPolicy = archiveViewPolicy(showArchived ? 'archived' : 'active');
|
||||
|
||||
const { downloading: utilityTemplateDownloading, run: runUtilityTemplateDownload } =
|
||||
useDownload();
|
||||
const { downloading: personalTemplateDownloading, run: runPersonalTemplateDownload } =
|
||||
useDownload();
|
||||
const { downloading: personalExportDownloading, run: runPersonalExportDownload } = useDownload();
|
||||
|
||||
const {
|
||||
data: typeLookups = { typeOptions: [], personalTypeOptions: [], typeMap: {} },
|
||||
} = useQuery<{
|
||||
@@ -506,10 +512,12 @@ const ExpensesPage: React.FC = () => {
|
||||
onPurge={handlePurgeRoom}
|
||||
onImport={(formData) => mutations.importUtility.mutateAsync(formData)}
|
||||
onTemplateDownload={() => {
|
||||
void downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(
|
||||
() => message.error('下载失败'),
|
||||
);
|
||||
void runUtilityTemplateDownload('/expenses/utility/template', '水电费导入模板.xlsx', {
|
||||
successMsg: '模板已下载',
|
||||
errorMsg: '下载失败',
|
||||
});
|
||||
}}
|
||||
templateLoading={utilityTemplateDownloading}
|
||||
onAddUtility={() => setUtilityModal(true)}
|
||||
/>
|
||||
),
|
||||
@@ -547,15 +555,19 @@ const ExpensesPage: React.FC = () => {
|
||||
onPurge={handlePurgePersonal}
|
||||
onImport={(formData) => mutations.importPersonal.mutateAsync(formData)}
|
||||
onTemplateDownload={() => {
|
||||
void downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch(
|
||||
() => message.error('下载失败'),
|
||||
);
|
||||
void runPersonalTemplateDownload('/expenses/personal/template', '个人附加费导入模板.xlsx', {
|
||||
successMsg: '模板已下载',
|
||||
errorMsg: '下载失败',
|
||||
});
|
||||
}}
|
||||
templateLoading={personalTemplateDownloading}
|
||||
onExport={() => {
|
||||
downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() =>
|
||||
message.error('导出失败'),
|
||||
);
|
||||
void runPersonalExportDownload('/expenses/personal/export', '个人附加费导出.xlsx', {
|
||||
successMsg: '导出成功',
|
||||
errorMsg: '导出失败',
|
||||
});
|
||||
}}
|
||||
exportLoading={personalExportDownloading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Form, Input, Button, Card, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
@@ -18,6 +18,14 @@ const LoginPage: React.FC = () => {
|
||||
const clearPermissions = usePermissionStore((state) => state.clearPermissions);
|
||||
const writePermissions = usePermissionStore((state) => state.writePermissions);
|
||||
|
||||
// 会话过期被登出(如 AI 流式请求 401)后回到登录页时给出提示
|
||||
useEffect(() => {
|
||||
if (sessionStorage.getItem('login_expired_hint')) {
|
||||
sessionStorage.removeItem('login_expired_hint');
|
||||
message.warning('登录已过期,请重新登录');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onFinish = useCallback(
|
||||
async (values: any) => {
|
||||
clearPermissions();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { notificationsSchema } from '../../api/schemas';
|
||||
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, Grid, Select } from 'antd';
|
||||
@@ -14,10 +13,13 @@ import { useNavigate } from 'react-router';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { formatNotificationText } from '../../utils/notification-display';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface NotificationItem {
|
||||
id: number;
|
||||
type: string;
|
||||
@@ -65,31 +67,55 @@ const NotificationsPage: React.FC = () => {
|
||||
const isMobile = !screens.sm;
|
||||
const [filter, setFilter] = useState('all');
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: notifications = [], isLoading, isFetching } = useQuery<NotificationItem[]>({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return validateResponse<NotificationItem[]>(
|
||||
notificationsSchema,
|
||||
await api.get('/notifications?limit=50'),
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error('加载通知失败', e);
|
||||
message.error(e?.message || '加载通知失败');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
const loading = isLoading || isFetching;
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const loadPage = useCallback(async (after?: number) => {
|
||||
if (after === undefined) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
setError(false);
|
||||
try {
|
||||
const params =
|
||||
after !== undefined ? `?after=${after}&limit=${PAGE_SIZE}` : `?limit=${PAGE_SIZE}`;
|
||||
const res = validateResponse<NotificationItem[]>(
|
||||
notificationsSchema,
|
||||
await api.get(`/notifications${params}`),
|
||||
);
|
||||
setNotifications((prev) => (after === undefined ? res : [...prev, ...res]));
|
||||
setHasMore(res.length === PAGE_SIZE);
|
||||
} catch (e: any) {
|
||||
console.error('加载通知失败', e);
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage();
|
||||
}, [loadPage]);
|
||||
|
||||
const loadMore = () => {
|
||||
const last = notifications[notifications.length - 1];
|
||||
if (!last || loadingMore || loading) return;
|
||||
// 列表按 createdAt DESC 排序,最后一条 id 最小,作为下一页游标
|
||||
void loadPage(last.id);
|
||||
};
|
||||
|
||||
const handleClick = async (item: NotificationItem) => {
|
||||
if (!item.isRead) {
|
||||
try {
|
||||
await api.put(`/notifications/${item.id}/read`);
|
||||
queryClient.setQueryData<NotificationItem[]>(['notifications'], (prev) =>
|
||||
(prev ?? []).map((n) => (n.id === item.id ? { ...n, isRead: true } : n)),
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)),
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error('标记已读失败', e);
|
||||
@@ -102,9 +128,7 @@ const NotificationsPage: React.FC = () => {
|
||||
const handleMarkAll = async () => {
|
||||
try {
|
||||
await api.put('/notifications/read-all');
|
||||
queryClient.setQueryData<NotificationItem[]>(['notifications'], (prev) =>
|
||||
(prev ?? []).map((n) => ({ ...n, isRead: true })),
|
||||
);
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
|
||||
} catch (e: any) {
|
||||
console.error('全部已读失败', e);
|
||||
message.error(e?.message || '操作失败');
|
||||
@@ -141,76 +165,91 @@ const NotificationsPage: React.FC = () => {
|
||||
className="notifications-filter"
|
||||
/>
|
||||
)}
|
||||
<Spin spinning={loading}>
|
||||
{filtered.length === 0 ? (
|
||||
<Empty description="暂无通知" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={filtered}
|
||||
renderItem={(item) => {
|
||||
const meta = typeMap[item.type] || { label: item.type, icon: <BellOutlined /> };
|
||||
return (
|
||||
<List.Item
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`通知: ${item.title}`}
|
||||
onClick={() => handleClick(item)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick(item);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '16px 0',
|
||||
backgroundColor: item.isRead ? 'transparent' : '#f0f7ff',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{meta.icon}
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<Space wrap size={[8, 2]}>
|
||||
<Typography.Text strong={!item.isRead} style={{ fontSize: 15 }}>
|
||||
{formatNotificationText(item.title)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{timeAgo(item.createdAt)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
item.content && (
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 1 }}
|
||||
style={{ marginBottom: 0 }}
|
||||
{error && !loading ? (
|
||||
<QueryErrorState
|
||||
title="通知加载失败"
|
||||
description="请检查网络后重试。"
|
||||
onRetry={() => void loadPage()}
|
||||
/>
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
{filtered.length === 0 ? (
|
||||
<Empty description="暂无通知" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={filtered}
|
||||
renderItem={(item) => {
|
||||
const meta = typeMap[item.type] || { label: item.type, icon: <BellOutlined /> };
|
||||
return (
|
||||
<List.Item
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`通知: ${item.title}`}
|
||||
onClick={() => handleClick(item)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick(item);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '16px 0',
|
||||
backgroundColor: item.isRead ? 'transparent' : '#f0f7ff',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{formatNotificationText(item.content)}
|
||||
</Typography.Paragraph>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
{meta.icon}
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<Space wrap size={[8, 2]}>
|
||||
<Typography.Text strong={!item.isRead} style={{ fontSize: 15 }}>
|
||||
{formatNotificationText(item.title)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{timeAgo(item.createdAt)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
item.content && (
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 1 }}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{formatNotificationText(item.content)}
|
||||
</Typography.Paragraph>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filtered.length > 0 && hasMore && (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0' }}>
|
||||
<Button loading={loadingMore} onClick={loadMore}>
|
||||
加载更多
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="responsive-toolbar">
|
||||
@@ -130,13 +134,19 @@ export const OccupanciesToolbar: React.FC<{
|
||||
<PermissionButton
|
||||
permission="occupancy:view"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={templateLoading}
|
||||
onClick={onDownloadTemplate}
|
||||
>
|
||||
下载模板
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{viewMode !== 'archived' ? (
|
||||
<PermissionButton permission="occupancy:view" icon={<ExportOutlined />} onClick={onExport}>
|
||||
<PermissionButton
|
||||
permission="occupancy:view"
|
||||
icon={<ExportOutlined />}
|
||||
loading={exportLoading}
|
||||
onClick={onExport}
|
||||
>
|
||||
导出记录
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<Alert
|
||||
@@ -513,17 +517,21 @@ const OccupanciesPage: React.FC = () => {
|
||||
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' && (
|
||||
<NextStepHint
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface RoomsToolbarProps {
|
||||
onImport: (options: UploadRequestOption<{ message?: string }>) => void;
|
||||
onDownloadTemplate: () => void;
|
||||
onExport: () => void;
|
||||
templateLoading?: boolean;
|
||||
exportLoading?: boolean;
|
||||
}
|
||||
|
||||
export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
|
||||
@@ -63,6 +65,8 @@ export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
|
||||
onImport,
|
||||
onDownloadTemplate,
|
||||
onExport,
|
||||
templateLoading,
|
||||
exportLoading,
|
||||
}) => {
|
||||
return (
|
||||
<div className="responsive-toolbar">
|
||||
@@ -185,11 +189,17 @@ export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
|
||||
<PermissionButton
|
||||
permission="room:view"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={templateLoading}
|
||||
onClick={onDownloadTemplate}
|
||||
>
|
||||
下载模板
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="room:view" icon={<ExportOutlined />} onClick={onExport}>
|
||||
<PermissionButton
|
||||
permission="room:view"
|
||||
icon={<ExportOutlined />}
|
||||
loading={exportLoading}
|
||||
onClick={onExport}
|
||||
>
|
||||
导出列表
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface StudentsToolbarProps {
|
||||
onUpdateImport: UploadProps['customRequest'];
|
||||
onDownloadTemplate: () => void;
|
||||
onExport: () => void;
|
||||
templateLoading?: boolean;
|
||||
exportLoading?: boolean;
|
||||
}
|
||||
|
||||
export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
||||
@@ -94,6 +96,8 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
||||
onUpdateImport,
|
||||
onDownloadTemplate,
|
||||
onExport,
|
||||
templateLoading,
|
||||
exportLoading,
|
||||
}) => {
|
||||
const { modal } = App.useApp();
|
||||
return (
|
||||
@@ -273,6 +277,7 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={templateLoading}
|
||||
onClick={onDownloadTemplate}
|
||||
>
|
||||
下载模板
|
||||
@@ -280,6 +285,7 @@ export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
||||
<PermissionButton
|
||||
permission="student:export"
|
||||
icon={<ExportOutlined />}
|
||||
loading={exportLoading}
|
||||
onClick={onExport}
|
||||
>
|
||||
导出名单
|
||||
|
||||
@@ -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' && (
|
||||
<NextStepHint
|
||||
|
||||
Reference in New Issue
Block a user