chore: 清理 lint 警告(290+ 条 no-explicit-any) #82
@@ -34,7 +34,7 @@ export function LiteMermaid({ children }: LiteMermaidProps) {
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [children]);
|
||||
}, [children, isMounted]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip } from 'antd';
|
||||
import { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip, type MenuProps } from 'antd';
|
||||
import { BrandLogo } from '../components/BrandLogo';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
@@ -257,7 +257,7 @@ const MainLayout: React.FC = () => {
|
||||
setOpenKeys(keys);
|
||||
}, [setOpenKeys]);
|
||||
|
||||
const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => {
|
||||
const transformToMenuItems = useCallback((items: AppMenuItem[]): MenuProps['items'] => {
|
||||
return items.map((item) => ({
|
||||
key: item.key,
|
||||
icon: item.icon ? iconMap[item.icon] : undefined,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, Empty, Input, Spin, Table } from 'antd';
|
||||
import { Button, Card, Empty, Input, Spin, Table, type TableProps } from 'antd';
|
||||
import { ExportOutlined } from '@ant-design/icons';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ export const AttendanceAdminWorkspace: React.FC<{
|
||||
sortAttendanceRecords: (items: readonly AttendanceRecordItem[]) => AttendanceRecordItem[];
|
||||
sessionMap: Record<string, string>;
|
||||
records: AttendanceRecordItem[];
|
||||
columns: any[];
|
||||
columns: TableProps<AttendanceRecordItem>['columns'];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
|
||||
@@ -37,6 +37,33 @@ import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { billsSchema } from '../../api/schemas';
|
||||
|
||||
interface BillItem {
|
||||
id: number;
|
||||
expenseType: string;
|
||||
description?: string | null;
|
||||
days: number;
|
||||
totalRoomDays: number;
|
||||
roomTotalAmount: number;
|
||||
studentAmount: number;
|
||||
}
|
||||
|
||||
interface BillRow {
|
||||
id: number;
|
||||
status?: string;
|
||||
periodStart?: string | null;
|
||||
periodEnd?: string | null;
|
||||
sharedAmount?: number | string | null;
|
||||
personalAmount?: number | string | null;
|
||||
totalAmount?: number | string | null;
|
||||
paidAmount?: number | string | null;
|
||||
outstandingAmount?: number | string | null;
|
||||
walletBalance?: number | string | null;
|
||||
generatedAt?: string | null;
|
||||
expenseType?: string | null;
|
||||
student?: { name?: string | null } | null;
|
||||
items?: BillItem[];
|
||||
}
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
unpaid: { text: '待支付', color: 'orange' },
|
||||
partially_paid: { text: '部分支付', color: 'gold' },
|
||||
@@ -58,7 +85,7 @@ const BillsPage: React.FC = () => {
|
||||
const { hasPermission } = usePermission();
|
||||
const canPurgeBill = hasPermission('bill:purge');
|
||||
const [generateModal, setGenerateModal] = useState(false);
|
||||
const [detailModal, setDetailModal] = useState<any>(null);
|
||||
const [detailModal, setDetailModal] = useState<BillRow | null>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<number[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
@@ -76,13 +103,13 @@ const BillsPage: React.FC = () => {
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
} = useQuery<BillRow[]>({
|
||||
queryKey: ['bills', filterStatus, filterExpenseType],
|
||||
queryFn: async () => {
|
||||
const params: Record<string, string | undefined> = {};
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterExpenseType) params.expenseType = filterExpenseType;
|
||||
return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params }));
|
||||
return validateResponse<BillRow[]>(billsSchema, await api.get('/bills', { params }));
|
||||
},
|
||||
});
|
||||
const loading = isLoading || isFetching;
|
||||
@@ -113,7 +140,7 @@ const BillsPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const filteredBills = useMemo(() => {
|
||||
return bills.filter((b: any) => {
|
||||
return bills.filter((b: BillRow) => {
|
||||
if (searchText) {
|
||||
const s = searchText.toLowerCase();
|
||||
const matchName = b.student?.name?.toLowerCase().includes(s);
|
||||
@@ -129,10 +156,10 @@ const BillsPage: React.FC = () => {
|
||||
const values = await generateForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const res: any = await generateMutation.mutateAsync({
|
||||
const res = (await generateMutation.mutateAsync({
|
||||
operationId: newOperationId(),
|
||||
billingMonth: values.billingMonth.format('YYYY-MM'),
|
||||
});
|
||||
})) as { message?: string };
|
||||
message.success(res.message || '生成成功');
|
||||
setGenerateModal(false);
|
||||
generateForm.resetFields();
|
||||
@@ -152,10 +179,10 @@ const BillsPage: React.FC = () => {
|
||||
const showDetail = useCallback(async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/bills/${id}`);
|
||||
const res = await api.get<BillRow>(`/bills/${id}`);
|
||||
setDetailModal(res);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败,请稍后重试');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
@@ -262,19 +289,19 @@ const BillsPage: React.FC = () => {
|
||||
printWindow.document.open();
|
||||
printWindow.document.write(buildBillPrintHtml(bill));
|
||||
printWindow.document.close();
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
printWindow.close();
|
||||
message.error(error?.message || '账单加载失败');
|
||||
message.error(error instanceof Error ? error.message : '账单加载失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '学生', width: 120, render: (_: unknown, r: BillRow) => r.student?.name || '-' },
|
||||
{
|
||||
title: '账单周期',
|
||||
width: 200,
|
||||
render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`,
|
||||
render: (_: unknown, r: BillRow) => `${r.periodStart} ~ ${r.periodEnd}`,
|
||||
},
|
||||
{
|
||||
title: '分摊费用',
|
||||
@@ -337,7 +364,7 @@ const BillsPage: React.FC = () => {
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 320,
|
||||
render: (_: any, record: any) => (
|
||||
render: (_: unknown, record: BillRow) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="bill:view"
|
||||
@@ -570,8 +597,8 @@ const BillsPage: React.FC = () => {
|
||||
<Descriptions bordered size="small" column={{ xs: 1, sm: 2 }} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={statusMap[detailModal.status]?.color}>
|
||||
{statusMap[detailModal.status]?.text}
|
||||
<Tag color={statusMap[detailModal.status ?? '']?.color}>
|
||||
{statusMap[detailModal.status ?? '']?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账单周期">
|
||||
@@ -604,7 +631,7 @@ const BillsPage: React.FC = () => {
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<h4>费用明细</h4>
|
||||
<Table
|
||||
<Table<BillItem>
|
||||
scroll={{ x: 700 }}
|
||||
dataSource={detailModal.items || []}
|
||||
rowKey="id"
|
||||
|
||||
@@ -29,15 +29,49 @@ const RENTAL_FIELDS = {
|
||||
totalAmount: 'totalAmount',
|
||||
} as const;
|
||||
|
||||
export interface RentalClassroomOption {
|
||||
id: number;
|
||||
name: string;
|
||||
building?: string | null;
|
||||
status?: string;
|
||||
roomType?: string | null;
|
||||
}
|
||||
|
||||
export interface RentalOrganizationOption {
|
||||
id: number;
|
||||
name: string;
|
||||
status?: string;
|
||||
color?: string | null;
|
||||
isHost?: boolean;
|
||||
}
|
||||
|
||||
export interface RentalRecord {
|
||||
id: number;
|
||||
classroomId?: number | null;
|
||||
lesseeOrganizationId?: number | null;
|
||||
lessorOrganizationId?: number | null;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
dailyRate?: number | string | null;
|
||||
totalAmount?: number | string | null;
|
||||
effectiveStatus?: string;
|
||||
status?: string;
|
||||
notes?: string | null;
|
||||
contractPath?: string | null;
|
||||
contractOriginalName?: string | null;
|
||||
classroom?: { id: number; name: string; building?: string | null; roomType?: string | null } | null;
|
||||
lesseeOrganization?: { id: number; name: string; color?: string | null; contactName?: string | null; phone?: string | null } | null;
|
||||
}
|
||||
|
||||
export interface RentalTableProps {
|
||||
data: any[];
|
||||
data: RentalRecord[];
|
||||
loading: boolean;
|
||||
classrooms: any[];
|
||||
organizations: any[];
|
||||
classrooms: RentalClassroomOption[];
|
||||
organizations: RentalOrganizationOption[];
|
||||
canPurgeRental: boolean;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;
|
||||
onEdit: (record: any) => void;
|
||||
onSaveCell: (record: RentalRecord, field: string, value: unknown) => Promise<void> | void;
|
||||
onEdit: (record: RentalRecord) => void;
|
||||
onAction: (id: number, action: 'cancel' | 'end') => void;
|
||||
onArchive: (id: number) => void;
|
||||
onPurge: (id: number, name: string) => void;
|
||||
@@ -109,7 +143,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
title: '教室',
|
||||
width: 120,
|
||||
dataIndex: 'classroom',
|
||||
render: (c: any, r: any) => (
|
||||
render: (c: RentalRecord['classroom'], r: RentalRecord) => (
|
||||
<EditableRentalCell
|
||||
value={r.classroomId}
|
||||
field={RENTAL_FIELDS.classroomId}
|
||||
@@ -138,7 +172,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
title: '承租机构',
|
||||
width: 100,
|
||||
dataIndex: 'lesseeOrganization',
|
||||
render: (t: any, r: any) => (
|
||||
render: (t: RentalRecord['lesseeOrganization'], r: RentalRecord) => (
|
||||
<EditableRentalCell
|
||||
value={r.lesseeOrganizationId}
|
||||
field={RENTAL_FIELDS.lesseeOrganizationId}
|
||||
@@ -151,8 +185,8 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
>
|
||||
{t ? (
|
||||
<Tag
|
||||
color={t.color}
|
||||
style={{ background: t.color, color: '#fff', borderColor: t.color }}
|
||||
color={t.color ?? undefined}
|
||||
style={{ background: t.color ?? undefined, color: '#fff', borderColor: t.color ?? undefined }}
|
||||
>
|
||||
{t.name}
|
||||
</Tag>
|
||||
@@ -166,7 +200,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
title: '开始日期',
|
||||
dataIndex: 'startDate',
|
||||
width: 110,
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: RentalRecord) => (
|
||||
<EditableRentalCell value={v} field={RENTAL_FIELDS.startDate} record={r} editor="date" required>
|
||||
{v}
|
||||
</EditableRentalCell>
|
||||
@@ -176,7 +210,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
title: '结束日期',
|
||||
dataIndex: 'endDate',
|
||||
width: 110,
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: RentalRecord) => (
|
||||
<EditableRentalCell value={v} field={RENTAL_FIELDS.endDate} record={r} editor="date" required>
|
||||
{v}
|
||||
</EditableRentalCell>
|
||||
@@ -185,7 +219,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
{
|
||||
title: '时长',
|
||||
width: 80,
|
||||
render: (_: any, r: any) => {
|
||||
render: (_: unknown, r: RentalRecord) => {
|
||||
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
|
||||
return `${d}天`;
|
||||
},
|
||||
@@ -194,7 +228,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
title: '日租金',
|
||||
dataIndex: 'dailyRate',
|
||||
width: 100,
|
||||
render: (v: any, r: any) => (
|
||||
render: (v: number | string | null | undefined, r: RentalRecord) => (
|
||||
<EditableRentalCell value={v} field={RENTAL_FIELDS.dailyRate} record={r} editor="money" min={0.01}>
|
||||
{v ? `¥${v}` : '-'}
|
||||
</EditableRentalCell>
|
||||
@@ -204,7 +238,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
title: '总额',
|
||||
dataIndex: 'totalAmount',
|
||||
width: 100,
|
||||
render: (v: any, r: any) => (
|
||||
render: (v: number | string | null | undefined, r: RentalRecord) => (
|
||||
<EditableRentalCell value={v} field={RENTAL_FIELDS.totalAmount} record={r} editor="money" min={0.01}>
|
||||
{v ? `¥${v}` : '-'}
|
||||
</EditableRentalCell>
|
||||
@@ -227,14 +261,14 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
title: '合同',
|
||||
width: 120,
|
||||
dataIndex: 'contractPath',
|
||||
render: (v: string, r: any) =>
|
||||
render: (v: string, r: RentalRecord) =>
|
||||
v ? (
|
||||
<Space>
|
||||
<Tooltip title={r.contractOriginalName}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => onDownloadContract(r.id, r.contractOriginalName)}
|
||||
onClick={() => onDownloadContract(r.id, r.contractOriginalName ?? undefined)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
@@ -249,8 +283,8 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
<Upload
|
||||
accept="application/pdf"
|
||||
showUploadList={false}
|
||||
customRequest={async ({ file, onSuccess, onError }: any) => {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
if ((file as Blob).size > 10 * 1024 * 1024) {
|
||||
message.error('文件不能超过 10MB');
|
||||
onError?.(new Error('size'));
|
||||
return;
|
||||
@@ -288,7 +322,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
render: (_: any, record: any) => (
|
||||
render: (_: unknown, record: RentalRecord) => (
|
||||
<Space>
|
||||
{record.effectiveStatus === 'active' && (
|
||||
<>
|
||||
|
||||
@@ -25,7 +25,7 @@ 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';
|
||||
import { RentalTable, type RentalRecord, type RentalClassroomOption, type RentalOrganizationOption } from './RentalTable';
|
||||
|
||||
interface UnavailableDatesResponse {
|
||||
dates: string[];
|
||||
@@ -39,7 +39,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
const { hasPermission, hasAnyPermission } = usePermission();
|
||||
const canPurgeRental = hasPermission('rental:purge');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [editing, setEditing] = useState<RentalRecord | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
||||
@@ -58,13 +58,13 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<any[]>({
|
||||
} = useQuery<RentalRecord[]>({
|
||||
queryKey: ['classroom-rentals', filterMonth],
|
||||
queryFn: async () => {
|
||||
const params: any = {};
|
||||
const params: Record<string, unknown> = {};
|
||||
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
|
||||
params.includeEnded = true;
|
||||
return validateResponse<any[]>(
|
||||
return validateResponse<RentalRecord[]>(
|
||||
rentalsSchema,
|
||||
await api.get('/classroom-rentals', { params }),
|
||||
);
|
||||
@@ -72,21 +72,21 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
});
|
||||
const {
|
||||
data: meta = { classrooms: [], organizations: [] },
|
||||
} = useQuery<{ classrooms: any[]; organizations: any[] }>({
|
||||
} = useQuery<{ classrooms: RentalClassroomOption[]; organizations: RentalOrganizationOption[] }>({
|
||||
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' } }),
|
||||
const [cr, tn] = await Promise.all([
|
||||
api.get<RentalClassroomOption[]>('/classrooms'),
|
||||
api.get<RentalOrganizationOption[]>('/organizations', { params: { scope: 'all' } }),
|
||||
]);
|
||||
return {
|
||||
classrooms: validateResponse<any[]>(classroomsSchema, cr),
|
||||
organizations: validateResponse<any[]>(organizationsSchema, tn),
|
||||
classrooms: validateResponse<RentalClassroomOption[]>(classroomsSchema, cr),
|
||||
organizations: validateResponse<RentalOrganizationOption[]>(organizationsSchema, tn),
|
||||
};
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载教室列表失败');
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : '加载教室列表失败');
|
||||
return { classrooms: [], organizations: [] };
|
||||
}
|
||||
},
|
||||
@@ -120,7 +120,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
},
|
||||
);
|
||||
const saveCellMutation = useApiMutation(
|
||||
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
|
||||
async ({ record, field, value }: { record: RentalRecord; field: string; value: unknown }) =>
|
||||
api.put(`/classroom-rentals/${record.id}`, { [field]: value }),
|
||||
{ invalidate: [['classroom-rentals']] },
|
||||
);
|
||||
@@ -161,7 +161,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter((r: any) => {
|
||||
return data.filter((r: RentalRecord) => {
|
||||
if (filterStatus && r.effectiveStatus !== filterStatus) return false;
|
||||
if (!searchText) return true;
|
||||
const s = searchText.toLowerCase();
|
||||
@@ -201,10 +201,10 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
setUnavailableDates((draft) => {
|
||||
response.dates.forEach((item) => draft.add(item));
|
||||
});
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
loadedUnavailableMonths.current.delete(key);
|
||||
if (requestVersion === unavailableRequestVersion.current) {
|
||||
message.error(e?.message || '加载教室占用日期失败');
|
||||
message.error(e instanceof Error ? e.message : '加载教室占用日期失败');
|
||||
}
|
||||
} finally {
|
||||
if (requestVersion === unavailableRequestVersion.current) {
|
||||
@@ -271,7 +271,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: any, field: string, value: unknown) => {
|
||||
const saveCell = async (record: RentalRecord, field: string, value: unknown) => {
|
||||
try {
|
||||
await saveCellMutation.mutateAsync({ record, field, value });
|
||||
message.success('已保存');
|
||||
@@ -342,9 +342,10 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
return uploadContractMutation.mutateAsync({ id, formData, onProgress });
|
||||
};
|
||||
|
||||
const openEdit = (record: any) => {
|
||||
const openEdit = (record: RentalRecord) => {
|
||||
setEditing(record);
|
||||
resetUnavailableDates();
|
||||
if (!record.id || !record.classroomId) return;
|
||||
form.setFieldsValue({
|
||||
classroomId: record.classroomId,
|
||||
lessorOrganizationId: record.lessorOrganizationId,
|
||||
|
||||
@@ -23,14 +23,43 @@ import { message } from '../../ui/app-message';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
import type { RentalRecord } from '../ClassroomRentals/RentalTable';
|
||||
|
||||
interface ScheduleClassroomOption {
|
||||
id: number;
|
||||
name: string;
|
||||
building?: string | null;
|
||||
floor?: number | null;
|
||||
status?: string;
|
||||
roomType?: string | null;
|
||||
}
|
||||
|
||||
interface ScheduleOrganizationOption {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface ScheduleCell {
|
||||
scheduleType?: 'INTERNAL' | 'RENTAL';
|
||||
rentalId?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
subject?: string;
|
||||
teacherName?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
organizationName?: string;
|
||||
hasContract?: boolean;
|
||||
}
|
||||
|
||||
interface ScheduleData {
|
||||
year: number;
|
||||
month: number;
|
||||
days: number;
|
||||
classrooms: any[];
|
||||
organizations: any[];
|
||||
matrix: Record<number, Record<number, any>>;
|
||||
classrooms: ScheduleClassroomOption[];
|
||||
organizations: ScheduleOrganizationOption[];
|
||||
matrix: Record<number, Record<number, ScheduleCell | undefined>>;
|
||||
summary: Record<
|
||||
number,
|
||||
{ totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }
|
||||
@@ -39,7 +68,7 @@ interface ScheduleData {
|
||||
|
||||
const ClassroomSchedulePage: React.FC = () => {
|
||||
const [month, setMonth] = useState<Dayjs>(dayjs());
|
||||
const [detailModal, setDetailModal] = useState<any>(null);
|
||||
const [detailModal, setDetailModal] = useState<RentalRecord | null>(null);
|
||||
|
||||
const { data, isLoading, isFetching, isError, refetch } = useQuery<ScheduleData | null>({
|
||||
queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()],
|
||||
@@ -58,7 +87,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
// 按楼栋+楼层分组教室
|
||||
const groups = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const map = new Map<string, any[]>();
|
||||
const map = new Map<string, ScheduleClassroomOption[]>();
|
||||
for (const c of data.classrooms) {
|
||||
const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}层` : ''}`;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
@@ -84,7 +113,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
|
||||
const showDetail = async (rentalId: number) => {
|
||||
try {
|
||||
const res: any = await api.get(`/classroom-rentals/${rentalId}`);
|
||||
const res = await api.get<RentalRecord>(`/classroom-rentals/${rentalId}`);
|
||||
setDetailModal(res);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '加载详情失败'));
|
||||
@@ -181,8 +210,8 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
{data.organizations.map((t) => (
|
||||
<Tag
|
||||
key={t.id}
|
||||
color={t.color}
|
||||
style={{ background: t.color, color: '#fff', borderColor: t.color }}
|
||||
color={t.color ?? undefined}
|
||||
style={{ background: t.color ?? undefined, color: '#fff', borderColor: t.color ?? undefined }}
|
||||
>
|
||||
{t.name} (租赁)
|
||||
</Tag>
|
||||
@@ -299,7 +328,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
<td
|
||||
key={d}
|
||||
onClick={() => {
|
||||
if (isRental) showDetail(cell.rentalId);
|
||||
if (isRental && cell?.rentalId) showDetail(cell.rentalId);
|
||||
}}
|
||||
style={{
|
||||
padding: 0,
|
||||
@@ -362,11 +391,11 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
<div>
|
||||
<strong>承租机构:</strong>
|
||||
<Tag
|
||||
color={detailModal.lesseeOrganization?.color}
|
||||
color={detailModal.lesseeOrganization?.color ?? undefined}
|
||||
style={{
|
||||
background: detailModal.lesseeOrganization?.color,
|
||||
background: detailModal.lesseeOrganization?.color ?? undefined,
|
||||
color: '#fff',
|
||||
borderColor: detailModal.lesseeOrganization?.color,
|
||||
borderColor: detailModal.lesseeOrganization?.color ?? undefined,
|
||||
}}
|
||||
>
|
||||
{detailModal.lesseeOrganization?.name}
|
||||
@@ -405,7 +434,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
type="link"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() =>
|
||||
handleDownloadContract(detailModal.id, detailModal.contractOriginalName)
|
||||
handleDownloadContract(detailModal.id, detailModal.contractOriginalName ?? undefined)
|
||||
}
|
||||
>
|
||||
{detailModal.contractOriginalName || '下载'}
|
||||
|
||||
@@ -53,6 +53,22 @@ interface CurrentUsage {
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
interface ClassroomRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
building?: string | null;
|
||||
floor?: number | null;
|
||||
roomType?: string | null;
|
||||
capacity?: number;
|
||||
status?: string;
|
||||
effectiveStatus?: string;
|
||||
currentUsage?: CurrentUsage | null;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const typeColor: Record<string, string> = {
|
||||
大: 'volcano',
|
||||
次大: 'geekblue',
|
||||
@@ -63,7 +79,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
const { modal } = App.useApp();
|
||||
const { hasPermission } = usePermission();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [editing, setEditing] = useState<ClassroomRecord | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const formGuard = useDirtyGuard(form);
|
||||
@@ -79,13 +95,13 @@ const ClassroomsPage: React.FC = () => {
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<any[]>({
|
||||
} = useQuery<ClassroomRecord[]>({
|
||||
queryKey: ['classrooms', showArchived],
|
||||
queryFn: async () =>
|
||||
validateResponse<any[]>(
|
||||
validateResponse<ClassroomRecord[]>(
|
||||
classroomsSchema,
|
||||
await api.get('/classrooms', { params: { includeArchived: showArchived } }),
|
||||
),
|
||||
) ?? [],
|
||||
});
|
||||
const loading = isLoading || isFetching;
|
||||
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
|
||||
@@ -97,7 +113,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
{ invalidate: [['classrooms']] },
|
||||
);
|
||||
const saveCellMutation = useApiMutation(
|
||||
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
|
||||
async ({ record, field, value }: { record: ClassroomRecord; field: string; value: unknown }) =>
|
||||
api.put(`/classrooms/${record.id}`, { [field]: value }),
|
||||
{ invalidate: [['classrooms']] },
|
||||
);
|
||||
@@ -124,13 +140,13 @@ const ClassroomsPage: React.FC = () => {
|
||||
if (searchText) {
|
||||
const s = searchText.toLowerCase();
|
||||
result = result.filter(
|
||||
(d: Record<string, unknown>) =>
|
||||
(d: ClassroomRecord) =>
|
||||
(typeof d.name === 'string' && d.name.toLowerCase().includes(s)) ||
|
||||
(typeof d.building === 'string' && d.building.toLowerCase().includes(s)),
|
||||
);
|
||||
}
|
||||
if (filterStatus)
|
||||
result = result.filter((d: Record<string, unknown>) => d.effectiveStatus === filterStatus);
|
||||
result = result.filter((d: ClassroomRecord) => d.effectiveStatus === filterStatus);
|
||||
return result;
|
||||
}, [data, searchText, filterStatus]);
|
||||
|
||||
@@ -158,7 +174,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const saveCell = useCallback(
|
||||
async (record: any, field: string, value: unknown) => {
|
||||
async (record: ClassroomRecord, field: string, value: unknown) => {
|
||||
try {
|
||||
await saveCellMutation.mutateAsync({ record, field, value });
|
||||
message.success('已保存');
|
||||
@@ -232,8 +248,8 @@ const ClassroomsPage: React.FC = () => {
|
||||
title: '教室名',
|
||||
width: 120,
|
||||
dataIndex: 'name',
|
||||
sorter: (a: any, b: any) => a.name.localeCompare(b.name),
|
||||
render: (v: string, r: any) => (
|
||||
sorter: (a: ClassroomRecord, b: ClassroomRecord) => a.name.localeCompare(b.name),
|
||||
render: (v: string, r: ClassroomRecord) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
@@ -250,7 +266,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
title: '楼栋',
|
||||
dataIndex: 'building',
|
||||
width: 80,
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: ClassroomRecord) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="classroom:edit"
|
||||
@@ -266,7 +282,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
title: '楼层',
|
||||
dataIndex: 'floor',
|
||||
width: 80,
|
||||
render: (v: number, r: any) => (
|
||||
render: (v: number, r: ClassroomRecord) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="number"
|
||||
@@ -283,7 +299,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
title: '类型',
|
||||
width: 90,
|
||||
dataIndex: 'roomType',
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: ClassroomRecord) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
@@ -301,7 +317,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
title: '容量',
|
||||
dataIndex: 'capacity',
|
||||
width: 80,
|
||||
render: (v: number, r: any) => (
|
||||
render: (v: number, r: ClassroomRecord) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="number"
|
||||
@@ -319,10 +335,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
title: '状态',
|
||||
width: 100,
|
||||
dataIndex: 'status',
|
||||
render: (
|
||||
_s: string,
|
||||
record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null },
|
||||
) => {
|
||||
render: (_s: string, record: ClassroomRecord) => {
|
||||
const effectiveStatus = record.effectiveStatus || record.status;
|
||||
return (
|
||||
<EditableCell
|
||||
@@ -343,8 +356,8 @@ const ClassroomsPage: React.FC = () => {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Tag color={statusMap[effectiveStatus]?.color}>
|
||||
{statusMap[effectiveStatus]?.text || effectiveStatus}
|
||||
<Tag color={statusMap[effectiveStatus ?? '']?.color}>
|
||||
{statusMap[effectiveStatus ?? '']?.text || effectiveStatus}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</EditableCell>
|
||||
@@ -356,7 +369,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 180,
|
||||
render: (_: any, record: any) => (
|
||||
render: (_: unknown, record: ClassroomRecord) => (
|
||||
<Space>
|
||||
{record.status === 'archived' ? (
|
||||
<>
|
||||
@@ -490,11 +503,11 @@ const ClassroomsPage: React.FC = () => {
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
customRequest={async ({ file, onSuccess, onError }: any) => {
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const res: any = await importMutation.mutateAsync(formData);
|
||||
const res = (await importMutation.mutateAsync(formData)) as ImportResult;
|
||||
message.success(res.message);
|
||||
onSuccess?.(res);
|
||||
} catch (e) {
|
||||
|
||||
@@ -19,6 +19,14 @@ import EditableCell from '../../components/EditableCell';
|
||||
import type { DepositStudentLookup } from './deposit-student-option';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
|
||||
export interface DepositInstallment {
|
||||
id: number;
|
||||
amount: number;
|
||||
dueDate: string;
|
||||
paidDate?: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface DepositRecord {
|
||||
id: number;
|
||||
studentId: number;
|
||||
@@ -331,7 +339,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
||||
{
|
||||
title: '实付日',
|
||||
dataIndex: 'paidDate',
|
||||
render: (value: string, item: any) => (
|
||||
render: (value: string, item: DepositInstallment) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="date"
|
||||
@@ -347,7 +355,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (value: string, item: any) => (
|
||||
render: (value: string, item: DepositInstallment) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="select"
|
||||
@@ -369,7 +377,7 @@ export const DepositModals: React.FC<DepositModalsProps> = ({
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, item: any) => (
|
||||
render: (_: unknown, item: DepositInstallment) => (
|
||||
<Space>
|
||||
{item.status === 'pending' && (
|
||||
<PermissionButton
|
||||
|
||||
@@ -8,8 +8,23 @@ import { message } from '../../ui/app-message';
|
||||
import { statusMap } from './DepositModals';
|
||||
import type { DepositRecord } from './DepositModals';
|
||||
|
||||
interface DepositRow {
|
||||
id: number | string;
|
||||
studentId: number;
|
||||
amount: number | string;
|
||||
status: string;
|
||||
paidDate: string;
|
||||
refundDate?: string | null;
|
||||
notes?: string | null;
|
||||
student?: DepositRecord['student'];
|
||||
installments?: DepositRecord['installments'];
|
||||
roomNumber?: string;
|
||||
building?: string | null;
|
||||
roomType?: string | null;
|
||||
}
|
||||
|
||||
export interface DepositTableProps {
|
||||
data: any[];
|
||||
data: DepositRow[];
|
||||
loading: boolean;
|
||||
canPurgeDeposit: boolean;
|
||||
canCreateDeposit?: boolean;
|
||||
@@ -34,7 +49,7 @@ export const DepositTable: React.FC<DepositTableProps> = ({
|
||||
onPurge,
|
||||
}) => {
|
||||
const columns = [
|
||||
{ title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' },
|
||||
{ title: '学生', width: 120, render: (_: unknown, r: DepositRow) => r.student?.name || '-' },
|
||||
{
|
||||
title: '当前可用押金',
|
||||
dataIndex: 'amount',
|
||||
@@ -44,7 +59,7 @@ export const DepositTable: React.FC<DepositTableProps> = ({
|
||||
{
|
||||
title: '房间',
|
||||
width: 120,
|
||||
render: (_: unknown, r: any) =>
|
||||
render: (_: unknown, r: DepositRow) =>
|
||||
r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-',
|
||||
},
|
||||
{ title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' },
|
||||
@@ -65,12 +80,12 @@ export const DepositTable: React.FC<DepositTableProps> = ({
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 240,
|
||||
render: (_: unknown, record: any) => {
|
||||
render: (_: unknown, record: DepositRow) => {
|
||||
const hasDeposit = typeof record.id === 'number';
|
||||
return (
|
||||
<Space>
|
||||
{hasDeposit && (
|
||||
<PermissionButton permission="deposit:view" size="small" onClick={() => onDetail(record)}>
|
||||
<PermissionButton permission="deposit:view" size="small" onClick={() => onDetail(record as DepositRecord)}>
|
||||
详情
|
||||
</PermissionButton>
|
||||
)}
|
||||
@@ -80,7 +95,7 @@ export const DepositTable: React.FC<DepositTableProps> = ({
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
onRefund(record);
|
||||
onRefund(record as DepositRecord);
|
||||
refundForm.setFieldsValue({ refundDate: dayjs() });
|
||||
}}
|
||||
>
|
||||
@@ -92,7 +107,7 @@ export const DepositTable: React.FC<DepositTableProps> = ({
|
||||
title="确定归档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await onArchive(record.id);
|
||||
await onArchive(record.id as number);
|
||||
message.success('归档成功');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
@@ -117,7 +132,7 @@ export const DepositTable: React.FC<DepositTableProps> = ({
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await onPurge(record.id);
|
||||
await onPurge(record.id as number);
|
||||
message.success('已永久删除(不可恢复)');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'antd';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
import { type ExpenseRoomOption, type ExpenseStudentOption } from './ExpenseTablePanel';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -18,7 +19,7 @@ export const RoomExpenseModal: React.FC<{
|
||||
editing: boolean;
|
||||
saving: boolean;
|
||||
form: ReturnType<typeof Form.useForm>[0];
|
||||
rooms: any[];
|
||||
rooms: ExpenseRoomOption[];
|
||||
typeOptions: Array<{ value: string; label: string }>;
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -44,7 +45,7 @@ export const RoomExpenseModal: React.FC<{
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={rooms.map((r: any) => ({
|
||||
options={rooms.map((r: ExpenseRoomOption) => ({
|
||||
value: r.id,
|
||||
label: `${r.roomNumber} (${r.building || ''})`,
|
||||
}))}
|
||||
@@ -75,7 +76,7 @@ export const UtilityModal: React.FC<{
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
form: ReturnType<typeof Form.useForm>[0];
|
||||
students: any[];
|
||||
students: ExpenseStudentOption[];
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, saving, form, students, onOk, onCancel }) => {
|
||||
@@ -100,7 +101,7 @@ export const UtilityModal: React.FC<{
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={students.map((student: any) => ({
|
||||
options={students.map((student: ExpenseStudentOption) => ({
|
||||
value: student.id,
|
||||
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
||||
}))}
|
||||
@@ -133,8 +134,8 @@ export const PersonalExpenseModal: React.FC<{
|
||||
editing: boolean;
|
||||
saving: boolean;
|
||||
form: ReturnType<typeof Form.useForm>[0];
|
||||
students: any[];
|
||||
rooms: any[];
|
||||
students: ExpenseStudentOption[];
|
||||
rooms: ExpenseRoomOption[];
|
||||
personalTypeOptions: Array<{ value: string; label: string }>;
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -160,7 +161,7 @@ export const PersonalExpenseModal: React.FC<{
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={students.map((s: any) => ({ value: s.id, label: s.name }))}
|
||||
options={students.map((s: ExpenseStudentOption) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="roomId" label="关联宿舍">
|
||||
@@ -168,7 +169,7 @@ export const PersonalExpenseModal: React.FC<{
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={rooms.map((r: any) => ({ value: r.id, label: r.roomNumber }))}
|
||||
options={rooms.map((r: ExpenseRoomOption) => ({ value: r.id, label: r.roomNumber }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
|
||||
|
||||
@@ -35,6 +35,40 @@ export const EXPENSE_FIELDS = {
|
||||
expenseDate: 'expenseDate',
|
||||
} as const;
|
||||
|
||||
export interface ExpenseRoomOption {
|
||||
id: number;
|
||||
roomNumber?: string | null;
|
||||
building?: string | null;
|
||||
}
|
||||
|
||||
export interface ExpenseStudentOption {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
studentNo?: string | null;
|
||||
}
|
||||
|
||||
/** 费用行(房间/个人两类共用),字段与 /expenses 响应一致 */
|
||||
export interface ExpenseRow {
|
||||
id: number;
|
||||
roomId?: number | null;
|
||||
studentId?: number | null;
|
||||
expenseType?: string;
|
||||
amount?: number;
|
||||
description?: string | null;
|
||||
expenseDate?: string | null;
|
||||
periodStart?: string | null;
|
||||
periodEnd?: string | null;
|
||||
createdAt?: string | null;
|
||||
status?: string;
|
||||
room?: { roomNumber?: string | null } | null;
|
||||
student?: { name?: string | null } | null;
|
||||
}
|
||||
|
||||
export interface ExpenseImportResult {
|
||||
message?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export interface ExpenseTablePanelProps {
|
||||
kind: 'room' | 'personal';
|
||||
searchText: string;
|
||||
@@ -43,12 +77,12 @@ export interface ExpenseTablePanelProps {
|
||||
onTypeFilterChange: (value?: string) => void;
|
||||
typeOptions: Array<{ value: string; label: string }>;
|
||||
typeMap: Record<string, string>;
|
||||
data: any[];
|
||||
data: ExpenseRow[];
|
||||
loading: boolean;
|
||||
selectedKeys: number[];
|
||||
onSelect: (keys: number[]) => void;
|
||||
rooms: any[];
|
||||
students: any[];
|
||||
rooms: ExpenseRoomOption[];
|
||||
students: ExpenseStudentOption[];
|
||||
readonly: boolean;
|
||||
showArchived: boolean;
|
||||
canPurgeExpense: boolean;
|
||||
@@ -57,12 +91,12 @@ export interface ExpenseTablePanelProps {
|
||||
onBatchRestore: () => void;
|
||||
onBatchPurge: () => void;
|
||||
onBatchDelete: () => void;
|
||||
onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;
|
||||
onSaveCell: (record: ExpenseRow, field: string, value: unknown) => Promise<void> | void;
|
||||
onPeriodSave: (id: number, periodStart: string, periodEnd: string) => Promise<void> | void;
|
||||
onEdit: (record: any) => void;
|
||||
onEdit: (record: ExpenseRow) => void;
|
||||
onArchive: (id: number) => Promise<unknown> | unknown;
|
||||
onPurge: (id: number) => void;
|
||||
onImport: (formData: FormData) => Promise<any>;
|
||||
onImport: (formData: FormData) => Promise<ExpenseImportResult>;
|
||||
onTemplateDownload: () => void;
|
||||
onExport?: () => void;
|
||||
templateLoading?: boolean;
|
||||
@@ -143,7 +177,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
</EditableCell>
|
||||
);
|
||||
|
||||
const renderExpenseActions = (record: any) => {
|
||||
const renderExpenseActions = (record: ExpenseRow) => {
|
||||
if (showArchived) {
|
||||
return (
|
||||
<Space>
|
||||
@@ -190,11 +224,11 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
{
|
||||
title: '宿舍',
|
||||
width: 120,
|
||||
render: (_: any, r: any) => (
|
||||
render: (_: unknown, r: ExpenseRow) => (
|
||||
<EditableExpenseCell
|
||||
value={r.roomId}
|
||||
editor="select"
|
||||
options={rooms.map((item) => ({ value: item.id, label: item.roomNumber }))}
|
||||
options={rooms.map((item) => ({ value: item.id, label: item.roomNumber ?? String(item.id) }))}
|
||||
required
|
||||
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.roomId, next)}
|
||||
>
|
||||
@@ -206,7 +240,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
title: '费用类型',
|
||||
width: 100,
|
||||
dataIndex: 'expenseType',
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: ExpenseRow) => (
|
||||
<EditableExpenseCell
|
||||
value={v}
|
||||
editor="select"
|
||||
@@ -222,7 +256,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
width: 100,
|
||||
render: (v: number, r: any) => (
|
||||
render: (v: number, r: ExpenseRow) => (
|
||||
<EditableExpenseCell
|
||||
value={v}
|
||||
editor="money"
|
||||
@@ -237,7 +271,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
{
|
||||
title: '账单周期',
|
||||
width: 200,
|
||||
render: (_: any, r: any) => (
|
||||
render: (_: unknown, r: ExpenseRow) => (
|
||||
<EditableCell
|
||||
value={[r.periodStart, r.periodEnd]}
|
||||
editor="date-range"
|
||||
@@ -255,7 +289,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
title: '说明',
|
||||
dataIndex: 'description',
|
||||
width: 150,
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: ExpenseRow) => (
|
||||
<EditableExpenseCell
|
||||
value={v}
|
||||
editor="textarea"
|
||||
@@ -274,18 +308,18 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_: any, record: any) => renderExpenseActions(record),
|
||||
render: (_: unknown, record: ExpenseRow) => renderExpenseActions(record),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: '学生',
|
||||
width: 120,
|
||||
render: (_: any, r: any) => (
|
||||
render: (_: unknown, r: ExpenseRow) => (
|
||||
<EditableExpenseCell
|
||||
value={r.studentId}
|
||||
editor="select"
|
||||
options={students.map((item) => ({ value: item.id, label: item.name }))}
|
||||
options={students.map((item) => ({ value: item.id, label: item.name ?? String(item.id) }))}
|
||||
required
|
||||
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.studentId, next)}
|
||||
>
|
||||
@@ -297,7 +331,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
title: '费用类型',
|
||||
width: 100,
|
||||
dataIndex: 'expenseType',
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: ExpenseRow) => (
|
||||
<EditableExpenseCell
|
||||
value={v}
|
||||
editor="select"
|
||||
@@ -312,7 +346,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
render: (v: number, r: any) => (
|
||||
render: (v: number, r: ExpenseRow) => (
|
||||
<EditableExpenseCell
|
||||
value={v}
|
||||
editor="money"
|
||||
@@ -328,7 +362,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
title: '日期',
|
||||
dataIndex: 'expenseDate',
|
||||
width: 110,
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: ExpenseRow) => (
|
||||
<EditableExpenseCell
|
||||
value={v}
|
||||
editor="date"
|
||||
@@ -343,7 +377,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
title: '说明',
|
||||
dataIndex: 'description',
|
||||
width: 150,
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: ExpenseRow) => (
|
||||
<EditableExpenseCell
|
||||
value={v}
|
||||
editor="textarea"
|
||||
@@ -356,7 +390,7 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_: any, record: any) => renderExpenseActions(record),
|
||||
render: (_: unknown, record: ExpenseRow) => renderExpenseActions(record),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -394,17 +428,18 @@ export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
customRequest={async ({ file, onSuccess, onError }: any) => {
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res: any = await onImport(formData);
|
||||
if (isRoom && res.errors?.length > 0) {
|
||||
const res: ExpenseImportResult = await onImport(formData);
|
||||
const errors = res.errors ?? [];
|
||||
if (isRoom && errors.length > 0) {
|
||||
message.warning(res.message || '导入完成');
|
||||
res.errors.forEach((e: string) => message.warning(e));
|
||||
errors.forEach((e: string) => message.warning(e));
|
||||
} else {
|
||||
message.success(res.message || '导入完成');
|
||||
if (res.errors?.length) res.errors.forEach((e: string) => message.warning(e));
|
||||
if (errors.length) errors.forEach((e: string) => message.warning(e));
|
||||
}
|
||||
onSuccess?.(res);
|
||||
} catch (e) {
|
||||
|
||||
@@ -16,11 +16,15 @@ import {
|
||||
expenseStudentLookupsSchema,
|
||||
} from '../../api/schemas';
|
||||
import { archiveViewPolicy, expenseStatusForView } from '../archive-view';
|
||||
import { ExpenseTablePanel } from './ExpenseTablePanel';
|
||||
import { ExpenseTablePanel, type ExpenseRow, type ExpenseRoomOption, type ExpenseStudentOption, type ExpenseImportResult } from './ExpenseTablePanel';
|
||||
import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
|
||||
interface UtilityBillResult {
|
||||
bill?: { paidAmount?: number | string; outstandingAmount?: number | string };
|
||||
}
|
||||
|
||||
const ExpensesPage: React.FC = () => {
|
||||
const { modal } = App.useApp();
|
||||
const { hasPermission } = usePermission();
|
||||
@@ -28,8 +32,8 @@ const ExpensesPage: React.FC = () => {
|
||||
const [roomModal, setRoomModal] = useState(false);
|
||||
const [personalModal, setPersonalModal] = useState(false);
|
||||
const [utilityModal, setUtilityModal] = useState(false);
|
||||
const [editingRoom, setEditingRoom] = useState<any>(null);
|
||||
const [editingPersonal, setEditingPersonal] = useState<any>(null);
|
||||
const [editingRoom, setEditingRoom] = useState<ExpenseRow | null>(null);
|
||||
const [editingPersonal, setEditingPersonal] = useState<ExpenseRow | null>(null);
|
||||
const [roomForm] = Form.useForm();
|
||||
const [personalForm] = Form.useForm();
|
||||
const [utilityForm] = Form.useForm();
|
||||
@@ -75,10 +79,10 @@ const ExpensesPage: React.FC = () => {
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<{
|
||||
rooms: any[];
|
||||
personal: any[];
|
||||
students: any[];
|
||||
roomsList: any[];
|
||||
rooms: ExpenseRow[];
|
||||
personal: ExpenseRow[];
|
||||
students: ExpenseStudentOption[];
|
||||
roomsList: ExpenseRoomOption[];
|
||||
}>({
|
||||
queryKey: ['expenses', showArchived ? 'archived' : 'active'],
|
||||
queryFn: async () => {
|
||||
@@ -93,10 +97,10 @@ const ExpensesPage: React.FC = () => {
|
||||
api.get('/rooms'),
|
||||
]);
|
||||
return {
|
||||
rooms: validateResponse(expenseRecordsSchema, rooms),
|
||||
personal: validateResponse(expenseRecordsSchema, personal),
|
||||
students: validateResponse(expenseStudentLookupsSchema, students),
|
||||
roomsList: validateResponse(expenseRoomsListSchema, roomsList),
|
||||
rooms: validateResponse<ExpenseRow[]>(expenseRecordsSchema, rooms),
|
||||
personal: validateResponse<ExpenseRow[]>(expenseRecordsSchema, personal),
|
||||
students: validateResponse<ExpenseStudentOption[]>(expenseStudentLookupsSchema, students),
|
||||
roomsList: validateResponse<ExpenseRoomOption[]>(expenseRoomsListSchema, roomsList),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -117,7 +121,7 @@ const ExpensesPage: React.FC = () => {
|
||||
{ invalidate: [['expenses']] },
|
||||
),
|
||||
saveRoomCell: useApiMutation(
|
||||
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
|
||||
async ({ record, field, value }: { record: ExpenseRow; field: string; value: unknown }) =>
|
||||
api.put(`/expenses/room/${record.id}`, { [field]: value }),
|
||||
{ invalidate: [['expenses']] },
|
||||
),
|
||||
@@ -129,7 +133,7 @@ const ExpensesPage: React.FC = () => {
|
||||
{ invalidate: [['expenses']] },
|
||||
),
|
||||
savePersonalCell: useApiMutation(
|
||||
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
|
||||
async ({ record, field, value }: { record: ExpenseRow; field: string; value: unknown }) =>
|
||||
api.put(`/expenses/personal/${record.id}`, { [field]: value }),
|
||||
{ invalidate: [['expenses']] },
|
||||
),
|
||||
@@ -307,7 +311,7 @@ const ExpensesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const filteredRoomExpenses = useMemo(() => {
|
||||
return roomExpenses.filter((r: any) => {
|
||||
return roomExpenses.filter((r: ExpenseRow) => {
|
||||
if (roomSearch) {
|
||||
const s = roomSearch.toLowerCase();
|
||||
if (!r.room?.roomNumber?.toLowerCase().includes(s)) return false;
|
||||
@@ -318,7 +322,7 @@ const ExpensesPage: React.FC = () => {
|
||||
}, [roomExpenses, roomSearch, roomTypeFilter]);
|
||||
|
||||
const filteredPersonalExpenses = useMemo(() => {
|
||||
return personalExpenses.filter((r: any) => {
|
||||
return personalExpenses.filter((r: ExpenseRow) => {
|
||||
if (personalSearch) {
|
||||
const s = personalSearch.toLowerCase();
|
||||
if (!r.student?.name?.toLowerCase().includes(s)) return false;
|
||||
@@ -356,15 +360,15 @@ const ExpensesPage: React.FC = () => {
|
||||
const values = await utilityForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result: any = await mutations.utility.mutateAsync({
|
||||
const result = (await mutations.utility.mutateAsync({
|
||||
studentId: values.studentId,
|
||||
expenseType: values.expenseType,
|
||||
amount: values.amount,
|
||||
periodStart: values.period[0].format('YYYY-MM-DD'),
|
||||
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
});
|
||||
const bill = result.bill;
|
||||
})) as UtilityBillResult;
|
||||
const bill = result.bill ?? {};
|
||||
message.success(
|
||||
`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`,
|
||||
);
|
||||
@@ -401,7 +405,7 @@ const ExpensesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openEditRoom = (record: any) => {
|
||||
const openEditRoom = (record: ExpenseRow) => {
|
||||
setEditingRoom(record);
|
||||
roomForm.setFieldsValue({
|
||||
roomId: record.roomId,
|
||||
@@ -413,7 +417,7 @@ const ExpensesPage: React.FC = () => {
|
||||
setRoomModal(true);
|
||||
};
|
||||
|
||||
const openEditPersonal = (record: any) => {
|
||||
const openEditPersonal = (record: ExpenseRow) => {
|
||||
setEditingPersonal(record);
|
||||
personalForm.setFieldsValue({
|
||||
studentId: record.studentId,
|
||||
@@ -427,7 +431,7 @@ const ExpensesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const saveRoomCell = useCallback(
|
||||
async (record: any, field: string, value: unknown) => {
|
||||
async (record: ExpenseRow, field: string, value: unknown) => {
|
||||
try {
|
||||
await mutations.saveRoomCell.mutateAsync({ record, field, value });
|
||||
message.success('已保存');
|
||||
@@ -439,7 +443,7 @@ const ExpensesPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const savePersonalCell = useCallback(
|
||||
async (record: any, field: string, value: unknown) => {
|
||||
async (record: ExpenseRow, field: string, value: unknown) => {
|
||||
try {
|
||||
await mutations.savePersonalCell.mutateAsync({ record, field, value });
|
||||
message.success('已保存');
|
||||
@@ -510,7 +514,7 @@ const ExpensesPage: React.FC = () => {
|
||||
onEdit={openEditRoom}
|
||||
onArchive={(id) => mutations.archiveRoom.mutateAsync(id)}
|
||||
onPurge={handlePurgeRoom}
|
||||
onImport={(formData) => mutations.importUtility.mutateAsync(formData)}
|
||||
onImport={async (formData) => (await mutations.importUtility.mutateAsync(formData)) as ExpenseImportResult}
|
||||
onTemplateDownload={() => {
|
||||
void runUtilityTemplateDownload('/expenses/utility/template', '水电费导入模板.xlsx', {
|
||||
successMsg: '模板已下载',
|
||||
@@ -553,7 +557,7 @@ const ExpensesPage: React.FC = () => {
|
||||
onEdit={openEditPersonal}
|
||||
onArchive={(id) => mutations.archivePersonal.mutateAsync(id)}
|
||||
onPurge={handlePurgePersonal}
|
||||
onImport={(formData) => mutations.importPersonal.mutateAsync(formData)}
|
||||
onImport={async (formData) => (await mutations.importPersonal.mutateAsync(formData)) as ExpenseImportResult}
|
||||
onTemplateDownload={() => {
|
||||
void runPersonalTemplateDownload('/expenses/personal/template', '个人附加费导入模板.xlsx', {
|
||||
successMsg: '模板已下载',
|
||||
|
||||
@@ -27,11 +27,14 @@ const LoginPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const onFinish = useCallback(
|
||||
async (values: any) => {
|
||||
async (values: { username: string; password: string }) => {
|
||||
clearPermissions();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/auth/login', values);
|
||||
const res = (await api.post('/auth/login', values)) as {
|
||||
access_token: string;
|
||||
user: { id: number; username: string; name?: string; roles: string[]; permissions: string[] };
|
||||
};
|
||||
setSession(res.access_token, res.user);
|
||||
const permissions = res.user.permissions || [];
|
||||
writePermissions(permissions);
|
||||
@@ -39,8 +42,8 @@ const LoginPage: React.FC = () => {
|
||||
navigate(findRoleAwareLandingPath(res.user.roles || [], permissions) || '/', {
|
||||
replace: true,
|
||||
});
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '登录失败');
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ const NotificationsPage: React.FC = () => {
|
||||
);
|
||||
setNotifications((prev) => (after === undefined ? res : [...prev, ...res]));
|
||||
setHasMore(res.length === PAGE_SIZE);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
console.error('加载通知失败', e);
|
||||
setError(true);
|
||||
} finally {
|
||||
@@ -114,9 +114,9 @@ const NotificationsPage: React.FC = () => {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)),
|
||||
);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
console.error('标记已读失败', e);
|
||||
message.error(e?.message || '标记已读失败');
|
||||
message.error(e instanceof Error ? e.message : '标记已读失败');
|
||||
}
|
||||
}
|
||||
if (item.link) navigate(item.link);
|
||||
@@ -126,9 +126,9 @@ const NotificationsPage: React.FC = () => {
|
||||
try {
|
||||
await api.put('/notifications/read-all');
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
console.error('全部已读失败', e);
|
||||
message.error(e?.message || '操作失败');
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Alert, Button, Popconfirm, Table } from 'antd';
|
||||
import { Alert, Button, Popconfirm, Table, type TableProps } from 'antd';
|
||||
import type { OccupancyRow } from './OccupancyColumns';
|
||||
import { InboxOutlined, LogoutOutlined, UndoOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { QueryEmpty } from '../../components/QueryState';
|
||||
|
||||
export const OccupanciesTableArea: React.FC<{
|
||||
columns: any[];
|
||||
data: any[];
|
||||
columns: TableProps<OccupancyRow>['columns'];
|
||||
data: OccupancyRow[];
|
||||
loading: boolean;
|
||||
selectedRowKeys: number[];
|
||||
rowSelection: any;
|
||||
rowSelection: TableProps<OccupancyRow>['rowSelection'];
|
||||
batchAction: 'checkout' | 'archive' | 'restore';
|
||||
canDelete: boolean;
|
||||
canPurge: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button, DatePicker, Input, InputNumber, Space, Switch, Tooltip, Upload } from 'antd';
|
||||
import { Button, DatePicker, Input, InputNumber, Space, Switch, Tooltip, Upload, type UploadProps } from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
ExportOutlined,
|
||||
@@ -20,7 +20,7 @@ export const OccupanciesToolbar: React.FC<{
|
||||
onChangeDateRange: (dates: [Dayjs | null, Dayjs | null] | null) => void;
|
||||
canCheckIn: boolean;
|
||||
onCheckIn: () => void;
|
||||
onImport: (options: any) => void;
|
||||
onImport: UploadProps['customRequest'];
|
||||
autoDeposit: boolean;
|
||||
onAutoDepositChange: (value: boolean) => void;
|
||||
depositAmount: number;
|
||||
|
||||
@@ -60,10 +60,10 @@ const buildOccupancyDataColumns = () => {
|
||||
title: '退宿日期',
|
||||
dataIndex: 'checkOutDate',
|
||||
width: 110,
|
||||
render: (v: any) => v || <Tag color="green">在住</Tag>,
|
||||
render: (v: string | null | undefined) => v || <Tag color="green">在住</Tag>,
|
||||
},
|
||||
{ title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' },
|
||||
{ title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' },
|
||||
{ title: '计费截止', dataIndex: 'billingEndDate', render: (v: string | null | undefined) => v || '-' },
|
||||
{ title: '退宿原因', dataIndex: 'checkOutReason', render: (v: string | null | undefined) => v || '-' },
|
||||
];
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ const buildOccupancyActionColumn = (ctx: OccupancyColumnContext) => {
|
||||
return {
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_: any, record: OccupancyRow) =>
|
||||
render: (_: unknown, record: OccupancyRow) =>
|
||||
readonly ? (
|
||||
<Space>
|
||||
<Tag color="#999">已归档</Tag>
|
||||
|
||||
@@ -15,6 +15,34 @@ import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
import type { OccupancyRow } from './OccupancyColumns';
|
||||
|
||||
export interface OccupancyStudentOption {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
studentNo?: string | null;
|
||||
idNumber?: string | null;
|
||||
phone?: string | null;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface OccupancyRoomOption {
|
||||
id: number;
|
||||
roomNumber: string;
|
||||
building?: string;
|
||||
status?: string;
|
||||
capacity?: number;
|
||||
currentCount?: number;
|
||||
floor?: number | null;
|
||||
roomType?: string;
|
||||
}
|
||||
|
||||
export interface AvailableResource {
|
||||
id: number;
|
||||
name?: string;
|
||||
label?: string;
|
||||
bedNumber?: string;
|
||||
lockerNumber?: string;
|
||||
}
|
||||
|
||||
export type FormRule = React.ComponentProps<typeof Form.Item>['rules'];
|
||||
|
||||
export const DateFormItem: React.FC<{
|
||||
@@ -45,14 +73,14 @@ export const CheckInModal: React.FC<{
|
||||
canCheckIn: boolean;
|
||||
saving: boolean;
|
||||
form: ReturnType<typeof Form.useForm>[0];
|
||||
students: any[];
|
||||
students: OccupancyStudentOption[];
|
||||
activeOccupancyByStudentId: Map<number, OccupancyRow>;
|
||||
rooms: any[];
|
||||
roomOptionLabel: (room: any) => string;
|
||||
isRoomSelectable: (room: any) => boolean;
|
||||
rooms: OccupancyRoomOption[];
|
||||
roomOptionLabel: (room: OccupancyRoomOption) => string;
|
||||
isRoomSelectable: (room: OccupancyRoomOption) => boolean;
|
||||
onRoomChange: (roomId: number) => void;
|
||||
availableBeds: any[];
|
||||
availableLockers: any[];
|
||||
availableBeds: AvailableResource[];
|
||||
availableLockers: AvailableResource[];
|
||||
availableResourcesLoading: boolean;
|
||||
selectedCheckInRoomId?: number;
|
||||
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
|
||||
@@ -304,7 +332,7 @@ export const BatchCheckOutModal: React.FC<{
|
||||
selectedRowKeys: number[];
|
||||
latestSelectedCheckInDate?: string;
|
||||
latestSelectedBillingStartDate?: string;
|
||||
data: any[];
|
||||
data: OccupancyRow[];
|
||||
form: ReturnType<typeof Form.useForm>[0];
|
||||
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
|
||||
onOk: () => void;
|
||||
@@ -389,8 +417,8 @@ export const BatchCheckOutModal: React.FC<{
|
||||
>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>即将退宿的学生:</div>
|
||||
{data
|
||||
.filter((r: any) => selectedRowKeys.includes(r.id))
|
||||
.map((r: any) => (
|
||||
.filter((r: OccupancyRow) => selectedRowKeys.includes(r.id))
|
||||
.map((r: OccupancyRow) => (
|
||||
<Tag key={r.id} style={{ marginBottom: 4 }}>
|
||||
{r.student?.name} ({r.room?.roomNumber})
|
||||
</Tag>
|
||||
@@ -405,12 +433,12 @@ export const TransferModal: React.FC<{
|
||||
canTransfer: boolean;
|
||||
saving: boolean;
|
||||
form: ReturnType<typeof Form.useForm>[0];
|
||||
rooms: any[];
|
||||
roomOptionLabel: (room: any) => string;
|
||||
isRoomSelectable: (room: any) => boolean;
|
||||
rooms: OccupancyRoomOption[];
|
||||
roomOptionLabel: (room: OccupancyRoomOption) => string;
|
||||
isRoomSelectable: (room: OccupancyRoomOption) => boolean;
|
||||
onRoomChange: (roomId: number) => void;
|
||||
transferAvailableBeds: any[];
|
||||
transferAvailableLockers: any[];
|
||||
transferAvailableBeds: AvailableResource[];
|
||||
transferAvailableLockers: AvailableResource[];
|
||||
transferResourcesLoading: boolean;
|
||||
selectedTransferRoomId?: number;
|
||||
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
|
||||
|
||||
@@ -26,6 +26,23 @@ import { useOccupancyMutations } from './useOccupancyMutations';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { NextStepHint } from '../../components/NextStepHint';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
|
||||
interface AvailableResource {
|
||||
id: number;
|
||||
name?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface BatchResult {
|
||||
message?: string;
|
||||
success?: number;
|
||||
skipped?: number;
|
||||
}
|
||||
|
||||
interface OccupancyImportResult {
|
||||
message?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
import { useDownload } from '../../hooks/useDownload';
|
||||
|
||||
interface StudentLookupRow {
|
||||
@@ -59,9 +76,9 @@ const OccupanciesPage: React.FC = () => {
|
||||
const canDelete = permissionsReady && hasPermission('occupancy:delete');
|
||||
const canPurge = permissionsReady && hasPermission('occupancy:purge');
|
||||
const [checkInModal, setCheckInModal] = useState(false);
|
||||
const [checkOutModal, setCheckOutModal] = useState<any>(null);
|
||||
const [editModal, setEditModal] = useState<any>(null);
|
||||
const [transferModal, setTransferModal] = useState<any>(null);
|
||||
const [checkOutModal, setCheckOutModal] = useState<OccupancyRow | null>(null);
|
||||
const [editModal, setEditModal] = useState<OccupancyRow | null>(null);
|
||||
const [transferModal, setTransferModal] = useState<OccupancyRow | null>(null);
|
||||
// 入住成功后的「下一步」引导提示
|
||||
const [nextStepHint, setNextStepHint] = useState<'billing' | null>(null);
|
||||
const [viewMode, setViewMode] = useState<OccupancyView>('active');
|
||||
@@ -145,11 +162,11 @@ const OccupanciesPage: React.FC = () => {
|
||||
const [editForm] = Form.useForm();
|
||||
const [transferForm] = Form.useForm();
|
||||
const [batchCheckOutForm] = Form.useForm();
|
||||
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
|
||||
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
|
||||
const [availableBeds, setAvailableBeds] = useState<AvailableResource[]>([]);
|
||||
const [availableLockers, setAvailableLockers] = useState<AvailableResource[]>([]);
|
||||
const [availableResourcesLoading, setAvailableResourcesLoading] = useState(false);
|
||||
const [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]);
|
||||
const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]);
|
||||
const [transferAvailableBeds, setTransferAvailableBeds] = useState<AvailableResource[]>([]);
|
||||
const [transferAvailableLockers, setTransferAvailableLockers] = useState<AvailableResource[]>([]);
|
||||
const [transferResourcesLoading, setTransferResourcesLoading] = useState(false);
|
||||
const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm);
|
||||
const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm);
|
||||
@@ -220,18 +237,18 @@ const OccupanciesPage: React.FC = () => {
|
||||
setAvailableResourcesLoading(true);
|
||||
try {
|
||||
const [beds, lockers] = await Promise.all([
|
||||
api.get<any[]>(`/rooms/${roomId}/beds/available`),
|
||||
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
|
||||
api.get<AvailableResource[]>(`/rooms/${roomId}/beds/available`),
|
||||
api.get<AvailableResource[]>(`/rooms/${roomId}/lockers/available`),
|
||||
]);
|
||||
setAvailableBeds(beds);
|
||||
setAvailableLockers(lockers);
|
||||
if (beds.length === 1) checkInForm.setFieldValue('bedId', beds[0].id);
|
||||
if (beds.length === 0) message.warning('该宿舍暂无可用床位,请先在宿舍详情添加或释放床位');
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
setAvailableBeds([]);
|
||||
setAvailableLockers([]);
|
||||
message.error(e?.message || '宿舍床位和柜子加载失败');
|
||||
message.error(e instanceof Error ? e.message : '宿舍床位和柜子加载失败');
|
||||
} finally {
|
||||
setAvailableResourcesLoading(false);
|
||||
}
|
||||
@@ -246,18 +263,18 @@ const OccupanciesPage: React.FC = () => {
|
||||
setTransferResourcesLoading(true);
|
||||
try {
|
||||
const [beds, lockers] = await Promise.all([
|
||||
api.get<any[]>(`/rooms/${roomId}/beds/available`),
|
||||
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
|
||||
api.get<AvailableResource[]>(`/rooms/${roomId}/beds/available`),
|
||||
api.get<AvailableResource[]>(`/rooms/${roomId}/lockers/available`),
|
||||
]);
|
||||
setTransferAvailableBeds(beds);
|
||||
setTransferAvailableLockers(lockers);
|
||||
if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id);
|
||||
if (beds.length === 0) message.warning('目标宿舍暂无可用床位,请先在宿舍详情添加或释放床位');
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
setTransferAvailableBeds([]);
|
||||
setTransferAvailableLockers([]);
|
||||
message.error(e?.message || '目标宿舍床位和柜子加载失败');
|
||||
message.error(e instanceof Error ? e.message : '目标宿舍床位和柜子加载失败');
|
||||
} finally {
|
||||
setTransferResourcesLoading(false);
|
||||
}
|
||||
@@ -267,7 +284,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
if (!searchText) return data;
|
||||
const keyword = searchText.toLowerCase();
|
||||
return data.filter(
|
||||
(r: any) =>
|
||||
(r: OccupancyRow) =>
|
||||
r.student?.name?.toLowerCase().includes(keyword) ||
|
||||
r.room?.roomNumber?.toLowerCase().includes(keyword),
|
||||
);
|
||||
@@ -291,6 +308,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleCheckOut = async () => {
|
||||
if (!checkOutModal) return;
|
||||
const values = await checkOutForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -313,6 +331,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!editModal) return;
|
||||
const values = await editForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -331,6 +350,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!transferModal) return;
|
||||
const values = await transferForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -353,12 +373,12 @@ const OccupanciesPage: React.FC = () => {
|
||||
const values = await batchCheckOutForm.validateFields();
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await batchCheckOutMutation.mutateAsync({
|
||||
const res = (await batchCheckOutMutation.mutateAsync({
|
||||
ids: selectedRowKeys,
|
||||
checkOutDate: values.checkOutDate.format('YYYY-MM-DD'),
|
||||
billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'),
|
||||
checkOutReason: values.checkOutReason,
|
||||
});
|
||||
})) as BatchResult;
|
||||
message.success(res.message || `已成功退宿 ${res.success} 人`);
|
||||
setBatchCheckOutModal(false);
|
||||
batchCheckOutForm.resetFields();
|
||||
@@ -374,7 +394,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await batchDeleteMutation.mutateAsync(selectedRowKeys);
|
||||
const res = (await batchDeleteMutation.mutateAsync(selectedRowKeys)) as BatchResult;
|
||||
message.success(res?.message || `已归档 ${selectedRowKeys.length} 条`);
|
||||
setSelectedRowKeys([]);
|
||||
} catch {
|
||||
@@ -425,7 +445,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await batchPurgeMutation.mutateAsync(selectedRowKeys);
|
||||
const res = (await batchPurgeMutation.mutateAsync(selectedRowKeys)) as BatchResult;
|
||||
message.success(res?.message || `已永久删除 ${selectedRowKeys.length} 条`);
|
||||
setSelectedRowKeys([]);
|
||||
} catch {
|
||||
@@ -482,8 +502,8 @@ const OccupanciesPage: React.FC = () => {
|
||||
const rowSelection = useMemo(
|
||||
() => ({
|
||||
selectedRowKeys,
|
||||
onChange: (keys: any[]) => setSelectedRowKeys(keys),
|
||||
getCheckboxProps: (record: any) =>
|
||||
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys as number[]),
|
||||
getCheckboxProps: (record: OccupancyRow) =>
|
||||
viewMode === 'active' ? { disabled: !!record.checkOutDate } : {},
|
||||
}),
|
||||
[selectedRowKeys, viewMode],
|
||||
@@ -526,20 +546,21 @@ const OccupanciesPage: React.FC = () => {
|
||||
onChangeDateRange={changeDateRange}
|
||||
canCheckIn={canCheckIn}
|
||||
onCheckIn={openCheckInModal}
|
||||
onImport={async ({ file, onSuccess, onError }: any) => {
|
||||
onImport={async ({ file, onSuccess, onError }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const params = new URLSearchParams();
|
||||
if (autoDeposit) { params.set('autoDeposit', 'true'); params.set('depositAmount', String(depositAmount)); }
|
||||
try {
|
||||
const res: any = await importMutation.mutateAsync({
|
||||
const res = (await importMutation.mutateAsync({
|
||||
formData,
|
||||
params: params.toString(),
|
||||
});
|
||||
if (res.errors?.length > 0) {
|
||||
})) as OccupancyImportResult;
|
||||
const errors = res.errors ?? [];
|
||||
if (errors.length > 0) {
|
||||
modal.warning({
|
||||
title: res.message,
|
||||
content: res.errors.join('\n'),
|
||||
content: errors.join('\n'),
|
||||
width: 500,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -28,6 +28,19 @@ const OperationLogsPage: React.FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [filterModule, setFilterModule] = useState<string | undefined>();
|
||||
|
||||
interface OperationLogRow {
|
||||
id: number;
|
||||
module?: string;
|
||||
action?: string;
|
||||
username?: string;
|
||||
detail?: string | null;
|
||||
ipAddress?: string | null;
|
||||
status?: string;
|
||||
createdAt?: string;
|
||||
targetType?: string | null;
|
||||
targetId?: number | null;
|
||||
}
|
||||
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
||||
|
||||
const {
|
||||
@@ -36,16 +49,16 @@ const OperationLogsPage: React.FC = () => {
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<{ data: any[]; total: number }>({
|
||||
} = useQuery<{ data: OperationLogRow[]; total: number }>({
|
||||
queryKey: ['operation-logs', page, pageSize, filterModule, dateRange],
|
||||
queryFn: async () => {
|
||||
const params: any = { page, pageSize };
|
||||
const params: Record<string, unknown> = { page, pageSize };
|
||||
if (filterModule) params.module = filterModule;
|
||||
if (dateRange) {
|
||||
params.startDate = dateRange[0];
|
||||
params.endDate = dateRange[1];
|
||||
}
|
||||
return validateResponse<{ data: any[]; total: number }>(
|
||||
return validateResponse<{ data: OperationLogRow[]; total: number }>(
|
||||
operationLogsSchema,
|
||||
await api.get('/operation-logs', { params }),
|
||||
);
|
||||
|
||||
@@ -250,7 +250,7 @@ const RolesPage: React.FC = () => {
|
||||
title: '操作',
|
||||
width: 180,
|
||||
fixed: 'right' as const,
|
||||
render: (_: any, record: RoleItem) => (
|
||||
render: (_: unknown, record: RoleItem) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="role:edit"
|
||||
|
||||
@@ -19,7 +19,53 @@ import { usePermission } from '../../hooks/usePermission';
|
||||
import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
|
||||
function getCardStyle(room: any): React.CSSProperties {
|
||||
interface RoomVisualOccupant {
|
||||
studentId: number;
|
||||
occupancyId: number;
|
||||
studentName: string;
|
||||
checkInDate?: string | null;
|
||||
days?: number;
|
||||
billingStartDate?: string | null;
|
||||
bedNumber?: string | null;
|
||||
supervisor?: string | null;
|
||||
organization?: string | null;
|
||||
organizationId?: number | null;
|
||||
organizationName?: string | null;
|
||||
organizationColor?: string | null;
|
||||
inspectionStatus?: 'present' | 'absent' | null;
|
||||
}
|
||||
|
||||
interface RoomVisualInspection {
|
||||
submitted: boolean;
|
||||
source?: string;
|
||||
inspectorName?: string;
|
||||
}
|
||||
|
||||
interface RoomVisualRoom {
|
||||
id: number;
|
||||
name: string;
|
||||
roomNumber?: string;
|
||||
floor?: number | string | null;
|
||||
building?: string | null;
|
||||
status?: string;
|
||||
currentCount: number;
|
||||
capacity: number;
|
||||
totalBeds?: number;
|
||||
occupiedBeds?: number;
|
||||
organizationColor?: string | null;
|
||||
organizationIds?: number[];
|
||||
orgLabel?: string | null;
|
||||
occupants: RoomVisualOccupant[];
|
||||
inspection?: RoomVisualInspection | null;
|
||||
}
|
||||
|
||||
interface RoomVisualData {
|
||||
rooms: RoomVisualRoom[];
|
||||
buildings: string[];
|
||||
organizations: Array<{ id: number; name: string; color?: string | null }>;
|
||||
}
|
||||
|
||||
function getCardStyle(room: RoomVisualRoom): React.CSSProperties {
|
||||
let base: React.CSSProperties;
|
||||
if (room.status === 'maintenance') base = { background: '#f5f5f5', borderColor: '#d9d9d9' };
|
||||
else if (room.currentCount === 0) base = { background: '#f6ffed', borderColor: '#b7eb8f' };
|
||||
@@ -35,19 +81,19 @@ function getCardStyle(room: any): React.CSSProperties {
|
||||
return base;
|
||||
}
|
||||
|
||||
function getStatusLabel(room: any) {
|
||||
function getStatusLabel(room: RoomVisualRoom) {
|
||||
if (room.status === 'maintenance') return <Tag color="default">维修中</Tag>;
|
||||
if (room.currentCount === 0) return <Tag color="success">空闲</Tag>;
|
||||
if (room.currentCount >= room.capacity) return <Tag color="error">满员</Tag>;
|
||||
return <Tag color="processing">部分入住</Tag>;
|
||||
}
|
||||
|
||||
function getOrganizationTags(occupants: any[]) {
|
||||
function getOrganizationTags(occupants: RoomVisualOccupant[]) {
|
||||
const organizationList = [
|
||||
...new Map(
|
||||
occupants
|
||||
.filter((o: any) => o.organizationName)
|
||||
.map((o: any) => [
|
||||
.filter((o: RoomVisualOccupant) => o.organizationName)
|
||||
.map((o: RoomVisualOccupant) => [
|
||||
o.organizationId,
|
||||
{ name: o.organizationName, color: o.organizationColor },
|
||||
]),
|
||||
@@ -73,7 +119,7 @@ function getOrganizationTags(occupants: any[]) {
|
||||
const RoomVisualPage: React.FC = () => {
|
||||
const [selectedBuilding, setSelectedBuilding] = useState<string>('all');
|
||||
const [selectedOrganization, setSelectedOrganization] = useState<number | 'all'>('all');
|
||||
const [detailRoom, setDetailRoom] = useState<any>(null);
|
||||
const [detailRoom, setDetailRoom] = useState<RoomVisualRoom | null>(null);
|
||||
const [asOf, setAsOf] = useState<Dayjs | null>(null);
|
||||
const [presentOccupancyIds, setPresentOccupancyIds] = useState<number[]>([]);
|
||||
const [inspectionSaving, setInspectionSaving] = useState(false);
|
||||
@@ -82,7 +128,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isFetching, isError } = useQuery<any>({
|
||||
const { data, isLoading, isFetching, isError } = useQuery<RoomVisualData>({
|
||||
queryKey: ['rooms', 'visual', isHistorical, asOf],
|
||||
queryFn: async () => {
|
||||
const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined;
|
||||
@@ -91,7 +137,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
});
|
||||
const loading = isLoading || isFetching;
|
||||
|
||||
const openRoomDetail = (room: any) => {
|
||||
const openRoomDetail = (room: RoomVisualRoom) => {
|
||||
setDetailRoom(room);
|
||||
setPresentOccupancyIds(
|
||||
getInitialPresentOccupancyIds(room.occupants || [], room.inspection?.submitted === true),
|
||||
@@ -109,9 +155,9 @@ const RoomVisualPage: React.FC = () => {
|
||||
});
|
||||
message.success(detailRoom.inspection?.submitted ? '查寝记录已更新' : '查寝已提交');
|
||||
const params = isHistorical ? { asOf: inspectionDate } : undefined;
|
||||
const res: any = await api.get('/rooms/visual', { params });
|
||||
const res = (await api.get('/rooms/visual', { params })) as RoomVisualData;
|
||||
queryClient.setQueryData(['rooms', 'visual', isHistorical, asOf], res);
|
||||
const updatedRoom = res.rooms.find((room: any) => room.id === detailRoom.id);
|
||||
const updatedRoom = res.rooms.find((room: RoomVisualRoom) => room.id === detailRoom.id);
|
||||
if (updatedRoom) setDetailRoom(updatedRoom);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '查寝提交失败'));
|
||||
@@ -144,7 +190,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const rooms = data.rooms.filter((r: any) => {
|
||||
const rooms = data.rooms.filter((r: RoomVisualRoom) => {
|
||||
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;
|
||||
if (selectedOrganization !== 'all' && !(r.organizationIds || []).includes(selectedOrganization))
|
||||
return false;
|
||||
@@ -153,12 +199,12 @@ const RoomVisualPage: React.FC = () => {
|
||||
|
||||
const totalRooms = rooms.length;
|
||||
const emptyRooms = rooms.filter(
|
||||
(r: any) => r.currentCount === 0 && r.status !== 'maintenance',
|
||||
(r: RoomVisualRoom) => r.currentCount === 0 && r.status !== 'maintenance',
|
||||
).length;
|
||||
const totalBeds = rooms.reduce((sum: number, r: any) => sum + (r.totalBeds || 0), 0);
|
||||
const occupiedBeds = rooms.reduce((sum: number, r: any) => sum + (r.occupiedBeds || 0), 0);
|
||||
const totalBeds = rooms.reduce((sum: number, r: RoomVisualRoom) => sum + (r.totalBeds || 0), 0);
|
||||
const occupiedBeds = rooms.reduce((sum: number, r: RoomVisualRoom) => sum + (r.occupiedBeds || 0), 0);
|
||||
const availableBedsCount = totalBeds - occupiedBeds;
|
||||
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
|
||||
const fullRooms = rooms.filter((r: RoomVisualRoom) => r.currentCount >= r.capacity).length;
|
||||
|
||||
/* getCardStyle, getStatusLabel, getOrganizationTags are now standalone functions outside the component */
|
||||
|
||||
@@ -200,7 +246,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
style={{ width: 180 }}
|
||||
options={[
|
||||
{ value: 'all', label: '全部机构' },
|
||||
...(data.organizations || []).map((t: any) => ({
|
||||
...(data.organizations || []).map((t: RoomVisualData['organizations'][number]) => ({
|
||||
value: t.id,
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
@@ -279,7 +325,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
|
||||
{/* 房态网格 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
{rooms.map((room: any) => (
|
||||
{rooms.map((room: RoomVisualRoom) => (
|
||||
<Col xs={12} sm={8} md={6} lg={4} key={room.id}>
|
||||
<Card
|
||||
size="small"
|
||||
@@ -331,11 +377,11 @@ const RoomVisualPage: React.FC = () => {
|
||||
{room.building && <span>{room.building} </span>}
|
||||
{room.floor && <span>{room.floor}F</span>}
|
||||
</div>
|
||||
{room.totalBeds > 0 && (
|
||||
{(room.totalBeds ?? 0) > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: room.occupiedBeds >= room.totalBeds ? '#FF3B30' : '#34C759',
|
||||
color: (room.occupiedBeds ?? 0) >= (room.totalBeds ?? 0) ? '#FF3B30' : '#34C759',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
@@ -370,7 +416,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
className="room-card-tag-wrapper"
|
||||
style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 6 }}
|
||||
>
|
||||
{room.occupants.slice(0, 4).map((o: any) => (
|
||||
{room.occupants.slice(0, 4).map((o: RoomVisualOccupant) => (
|
||||
<Tooltip key={o.studentId} title={`入住 ${o.days} 天 (${o.checkInDate} 起)`}>
|
||||
<Tag
|
||||
style={{ margin: '0 4px 4px 0', fontSize: 12, maxWidth: '100%' }}
|
||||
@@ -455,7 +501,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{detailRoom.occupants.map((o: any) => (
|
||||
{detailRoom.occupants.map((o: RoomVisualOccupant) => (
|
||||
<Card
|
||||
key={o.occupancyId}
|
||||
size="small"
|
||||
@@ -537,7 +583,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() =>
|
||||
setPresentOccupancyIds(
|
||||
detailRoom.occupants.map((occupant: any) => occupant.occupancyId),
|
||||
detailRoom.occupants.map((occupant: RoomVisualOccupant) => occupant.occupancyId),
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -48,6 +48,21 @@ export interface LockerItem {
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface RoomRecord {
|
||||
id: number;
|
||||
roomNumber: string;
|
||||
building?: string | null;
|
||||
floor?: number | null;
|
||||
roomType?: string | null;
|
||||
rentalCategory?: string | null;
|
||||
monthlyRate?: number | string | null;
|
||||
capacity?: number;
|
||||
currentCount?: number;
|
||||
status?: string;
|
||||
beds?: BedItem[];
|
||||
lockers?: LockerItem[];
|
||||
}
|
||||
|
||||
export function parseRoomNumber(input: string) {
|
||||
const match = /^(\d+)-(\d+)/.exec(input.trim());
|
||||
if (!match) return null;
|
||||
@@ -104,12 +119,12 @@ export interface RoomColumnContext {
|
||||
canEditRooms: boolean;
|
||||
canDeleteRooms: boolean;
|
||||
canPurgeRooms: boolean;
|
||||
onSaveRoomCell: (record: any, field: string, value: unknown) => Promise<void> | void;
|
||||
onSaveRoomCell: (record: RoomRecord, field: string, value: unknown) => Promise<void> | void;
|
||||
onRestore: (id: number) => Promise<unknown> | unknown;
|
||||
onArchive: (id: number) => Promise<unknown> | unknown;
|
||||
onPurge: (id: number, name: string) => void;
|
||||
onView: (record: any) => void;
|
||||
onEdit: (record: any) => void;
|
||||
onView: (record: RoomRecord) => void;
|
||||
onEdit: (record: RoomRecord) => void;
|
||||
}
|
||||
|
||||
function buildRoomIdentityColumns(ctx: RoomColumnContext) {
|
||||
@@ -119,8 +134,8 @@ function buildRoomIdentityColumns(ctx: RoomColumnContext) {
|
||||
title: '房间号',
|
||||
dataIndex: 'roomNumber',
|
||||
width: 100,
|
||||
sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber),
|
||||
render: (v: string, r: any) => (
|
||||
sorter: (a: RoomRecord, b: RoomRecord) => a.roomNumber.localeCompare(b.roomNumber),
|
||||
render: (v: string, r: RoomRecord) => (
|
||||
<EditableRoomCell value={v} field="roomNumber" record={r} required onSave={onSaveRoomCell}>
|
||||
{v}
|
||||
</EditableRoomCell>
|
||||
@@ -130,7 +145,7 @@ function buildRoomIdentityColumns(ctx: RoomColumnContext) {
|
||||
title: '楼栋',
|
||||
dataIndex: 'building',
|
||||
width: 80,
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: RoomRecord) => (
|
||||
<EditableRoomCell value={v} field="building" record={r} onSave={onSaveRoomCell}>
|
||||
{v || '-'}
|
||||
</EditableRoomCell>
|
||||
@@ -140,7 +155,7 @@ function buildRoomIdentityColumns(ctx: RoomColumnContext) {
|
||||
title: '楼层',
|
||||
dataIndex: 'floor',
|
||||
width: 80,
|
||||
render: (v: number, r: any) => (
|
||||
render: (v: number, r: RoomRecord) => (
|
||||
<EditableRoomCell value={v} field="floor" record={r} editor="number" onSave={onSaveRoomCell}>
|
||||
{v ?? '-'}
|
||||
</EditableRoomCell>
|
||||
@@ -150,7 +165,7 @@ function buildRoomIdentityColumns(ctx: RoomColumnContext) {
|
||||
title: '类型',
|
||||
dataIndex: 'roomType',
|
||||
width: 90,
|
||||
render: (v: any, r: any) => (
|
||||
render: (v: string | null | undefined, r: RoomRecord) => (
|
||||
<EditableRoomCell value={v} field="roomType" record={r} onSave={onSaveRoomCell}>
|
||||
{v || '-'}
|
||||
</EditableRoomCell>
|
||||
@@ -160,7 +175,7 @@ function buildRoomIdentityColumns(ctx: RoomColumnContext) {
|
||||
title: '租赁类型',
|
||||
dataIndex: 'rentalCategory',
|
||||
width: 100,
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: RoomRecord) => (
|
||||
<EditableRoomCell
|
||||
value={v}
|
||||
field="rentalCategory"
|
||||
@@ -183,7 +198,7 @@ function buildRoomIdentityColumns(ctx: RoomColumnContext) {
|
||||
title: '月租金',
|
||||
dataIndex: 'monthlyRate',
|
||||
width: 100,
|
||||
render: (v: number, r: any) => (
|
||||
render: (v: number, r: RoomRecord) => (
|
||||
<EditableRoomCell value={v} field="monthlyRate" record={r} editor="money" min={0} onSave={onSaveRoomCell}>
|
||||
{v ? `¥${v}` : '-'}
|
||||
</EditableRoomCell>
|
||||
@@ -199,7 +214,7 @@ function buildRoomStatusColumns(ctx: RoomColumnContext) {
|
||||
title: '额定人数',
|
||||
dataIndex: 'capacity',
|
||||
width: 80,
|
||||
render: (v: number, r: any) => (
|
||||
render: (v: number, r: RoomRecord) => (
|
||||
<EditableRoomCell value={v} field="capacity" record={r} editor="number" min={1} required onSave={onSaveRoomCell}>
|
||||
{v}
|
||||
</EditableRoomCell>
|
||||
@@ -208,7 +223,7 @@ function buildRoomStatusColumns(ctx: RoomColumnContext) {
|
||||
{
|
||||
title: '当前入住',
|
||||
width: 80,
|
||||
render: (_: any, r: any) =>
|
||||
render: (_: unknown, r: RoomRecord) =>
|
||||
r.status === 'archived' ? (
|
||||
<Tag color="#999">-</Tag>
|
||||
) : (
|
||||
@@ -216,7 +231,7 @@ function buildRoomStatusColumns(ctx: RoomColumnContext) {
|
||||
count={r.currentCount}
|
||||
showZero
|
||||
overflowCount={99}
|
||||
style={{ backgroundColor: r.currentCount >= r.capacity ? '#ff4d4f' : '#52c41a' }}
|
||||
style={{ backgroundColor: (r.currentCount ?? 0) >= (r.capacity ?? 0) ? '#ff4d4f' : '#52c41a' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -224,7 +239,7 @@ function buildRoomStatusColumns(ctx: RoomColumnContext) {
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string, r: any) => (
|
||||
render: (s: string, r: RoomRecord) => (
|
||||
<EditableRoomCell
|
||||
value={s}
|
||||
field="status"
|
||||
@@ -255,8 +270,8 @@ function buildRoomActionColumn(ctx: RoomColumnContext) {
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 220,
|
||||
render: (_: unknown, record: unknown) => {
|
||||
const r = record as { status?: string; id: number; roomNumber?: string };
|
||||
render: (_: unknown, record: RoomRecord) => {
|
||||
const r = record;
|
||||
return (
|
||||
<Space>
|
||||
{r.status === 'archived' ? (
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
statusMap,
|
||||
type BedItem,
|
||||
type LockerItem,
|
||||
type RoomRecord,
|
||||
} from './RoomColumns';
|
||||
|
||||
export interface RoomDrawerProps {
|
||||
open: boolean;
|
||||
room: any;
|
||||
room: RoomRecord | null;
|
||||
beds: BedItem[];
|
||||
lockers: LockerItem[];
|
||||
bedsError: boolean;
|
||||
@@ -71,10 +72,9 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
|
||||
onDeleteLocker,
|
||||
onSaveLockerCell,
|
||||
}) => {
|
||||
const roomItemActions = (kind: 'bed' | 'locker') => (r: any) => {
|
||||
const roomItemActions = (kind: 'bed' | 'locker') => (r: BedItem | LockerItem) => {
|
||||
const isBed = kind === 'bed';
|
||||
const handleDelete = isBed ? onDeleteBed : onDeleteLocker;
|
||||
const handleEdit = isBed ? onEditBed : onEditLocker;
|
||||
return (
|
||||
<Space size="small">
|
||||
<PermissionButton
|
||||
@@ -82,7 +82,7 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={room?.status === 'archived'}
|
||||
onClick={() => handleEdit(r)}
|
||||
onClick={() => (isBed ? onEditBed(r as BedItem) : onEditLocker(r as LockerItem))}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
@@ -144,8 +144,8 @@ export const RoomDrawer: React.FC<RoomDrawerProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<strong>状态:</strong>
|
||||
<Tag color={statusMap[room.status]?.color}>
|
||||
{statusMap[room.status]?.text}
|
||||
<Tag color={statusMap[room.status ?? '']?.color}>
|
||||
{statusMap[room.status ?? '']?.text}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, InputNumber, Modal, Select } from 'antd';
|
||||
import { RoomDrawer } from './RoomDrawer';
|
||||
import type { BedItem, LockerItem } from './RoomColumns';
|
||||
import type { BedItem, LockerItem, RoomRecord } from './RoomColumns';
|
||||
import { parseRoomNumber } from './RoomColumns';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
|
||||
@@ -167,7 +167,7 @@ export const RoomDetailArea: React.FC<{
|
||||
onSaveRoom: () => void;
|
||||
onCloseRoomModal: () => void;
|
||||
drawerOpen: boolean;
|
||||
drawerRoom: any;
|
||||
drawerRoom: RoomRecord | null;
|
||||
beds: BedItem[];
|
||||
lockers: LockerItem[];
|
||||
bedsError: boolean;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { Table, type TableProps } from 'antd';
|
||||
import type { RoomRecord } from './RoomColumns';
|
||||
import { QueryEmpty } from '../../components/QueryState';
|
||||
|
||||
export const RoomsTable: React.FC<{
|
||||
columns: any[];
|
||||
data: any[];
|
||||
columns: TableProps<RoomRecord>['columns'];
|
||||
data: RoomRecord[];
|
||||
loading: boolean;
|
||||
selectedRowKeys: number[];
|
||||
onSelect: (keys: number[]) => void;
|
||||
|
||||
@@ -17,7 +17,7 @@ import { selectArchiveRecords } from '../archive-view';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { useRoomColumns, type BedItem, type LockerItem } from './RoomColumns';
|
||||
import { useRoomColumns, type BedItem, type LockerItem, type RoomRecord } from './RoomColumns';
|
||||
import { RoomDetailArea } from './RoomModals';
|
||||
import { RoomsToolbar } from './RoomsToolbar';
|
||||
import { RoomsTable } from './RoomsTable';
|
||||
@@ -31,7 +31,7 @@ const RoomsPage: React.FC = () => {
|
||||
const canDeleteRooms = permissionsReady && hasPermission('room:delete');
|
||||
const canPurgeRooms = permissionsReady && hasPermission('room:purge');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [editing, setEditing] = useState<RoomRecord | null>(null);
|
||||
const canSaveRoom = editing ? canEditRooms : canCreateRooms;
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
@@ -43,13 +43,13 @@ const RoomsPage: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const roomGuard = useDirtyGuard(form);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerRoom, setDrawerRoom] = useState<any>(null);
|
||||
const [drawerRoom, setDrawerRoom] = useState<RoomRecord | null>(null);
|
||||
const [beds, setBeds] = useState<BedItem[]>([]);
|
||||
const [lockers, setLockers] = useState<LockerItem[]>([]);
|
||||
const [bedModalOpen, setBedModalOpen] = useState(false);
|
||||
const [bedEditing, setBedEditing] = useState<any>(null);
|
||||
const [bedEditing, setBedEditing] = useState<BedItem | null>(null);
|
||||
const [lockerModalOpen, setLockerModalOpen] = useState(false);
|
||||
const [lockerEditing, setLockerEditing] = useState<any>(null);
|
||||
const [lockerEditing, setLockerEditing] = useState<LockerItem | null>(null);
|
||||
const [bedForm] = Form.useForm();
|
||||
const bedGuard = useDirtyGuard(bedForm);
|
||||
const [lockerForm] = Form.useForm();
|
||||
@@ -66,13 +66,13 @@ const RoomsPage: React.FC = () => {
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<any[]>({
|
||||
} = useQuery<RoomRecord[]>({
|
||||
queryKey: ['rooms', 'overview', showArchived],
|
||||
queryFn: async () => {
|
||||
const params: any = { includeArchived: showArchived ? 'true' : undefined };
|
||||
const res: any = await api.get('/rooms/overview', { params });
|
||||
const params: Record<string, unknown> = { includeArchived: showArchived ? 'true' : undefined };
|
||||
const res = (await api.get('/rooms/overview', { params })) as RoomRecord[];
|
||||
return selectArchiveRecords(
|
||||
validateResponse<any[]>(roomsOverviewSchema, res),
|
||||
validateResponse<RoomRecord[]>(roomsOverviewSchema, res),
|
||||
showArchived ? 'archived' : 'active',
|
||||
);
|
||||
},
|
||||
@@ -107,7 +107,7 @@ const RoomsPage: React.FC = () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await batchDeleteMutation.mutateAsync(selectedRowKeys);
|
||||
const res = (await batchDeleteMutation.mutateAsync(selectedRowKeys)) as { message?: string };
|
||||
message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 间`);
|
||||
setSelectedRowKeys([]);
|
||||
} catch {
|
||||
@@ -134,7 +134,7 @@ const RoomsPage: React.FC = () => {
|
||||
}, [batchLoading, batchRestoreMutation, selectedRowKeys]);
|
||||
|
||||
const buildings = useMemo(() => {
|
||||
const set = new Set(data.flatMap((r: any) => (r.building ? [r.building] : [])));
|
||||
const set = new Set(data.flatMap((r: RoomRecord) => (r.building ? [r.building] : [])));
|
||||
return [...set].sort();
|
||||
}, [data]);
|
||||
|
||||
@@ -143,18 +143,14 @@ const RoomsPage: React.FC = () => {
|
||||
if (searchText) {
|
||||
const keyword = searchText.toLowerCase();
|
||||
result = result.filter(
|
||||
(r: Record<string, unknown>) =>
|
||||
(r: RoomRecord) =>
|
||||
typeof r.roomNumber === 'string' && r.roomNumber.toLowerCase().includes(keyword),
|
||||
);
|
||||
}
|
||||
if (filterBuilding)
|
||||
result = result.filter((r: Record<string, unknown>) => r.building === filterBuilding);
|
||||
if (filterStatus)
|
||||
result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
|
||||
if (filterBuilding) result = result.filter((r: RoomRecord) => r.building === filterBuilding);
|
||||
if (filterStatus) result = result.filter((r: RoomRecord) => r.status === filterStatus);
|
||||
if (filterRentalCategory) {
|
||||
result = result.filter(
|
||||
(r: Record<string, unknown>) => r.rentalCategory === filterRentalCategory,
|
||||
);
|
||||
result = result.filter((r: RoomRecord) => r.rentalCategory === filterRentalCategory);
|
||||
}
|
||||
return result;
|
||||
}, [data, searchText, filterBuilding, filterStatus, filterRentalCategory]);
|
||||
@@ -190,7 +186,7 @@ const RoomsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const saveRoomCell = useCallback(
|
||||
async (record: any, field: string, value: unknown) => {
|
||||
async (record: RoomRecord, field: string, value: unknown) => {
|
||||
try {
|
||||
await saveRoomCellMutation.mutateAsync({ record, field, value });
|
||||
message.success('已保存');
|
||||
@@ -392,7 +388,7 @@ const RoomsPage: React.FC = () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await batchPurgeMutation.mutateAsync(selectedRowKeys);
|
||||
const res = (await batchPurgeMutation.mutateAsync(selectedRowKeys)) as { message?: string };
|
||||
message.success(res?.message || `已永久删除 ${selectedRowKeys.length} 间宿舍`);
|
||||
setSelectedRowKeys([]);
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import type { RoomRecord } from './RoomColumns';
|
||||
import api from '../../api';
|
||||
|
||||
export function useBedMutations() {
|
||||
@@ -103,14 +104,14 @@ export function useRoomItemMutations() {
|
||||
return { ...useBedMutations(), ...useLockerMutations() };
|
||||
}
|
||||
|
||||
export function useRoomMutations(editing: any) {
|
||||
export function useRoomMutations(editing: RoomRecord | null) {
|
||||
const saveMutation = useApiMutation(
|
||||
async (payload: Record<string, unknown>) =>
|
||||
editing ? api.put(`/rooms/${editing.id}`, payload) : api.post('/rooms', payload),
|
||||
{ invalidate: [['rooms']] },
|
||||
);
|
||||
const saveRoomCellMutation = useApiMutation(
|
||||
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
|
||||
async ({ record, field, value }: { record: RoomRecord; field: string; value: unknown }) =>
|
||||
api.put(`/rooms/${record.id}`, { [field]: value }),
|
||||
{ invalidate: [['rooms']] },
|
||||
);
|
||||
|
||||
@@ -29,6 +29,21 @@ export const SENSITIVE_LABELS = {
|
||||
emergencyPhone: '紧急联系人电话',
|
||||
} as const;
|
||||
|
||||
export interface StudentRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
phone?: string | null;
|
||||
studentNo?: string | null;
|
||||
idNumber?: string | null;
|
||||
ethnicity?: string | null;
|
||||
emergencyContact?: string | null;
|
||||
emergencyPhone?: string | null;
|
||||
supervisor?: string | null;
|
||||
status?: string;
|
||||
organization?: { name?: string } | null;
|
||||
organizationId?: number | null;
|
||||
}
|
||||
|
||||
export const STUDENT_STATUS_OPTIONS = [
|
||||
{ value: 'active', label: '在读' },
|
||||
{ value: 'graduated', label: '已毕业' },
|
||||
@@ -43,10 +58,10 @@ export interface StudentColumnContext {
|
||||
canDeleteStudent: boolean;
|
||||
canPurgeStudent: boolean;
|
||||
canViewSensitive: boolean;
|
||||
onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;
|
||||
onSaveCell: (record: StudentRecord, field: string, value: unknown) => Promise<void> | void;
|
||||
onViewSensitive: (recordId: number, field: string, value: string) => void;
|
||||
onOpenDrawer: (recordId: number) => void;
|
||||
onEdit: (record: any) => void;
|
||||
onEdit: (record: StudentRecord) => void;
|
||||
onRestore: (id: number) => Promise<unknown> | unknown;
|
||||
onPurge: (id: number, name: string) => void;
|
||||
onArchive: (id: number) => Promise<unknown> | unknown;
|
||||
@@ -139,7 +154,7 @@ function buildIdentityColumns(ctx: StudentColumnContext) {
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v: string, record: any) => (
|
||||
render: (v: string, record: StudentRecord) => (
|
||||
<EditableStudentCell value={v} field={STUDENT_FIELDS.name} record={record} required onSave={onSaveCell}>
|
||||
{v}
|
||||
</EditableStudentCell>
|
||||
@@ -149,7 +164,7 @@ function buildIdentityColumns(ctx: StudentColumnContext) {
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
width: 140,
|
||||
render: (v: string, record: any) => (
|
||||
render: (v: string, record: StudentRecord) => (
|
||||
<SensitiveValue
|
||||
value={v}
|
||||
masked={maskPhone(v)}
|
||||
@@ -164,7 +179,7 @@ function buildIdentityColumns(ctx: StudentColumnContext) {
|
||||
title: '学号',
|
||||
dataIndex: 'studentNo',
|
||||
width: 120,
|
||||
render: (v: string, record: any) => (
|
||||
render: (v: string, record: StudentRecord) => (
|
||||
<EditableStudentCell value={v} field={STUDENT_FIELDS.studentNo} record={record} onSave={onSaveCell}>
|
||||
{v || '-'}
|
||||
</EditableStudentCell>
|
||||
@@ -174,7 +189,7 @@ function buildIdentityColumns(ctx: StudentColumnContext) {
|
||||
title: '身份证',
|
||||
dataIndex: 'idNumber',
|
||||
width: 180,
|
||||
render: (v: string, record: any) => (
|
||||
render: (v: string, record: StudentRecord) => (
|
||||
<SensitiveValue
|
||||
value={v}
|
||||
masked={maskIdNumber(v)}
|
||||
@@ -196,7 +211,7 @@ function buildContactColumns(ctx: StudentColumnContext) {
|
||||
title: '民族',
|
||||
dataIndex: 'ethnicity',
|
||||
width: 90,
|
||||
render: (v: string, record: any) => (
|
||||
render: (v: string, record: StudentRecord) => (
|
||||
<EditableStudentCell value={v} field={STUDENT_FIELDS.ethnicity} record={record} onSave={onSaveCell}>
|
||||
{v || '-'}
|
||||
</EditableStudentCell>
|
||||
@@ -206,7 +221,7 @@ function buildContactColumns(ctx: StudentColumnContext) {
|
||||
title: '紧急联系人',
|
||||
dataIndex: 'emergencyContact',
|
||||
width: 100,
|
||||
render: (v: string, record: any) => (
|
||||
render: (v: string, record: StudentRecord) => (
|
||||
<EditableStudentCell
|
||||
value={v}
|
||||
field={STUDENT_FIELDS.emergencyContact}
|
||||
@@ -221,7 +236,7 @@ function buildContactColumns(ctx: StudentColumnContext) {
|
||||
title: '紧急联系人电话',
|
||||
dataIndex: 'emergencyPhone',
|
||||
width: 150,
|
||||
render: (v: string, record: any) => (
|
||||
render: (v: string, record: StudentRecord) => (
|
||||
<SensitiveValue
|
||||
value={v}
|
||||
masked={maskPhone(v)}
|
||||
@@ -236,7 +251,7 @@ function buildContactColumns(ctx: StudentColumnContext) {
|
||||
title: '所属机构',
|
||||
dataIndex: 'organization',
|
||||
width: 100,
|
||||
render: (organization: { name?: string } | null, record: any) =>
|
||||
render: (organization: { name?: string } | null, record: StudentRecord) =>
|
||||
canChooseOrganization ? (
|
||||
<EditableStudentCell
|
||||
value={record.organizationId}
|
||||
@@ -274,7 +289,7 @@ function buildProfileColumns(ctx: StudentColumnContext) {
|
||||
title: '负责人',
|
||||
dataIndex: 'supervisor',
|
||||
width: 100,
|
||||
render: (v: string, record: any) => (
|
||||
render: (v: string, record: StudentRecord) => (
|
||||
<EditableStudentCell value={v} field={STUDENT_FIELDS.supervisor} record={record} onSave={onSaveCell}>
|
||||
{v || '-'}
|
||||
</EditableStudentCell>
|
||||
@@ -284,7 +299,7 @@ function buildProfileColumns(ctx: StudentColumnContext) {
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string, record: any) => (
|
||||
render: (s: string, record: StudentRecord) => (
|
||||
<EditableStudentCell
|
||||
value={s}
|
||||
field={STUDENT_FIELDS.status}
|
||||
@@ -320,7 +335,7 @@ function buildActionColumn(ctx: StudentColumnContext) {
|
||||
title: '操作',
|
||||
fixed: 'right' as const,
|
||||
width: 180,
|
||||
render: (_: any, record: any) => (
|
||||
render: (_: unknown, record: StudentRecord) => (
|
||||
<Space>
|
||||
{record.status === 'archived' ? (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Alert, Button, Card, Col, Descriptions, Row, Spin, Table, Tag } from 'antd';
|
||||
import { Alert, Button, Card, Col, Descriptions, Row, Spin, Table, Tag, type TableProps } from 'antd';
|
||||
import type { StudentRecord } from './StudentColumns';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { QueryEmpty } from '../../components/QueryState';
|
||||
@@ -16,8 +17,8 @@ export interface EnrollmentInfo {
|
||||
}
|
||||
|
||||
export const StudentsTable: React.FC<{
|
||||
columns: any[];
|
||||
data: any[];
|
||||
columns: TableProps<StudentRecord>['columns'];
|
||||
data: StudentRecord[];
|
||||
loading: boolean;
|
||||
pageInfo: { current: number; pageSize: number };
|
||||
onPageChange: (current: number, pageSize: number) => void;
|
||||
@@ -93,7 +94,7 @@ export const StudentsTable: React.FC<{
|
||||
showTotal: (total) => `共 ${total} 人`,
|
||||
onChange: onPageChange,
|
||||
}}
|
||||
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
rowClassName={(record: StudentRecord) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => onSelect(keys as number[]),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
|
||||
import {
|
||||
App,
|
||||
Form,
|
||||
type UploadProps,
|
||||
} from 'antd';
|
||||
import api from '../../api';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
} from '../../api/schemas';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildStudentColumns } from './StudentColumns';
|
||||
import { buildStudentColumns, type StudentRecord } from './StudentColumns';
|
||||
import { StudentsToolbar } from './StudentsToolbar';
|
||||
import {
|
||||
JinshujuModal,
|
||||
@@ -82,7 +83,7 @@ const StudentsPage: React.FC = () => {
|
||||
const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger');
|
||||
const canSyncDingTalk = hasAllPermissions('sync:read', 'sync:trigger');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [editing, setEditing] = useState<StudentRecord | null>(null);
|
||||
const canSaveStudent = editing ? canEditStudent : canCreateStudent;
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
@@ -178,7 +179,7 @@ const StudentsPage: React.FC = () => {
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useApiQuery<Array<Record<string, unknown>>>({
|
||||
} = useApiQuery<StudentRecord[]>({
|
||||
queryKey: queryKeys.students.list({
|
||||
search: searchName,
|
||||
status: showArchived ? 'archived' : filterStatus,
|
||||
@@ -213,7 +214,7 @@ const StudentsPage: React.FC = () => {
|
||||
{ invalidate: invalidateStudents },
|
||||
);
|
||||
const saveCellMutation = useApiMutation(
|
||||
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
|
||||
async ({ record, field, value }: { record: StudentRecord; field: string; value: unknown }) =>
|
||||
api.put(`/students/${record.id}`, { [field]: value }),
|
||||
{ invalidate: invalidateStudents },
|
||||
);
|
||||
@@ -314,7 +315,7 @@ const StudentsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const saveCell = useCallback(
|
||||
async (record: any, field: string, value: unknown) => {
|
||||
async (record: StudentRecord, field: string, value: unknown) => {
|
||||
try {
|
||||
await saveCellMutation.mutateAsync({ record, field, value });
|
||||
message.success('已保存');
|
||||
@@ -384,7 +385,7 @@ const StudentsPage: React.FC = () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await batchDeleteMutation.mutateAsync(selectedRowKeys);
|
||||
const res = (await batchDeleteMutation.mutateAsync(selectedRowKeys)) as { message?: string };
|
||||
message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 人`);
|
||||
setSelectedRowKeys([]);
|
||||
} catch {
|
||||
@@ -414,7 +415,7 @@ const StudentsPage: React.FC = () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await batchPurgeMutation.mutateAsync(selectedRowKeys);
|
||||
const res = (await batchPurgeMutation.mutateAsync(selectedRowKeys)) as { message?: string };
|
||||
message.success(res?.message || `已永久删除 ${selectedRowKeys.length} 人`);
|
||||
setSelectedRowKeys([]);
|
||||
} catch {
|
||||
@@ -424,7 +425,8 @@ const StudentsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateStudentsImport = async ({ file, onSuccess, onError }: any) => {
|
||||
const handleCreateStudentsImport = async (options: Parameters<NonNullable<UploadProps['customRequest']>>[0]) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file as File);
|
||||
try {
|
||||
@@ -440,7 +442,8 @@ const StudentsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateExistingStudentsImport = async ({ file, onSuccess, onError }: any) => {
|
||||
const handleUpdateExistingStudentsImport = async (options: Parameters<NonNullable<UploadProps['customRequest']>>[0]) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file as File);
|
||||
try {
|
||||
|
||||
@@ -30,16 +30,36 @@ const USER_FIELDS = {
|
||||
status: 'status',
|
||||
} as const;
|
||||
|
||||
interface RoleOption {
|
||||
id: number;
|
||||
name: string;
|
||||
status?: number;
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
interface UserRecord {
|
||||
id: number;
|
||||
username: string;
|
||||
name?: string;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
status?: number;
|
||||
isArchived?: boolean;
|
||||
lastLoginAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
roles?: RoleOption[];
|
||||
}
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
const { modal } = App.useApp();
|
||||
const { hasPermission } = usePermission();
|
||||
const canPurgeUser = hasPermission('user:purge');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [pwdModalOpen, setPwdModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [resetTarget, setResetTarget] = useState<any>(null);
|
||||
const [editing, setEditing] = useState<UserRecord | null>(null);
|
||||
const [resetTarget, setResetTarget] = useState<UserRecord | null>(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [profileUser, setProfileUser] = useState<any>(null);
|
||||
const [profileUser, setProfileUser] = useState<UserRecord | null>(null);
|
||||
const [profileForm] = Form.useForm();
|
||||
const [form] = Form.useForm();
|
||||
const [pwdForm] = Form.useForm();
|
||||
@@ -52,7 +72,7 @@ const UsersPage: React.FC = () => {
|
||||
const profileGuard = useDirtyGuard(profileForm);
|
||||
|
||||
const handleOpenProfile = useCallback(
|
||||
async (record: any) => {
|
||||
async (record: UserRecord) => {
|
||||
setProfileUser(record);
|
||||
try {
|
||||
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
|
||||
@@ -67,6 +87,7 @@ const UsersPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const handleProfileSubmit = async () => {
|
||||
if (!profileUser) return;
|
||||
setSaving(true);
|
||||
const values = await profileForm.validateFields();
|
||||
try {
|
||||
@@ -84,17 +105,17 @@ const UsersPage: React.FC = () => {
|
||||
data: fetchResult = { users: [], roles: [] },
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery<{ users: any[]; roles: any[] }>({
|
||||
} = useQuery<{ users: UserRecord[]; roles: RoleOption[] }>({
|
||||
queryKey: ['rbac', 'users', showArchived],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const [users, rolesRes] = await Promise.all([
|
||||
api.get(`/rbac/users?isArchived=${showArchived}`) as Promise<any[]>,
|
||||
api.get('/rbac/roles') as Promise<any[]>,
|
||||
api.get(`/rbac/users?isArchived=${showArchived}`) as Promise<UserRecord[]>,
|
||||
api.get('/rbac/roles') as Promise<RoleOption[]>,
|
||||
]);
|
||||
return {
|
||||
users: validateResponse<any[]>(usersSchema, users),
|
||||
roles: validateResponse<any[]>(rolesSchema, rolesRes),
|
||||
users: validateResponse<UserRecord[]>(usersSchema, users),
|
||||
roles: validateResponse<RoleOption[]>(rolesSchema, rolesRes),
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
|
||||
@@ -107,7 +128,7 @@ const UsersPage: React.FC = () => {
|
||||
const loading = isLoading || isFetching;
|
||||
|
||||
const profileMutation = useApiMutation(
|
||||
async ({ id, values }: { id: number; values: any }) =>
|
||||
async ({ id, values }: { id: number; values: Record<string, unknown> }) =>
|
||||
api.put(`/rbac/users/${id}/profile`, values),
|
||||
{ invalidate: [['rbac', 'users']] },
|
||||
);
|
||||
@@ -133,7 +154,7 @@ const UsersPage: React.FC = () => {
|
||||
{ invalidate: [['rbac', 'users']] },
|
||||
);
|
||||
const saveCellMutation = useApiMutation(
|
||||
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
|
||||
async ({ record, field, value }: { record: UserRecord; field: string; value: unknown }) =>
|
||||
api.put(`/rbac/users/${record.id}`, { [field]: value }),
|
||||
{ invalidate: [['rbac', 'users']] },
|
||||
);
|
||||
@@ -146,12 +167,12 @@ const UsersPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(record: any) => {
|
||||
(record: UserRecord) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
username: record.username,
|
||||
name: record.name,
|
||||
roleIds: record.roles?.map((r: any) => r.id) || [],
|
||||
roleIds: record.roles?.map((r: RoleOption) => r.id) || [],
|
||||
});
|
||||
accountGuard.snapshot();
|
||||
setModalOpen(true);
|
||||
@@ -190,7 +211,7 @@ const UsersPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const handlePurge = useCallback(
|
||||
(record: any) => {
|
||||
(record: UserRecord) => {
|
||||
modal.confirm({
|
||||
title: `永久删除账号「${record.name || record.username}」?`,
|
||||
content:
|
||||
@@ -212,7 +233,7 @@ const UsersPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const handleResetPwd = useCallback(
|
||||
(record: any) => {
|
||||
(record: UserRecord) => {
|
||||
setResetTarget(record);
|
||||
pwdForm.resetFields();
|
||||
pwdGuard.snapshot();
|
||||
@@ -222,6 +243,7 @@ const UsersPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const handlePwdSubmit = async () => {
|
||||
if (!resetTarget) return;
|
||||
const values = await pwdForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -236,7 +258,7 @@ const UsersPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const saveCell = useCallback(
|
||||
async (record: any, field: string, value: unknown) => {
|
||||
async (record: UserRecord, field: string, value: unknown) => {
|
||||
try {
|
||||
await saveCellMutation.mutateAsync({ record, field, value });
|
||||
message.success('已保存');
|
||||
@@ -254,7 +276,7 @@ const UsersPage: React.FC = () => {
|
||||
title: '用户名',
|
||||
dataIndex: USER_FIELDS.username,
|
||||
width: 120,
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: UserRecord) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
@@ -281,7 +303,7 @@ const UsersPage: React.FC = () => {
|
||||
title: '姓名',
|
||||
dataIndex: USER_FIELDS.name,
|
||||
width: 120,
|
||||
render: (v: string, r: any) => (
|
||||
render: (v: string, r: UserRecord) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
@@ -297,7 +319,7 @@ const UsersPage: React.FC = () => {
|
||||
title: '角色',
|
||||
dataIndex: 'roles',
|
||||
width: 200,
|
||||
render: (v: any[], record: any) => (
|
||||
render: (v: RoleOption[] | undefined, record: UserRecord) => (
|
||||
<EditableCell
|
||||
value={v?.map((item) => item.id) || []}
|
||||
editor="multi-select"
|
||||
@@ -307,7 +329,7 @@ const UsersPage: React.FC = () => {
|
||||
onSave={(next) => saveCell(record, 'roleIds', next)}
|
||||
>
|
||||
{v && v.length > 0 ? (
|
||||
v.map((r: any) => (
|
||||
v.map((r: RoleOption) => (
|
||||
<Tag key={r.id} color="blue">
|
||||
{r.name}
|
||||
</Tag>
|
||||
@@ -334,7 +356,7 @@ const UsersPage: React.FC = () => {
|
||||
title: '操作',
|
||||
width: 240,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: any) => (
|
||||
render: (_: unknown, record: UserRecord) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="user:edit"
|
||||
@@ -482,8 +504,8 @@ const UsersPage: React.FC = () => {
|
||||
mode="multiple"
|
||||
placeholder="选择角色"
|
||||
options={roles
|
||||
.filter((r: any) => r.status !== 0)
|
||||
.map((r: any) => ({
|
||||
.filter((r: RoleOption) => r.status !== 0)
|
||||
.map((r: RoleOption) => ({
|
||||
value: r.id,
|
||||
label: `${r.name}${r.isSystem ? ' (系统)' : ''}`,
|
||||
}))}
|
||||
|
||||
@@ -88,8 +88,8 @@ const WalletsPage: React.FC = () => {
|
||||
roomTypesSchema,
|
||||
await api.get('/wallets/room-types'),
|
||||
);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载房型失败');
|
||||
} catch (error: unknown) {
|
||||
message.error(error instanceof Error ? error.message : '加载房型失败');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -159,13 +159,13 @@ const WalletsPage: React.FC = () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result: any = await changeMutation.mutateAsync({
|
||||
const result = (await changeMutation.mutateAsync({
|
||||
operationId: newOperationId(),
|
||||
studentId: selected.studentId,
|
||||
...values,
|
||||
});
|
||||
})) as { payments?: Array<{ paidAmount?: number | string }> };
|
||||
const paid = (result.payments || []).reduce(
|
||||
(sum: number, bill: any) => sum + Number(bill.paidAmount || 0),
|
||||
(sum: number, bill: { paidAmount?: number | string }) => sum + Number(bill.paidAmount || 0),
|
||||
0,
|
||||
);
|
||||
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
|
||||
@@ -181,16 +181,18 @@ const WalletsPage: React.FC = () => {
|
||||
const values = await batchForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result: any = await batchChangeMutation.mutateAsync({
|
||||
const result = (await batchChangeMutation.mutateAsync({
|
||||
operationId: newOperationId(),
|
||||
studentIds: selectedRowKeys,
|
||||
...values,
|
||||
});
|
||||
const paid = (result.results || []).reduce((sum: number, item: any) => {
|
||||
})) as {
|
||||
results?: Array<{ payments?: Array<{ paidAmount?: number | string }> }>;
|
||||
};
|
||||
const paid = (result.results || []).reduce((sum: number, item: { payments?: Array<{ paidAmount?: number | string }> }) => {
|
||||
return (
|
||||
sum +
|
||||
(item.payments || []).reduce(
|
||||
(paymentSum: number, bill: any) => paymentSum + Number(bill.paidAmount || 0),
|
||||
(paymentSum: number, bill: { paidAmount?: number | string }) => paymentSum + Number(bill.paidAmount || 0),
|
||||
0,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ClassesService, normalizeTeacherSubjects } from './classes.service';
|
||||
import { Class, ClassStudent, ClassTeacher, TeacherRoleType } from '../entities';
|
||||
import { ClassStudent, ClassTeacher, TeacherRoleType } from '../entities';
|
||||
|
||||
/** 模拟 TypeORM EntityManager:按实体类型分发 create/save/find/update,
|
||||
* 班级/学生/教师写入走各自 repo mock,供 create() 事务化后的测试使用。 */
|
||||
|
||||
@@ -160,7 +160,7 @@ export class OccupanciesController {
|
||||
return withAuditLog(
|
||||
this.logService,
|
||||
req,
|
||||
(result) => ({
|
||||
() => ({
|
||||
module: '入住管理',
|
||||
action: '编辑入住记录',
|
||||
targetId: +id,
|
||||
|
||||
Reference in New Issue
Block a user