feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useImmer } from 'use-immer';
import {
Table,
Button,
App,
Modal,
Form,
Select,
@@ -9,39 +9,32 @@ import {
InputNumber,
Input,
Space,
Tag,
Popconfirm,
Upload,
Tooltip,
Empty,
} from 'antd';
import {
PlusOutlined,
UploadOutlined,
FileTextOutlined,
StopOutlined,
CheckOutlined,
} from '@ant-design/icons';
import { PlusOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { getErrorMessage } from '../../utils/error';
import { validateResponse } from '../../utils/validate';
import { classroomsSchema, organizationsSchema, rentalsSchema } from '../../api/schemas';
import { RentalTable } from './RentalTable';
interface UnavailableDatesResponse {
dates: string[];
}
export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) =>
`${classroomId}:${date.format('YYYY-MM')}`;
const ClassroomRentalsPage: React.FC = () => {
const { modal } = App.useApp();
const { hasPermission, hasAnyPermission } = usePermission();
const [data, setData] = useState<any[]>([]);
const [classrooms, setClassrooms] = useState<any[]>([]);
const [organizations, setOrganizations] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const canPurgeRental = hasPermission('rental:purge');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form] = Form.useForm();
@@ -49,12 +42,110 @@ const ClassroomRentalsPage: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string | undefined>();
const [searchText, setSearchText] = useState('');
const [saving, setSaving] = useState(false);
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set());
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
const unavailableRequestVersion = useRef(0);
const [unavailableDatesLoading, setUnavailableDatesLoading] = useState(false);
const selectedClassroomId = Form.useWatch('classroomId', form);
const {
data = [],
isLoading,
isFetching,
} = useQuery<any[]>({
queryKey: ['classroom-rentals', filterMonth],
queryFn: async () => {
try {
const params: any = {};
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
params.includeEnded = true;
return validateResponse<any[]>(
rentalsSchema,
await api.get('/classroom-rentals', { params }),
);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
},
});
const {
data: meta = { classrooms: [], organizations: [] },
} = useQuery<{ classrooms: any[]; organizations: any[] }>({
queryKey: ['classroom-rentals', 'meta'],
enabled: hasAnyPermission('rental:create', 'rental:edit'),
queryFn: async () => {
try {
const [cr, tn]: any = await Promise.all([
api.get('/classrooms'),
api.get('/organizations', { params: { scope: 'all' } }),
]);
return {
classrooms: validateResponse<any[]>(classroomsSchema, cr),
organizations: validateResponse<any[]>(organizationsSchema, tn),
};
} catch (e: any) {
message.error(e?.message || '加载教室列表失败');
return { classrooms: [], organizations: [] };
}
},
});
const classrooms = meta.classrooms;
const organizations = meta.organizations;
const loading = isLoading || isFetching;
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
editing
? api.put(`/classroom-rentals/${editing.id}`, payload)
: api.post('/classroom-rentals', payload),
{
invalidate: [['classroom-rentals']],
onError: (error: unknown) => {
const e = error as {
conflicts?: Array<{ organizationName?: string; startDate?: string; endDate?: string }>;
};
if (e?.conflicts?.length) {
const list = e.conflicts
.map((c) => `${c.organizationName}(${c.startDate}~${c.endDate})`)
.join('、');
message.error(`时间段冲突:${list}`);
} else {
message.error(getErrorMessage(error, '操作失败'));
}
},
},
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
api.put(`/classroom-rentals/${record.id}`, { [field]: value }),
{ invalidate: [['classroom-rentals']] },
);
const deleteMutation = useApiMutation(
async (id: number) => api.delete(`/classroom-rentals/${id}`),
{ invalidate: [['classroom-rentals']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/classroom-rentals/${id}/permanent`),
{ invalidate: [['classroom-rentals']] },
);
const actionMutation = useApiMutation(
async ({ id, action }: { id: number; action: 'cancel' | 'end' }) =>
api.put(`/classroom-rentals/${id}/${action}`),
{ invalidate: [['classroom-rentals']] },
);
const deleteContractMutation = useApiMutation(
async (id: number) => api.delete(`/classroom-rentals/${id}/contract`),
{ invalidate: [['classroom-rentals']] },
);
const uploadContractMutation = useApiMutation(
async ({ id, formData }: { id: number; formData: FormData }) =>
api.post(`/classroom-rentals/${id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
{ invalidate: [['classroom-rentals']] },
);
const filteredData = useMemo(() => {
return data.filter((r: any) => {
if (filterStatus && r.effectiveStatus !== filterStatus) return false;
@@ -66,40 +157,6 @@ const ClassroomRentalsPage: React.FC = () => {
});
}, [data, searchText, filterStatus]);
const fetchData = async () => {
setLoading(true);
try {
const params: any = {};
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
params.includeEnded = true;
const res: any = await api.get('/classroom-rentals', { params });
setData(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setLoading(false);
};
const fetchMeta = async () => {
try {
const [cr, tn]: any = await Promise.all([
api.get('/classrooms'),
api.get('/organizations', { params: { scope: 'all' } }),
]);
setClassrooms(cr);
setOrganizations(tn);
} catch (e: any) {
message.error(e?.message || '加载教室列表失败');
}
};
useEffect(() => {
if (hasAnyPermission('rental:create', 'rental:edit')) fetchMeta();
}, [hasAnyPermission]);
useEffect(() => {
fetchData();
}, [filterMonth]);
const resetUnavailableDates = () => {
unavailableRequestVersion.current += 1;
loadedUnavailableMonths.current.clear();
@@ -127,10 +184,8 @@ const ClassroomRentalsPage: React.FC = () => {
},
);
if (requestVersion !== unavailableRequestVersion.current) return;
setUnavailableDates((current) => {
const next = new Set(current);
response.dates.forEach((item) => next.add(item));
return next;
setUnavailableDates((draft) => {
response.dates.forEach((item) => draft.add(item));
});
} catch (e: any) {
loadedUnavailableMonths.current.delete(key);
@@ -190,75 +245,85 @@ const ClassroomRentalsPage: React.FC = () => {
notes: values.notes,
};
try {
if (editing) {
await api.put(`/classroom-rentals/${editing.id}`, payload);
message.success('更新成功');
} else {
await api.post('/classroom-rentals', payload);
message.success('创建成功');
}
await saveMutation.mutateAsync(payload);
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) {
if (e?.conflicts?.length) {
const list = e.conflicts
.map((c: any) => `${c.organizationName}(${c.startDate}~${c.endDate})`)
.join('、');
message.error(`时间段冲突:${list}`);
} else {
message.error(e?.message || '操作失败');
}
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: any, field: string, value: unknown) => {
await api.put(`/classroom-rentals/${record.id}`, { [field]: value });
message.success('已保存');
await fetchData();
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}`);
await deleteMutation.mutateAsync(id);
message.success('已归档');
fetchData();
} catch (e: any) {
message.error(e?.message || '归档失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (id: number, name: string) => {
modal.confirm({
title: `永久删除租赁订单(${name}`,
content: '删除后不可恢复,排课与合同文件将被清除(存在考勤记录时将无法删除)。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handleRentalAction = async (id: number, action: 'cancel' | 'end') => {
try {
await api.put(`/classroom-rentals/${id}/${action}`);
await actionMutation.mutateAsync({ id, action });
message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
} catch {
} catch (e) {
console.error('下载合同失败', e);
message.error('下载失败(可能文件已丢失)');
}
};
const handleDeleteContract = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}/contract`);
await deleteContractMutation.mutateAsync(id);
message.success('合同已移除');
fetchData();
} catch (e: any) {
message.error(e?.message || '移除失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleUploadContract = async (id: number, formData: FormData) => {
return uploadContractMutation.mutateAsync({ id, formData });
};
const openEdit = (record: any) => {
setEditing(record);
resetUnavailableDates();
@@ -280,271 +345,6 @@ const ClassroomRentalsPage: React.FC = () => {
);
};
const columns = useMemo(
() => [
{
title: '教室',
width: 120,
dataIndex: 'classroom',
render: (c: any, r: any) => (
<EditableCell
value={r.classroomId}
editor="select"
options={classrooms
.filter((item) => item.status !== 'archived')
.map((item) => ({
value: item.id,
label: item.building ? `${item.building} · ${item.name}` : item.name,
}))}
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
required
onSave={(next) => saveCell(r, 'classroomId', next)}
>
{c ? (
<span>
{c.building ? `${c.building} · ` : ''}
{c.name}
</span>
) : (
'-'
)}
</EditableCell>
),
},
{
title: '承租机构',
width: 100,
dataIndex: 'lesseeOrganization',
render: (t: any, r: any) => (
<EditableCell
value={r.lesseeOrganizationId}
editor="select"
options={organizations
.filter((item) => item.status !== 'archived')
.map((item) => ({ value: item.id, label: item.name }))}
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
required
onSave={(next) => saveCell(r, 'lesseeOrganizationId', next)}
>
{t ? (
<Tag
color={t.color}
style={{ background: t.color, color: '#fff', borderColor: t.color }}
>
{t.name}
</Tag>
) : (
'-'
)}
</EditableCell>
),
},
{
title: '开始日期',
dataIndex: 'startDate',
width: 110,
render: (v: string, r: any) => (
<EditableCell
value={v}
editor="date"
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
required
onSave={(next) => saveCell(r, 'startDate', next)}
>
{v}
</EditableCell>
),
},
{
title: '结束日期',
dataIndex: 'endDate',
width: 110,
render: (v: string, r: any) => (
<EditableCell
value={v}
editor="date"
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
required
onSave={(next) => saveCell(r, 'endDate', next)}
>
{v}
</EditableCell>
),
},
{
title: '时长',
width: 80,
render: (_: any, r: any) => {
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
return `${d}`;
},
},
{
title: '日租金',
dataIndex: 'dailyRate',
width: 100,
render: (v: any, r: any) => (
<EditableCell
value={v}
editor="money"
min={0.01}
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
onSave={(next) => saveCell(r, 'dailyRate', next)}
>
{v ? `¥${v}` : '-'}
</EditableCell>
),
},
{
title: '总额',
dataIndex: 'totalAmount',
width: 100,
render: (v: any, r: any) => (
<EditableCell
value={v}
editor="money"
min={0.01}
permission="rental:edit"
disabled={r.effectiveStatus !== 'active'}
onSave={(next) => saveCell(r, 'totalAmount', next)}
>
{v ? `¥${v}` : '-'}
</EditableCell>
),
},
{
title: '状态',
dataIndex: 'effectiveStatus',
width: 90,
render: (status: string) => {
const config: Record<string, { text: string; color: string }> = {
active: { text: '进行中', color: 'green' },
ended: { text: '已结束', color: 'default' },
cancelled: { text: '已取消', color: 'red' },
};
return <Tag color={config[status]?.color}>{config[status]?.text || status}</Tag>;
},
},
{
title: '合同',
width: 120,
dataIndex: 'contractPath',
render: (v: string, r: any) =>
v ? (
<Space>
<Tooltip title={r.contractOriginalName}>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => handleDownloadContract(r.id, r.contractOriginalName)}
>
</Button>
</Tooltip>
{hasPermission('rental:edit') ? (
<Popconfirm title="移除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<StopOutlined />} aria-label="移除合同文件" />
</Popconfirm>
) : null}
</Space>
) : hasPermission('rental:edit') ? (
<Upload
accept="application/pdf"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
if (file.size > 10 * 1024 * 1024) {
message.error('文件不能超过 10MB');
onError?.(new Error('size'));
return;
}
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/classroom-rentals/${r.id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success('合同已上传');
onSuccess?.({});
fetchData();
} catch (e: any) {
message.error(e?.message || '上传失败');
onError?.(e);
}
}}
>
<Button size="small" icon={<UploadOutlined />}>
PDF
</Button>
</Upload>
) : (
'-'
),
},
{
title: '操作',
width: 150,
render: (_: any, record: any) => (
<Space>
{record.effectiveStatus === 'active' && (
<>
<PermissionButton
permission="rental:edit"
size="small"
onClick={() => openEdit(record)}
>
</PermissionButton>
<Popconfirm
title="确定取消该租赁?"
onConfirm={() => handleRentalAction(record.id, 'cancel')}
>
<PermissionButton
permission="rental:edit"
size="small"
danger
icon={<StopOutlined />}
>
</PermissionButton>
</Popconfirm>
{!dayjs(record.startDate).isAfter(dayjs(), 'day') && (
<Popconfirm
title="确定今天结束该租赁?"
onConfirm={() => handleRentalAction(record.id, 'end')}
>
<PermissionButton
permission="rental:edit"
size="small"
icon={<CheckOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</>
)}
{record.effectiveStatus !== 'active' && (
<Popconfirm
title="确定归档该租赁订单?合同文件会保留。"
onConfirm={() => handleDelete(record.id)}
>
<PermissionButton permission="rental:delete" size="small" danger>
</PermissionButton>
</Popconfirm>
)}
</Space>
),
},
],
[classrooms, organizations, hasPermission],
);
return (
<div>
<div
@@ -601,19 +401,21 @@ const ClassroomRentalsPage: React.FC = () => {
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
<RentalTable
data={filteredData}
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
scroll={{ x: 1200 }}
classrooms={classrooms}
organizations={organizations}
canPurgeRental={canPurgeRental}
hasPermission={hasPermission}
onSaveCell={saveCell}
onEdit={openEdit}
onAction={handleRentalAction}
onArchive={handleDelete}
onPurge={handlePurge}
onDownloadContract={handleDownloadContract}
onDeleteContract={handleDeleteContract}
onUploadContract={handleUploadContract}
/>
<Modal
title={editing ? '编辑租赁' : '新增租赁'}