chore: 清理 lint 警告(290+ 条 no-explicit-any) #82

Merged
wangziqi merged 2 commits from chore/lint-cleanup into main 2026-08-12 03:08:16 +00:00
36 changed files with 696 additions and 361 deletions
@@ -34,7 +34,7 @@ export function LiteMermaid({ children }: LiteMermaidProps) {
}
}
})();
}, [children]);
}, [children, isMounted]);
if (error) {
return (
+2 -2
View File
@@ -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;
+44 -17
View File
@@ -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' && (
<>
+20 -19
View File
@@ -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 || '下载'}
+36 -23
View File
@@ -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
+23 -8
View File
@@ -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) {
+28 -24
View File
@@ -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: '模板已下载',
+7 -4
View File
@@ -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);
}
+5 -5
View File
@@ -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;
+48 -27
View File
@@ -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 {
+16 -3
View File
@@ -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 }),
);
+1 -1
View File
@@ -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"
+68 -22
View File
@@ -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),
)
}
>
+31 -16
View File
@@ -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' ? (
+6 -6
View File
@@ -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 -2
View File
@@ -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;
+4 -3
View File
@@ -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 -21
View File
@@ -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[]),
+12 -9
View File
@@ -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 {
+45 -23
View File
@@ -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 ? ' (系统)' : ''}`,
}))}
+11 -9
View File
@@ -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,