main #5

Merged
wangziqi merged 5 commits from xiongyuxing/gongxue-base:main into main 2026-07-14 02:56:04 +00:00
19 changed files with 213 additions and 52 deletions

View File

@@ -321,7 +321,11 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
columns={columns}
dataSource={data}
rowKey="id"
pagination={{ pageSize: 15 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加报读记录"
@@ -428,7 +432,11 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
columns={columns}
dataSource={data}
rowKey="id"
pagination={{ pageSize: 15 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加考试成绩"
@@ -529,7 +537,11 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({ data, st
columns={columns}
dataSource={data}
rowKey="id"
pagination={{ pageSize: 15 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加学情记录"
@@ -712,7 +724,11 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
columns={columns}
dataSource={data}
rowKey="id"
pagination={{ pageSize: 15 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
style={{ marginTop: 16 }}
/>
</div>

View File

@@ -399,7 +399,12 @@ const BillsPage: React.FC = () => {
dataSource={filteredBills}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedRows,

View File

@@ -530,7 +530,11 @@ const ClassDetailPage: React.FC = () => {
columns={studentColumns}
dataSource={students}
rowKey="id"
pagination={{ pageSize: 20 }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
<Modal
title="添加学员"
@@ -574,7 +578,11 @@ const ClassDetailPage: React.FC = () => {
columns={teacherColumns}
dataSource={teachers}
rowKey="id"
pagination={{ pageSize: 20 }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
<Modal
title="添加教师"
@@ -633,7 +641,11 @@ const ClassDetailPage: React.FC = () => {
columns={scheduleColumns}
dataSource={schedules}
rowKey="id"
pagination={{ pageSize: 20 }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
</div>
),

View File

@@ -264,7 +264,11 @@ const ClassesPage: React.FC = () => {
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20 }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
scroll={{ x: 1100 }}
/>

View File

@@ -483,7 +483,12 @@ const ClassroomRentalsPage: React.FC = () => {
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
scroll={{ x: 1200 }}
/>
<Modal

View File

@@ -287,7 +287,12 @@ const ClassroomsPage: React.FC = () => {
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total}`,
}}
/>
<Modal
title={editing ? '编辑教室' : '添加教室'}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildDepositStudentOption } from './deposit-student-option';
import { buildDepositStudentOption, buildDepositStudentOptions } from './deposit-student-option';
describe('deposit student option', () => {
it('uses the student number as the non-sensitive identifier', () => {
@@ -17,4 +17,13 @@ describe('deposit student option', () => {
label: '张三 (#23)',
});
});
it('uses lookup rows without requiring a status field', () => {
expect(buildDepositStudentOptions([{ id: 23, name: '张三', studentNo: 'S2026001' }])).toEqual([
{
value: 23,
label: '张三 (S2026001)',
},
]);
});
});

View File

@@ -8,3 +8,6 @@ export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
value: student.id,
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
});
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>
students.map(buildDepositStudentOption);

View File

@@ -19,7 +19,7 @@ import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { buildDepositStudentOption } from './deposit-student-option';
import { buildDepositStudentOptions } from './deposit-student-option';
const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
@@ -33,6 +33,10 @@ const installmentStatusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
};
const isFormValidationError = (error: unknown) =>
typeof error === 'object'
&& error !== null
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
const DepositsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
@@ -82,17 +86,14 @@ const DepositsPage: React.FC = () => {
}, [data, searchText, filterStatus]);
const studentOptions = useMemo(
() =>
students
.filter((s: any) => s.status === 'active')
.map(buildDepositStudentOption),
() => buildDepositStudentOptions(students),
[students],
);
const handleCreate = async () => {
setSaving(true);
const values = await createForm.validateFields();
try {
const values = await createForm.validateFields();
await api.post('/deposits', {
studentId: values.studentId,
amount: values.amount,
@@ -104,7 +105,9 @@ const DepositsPage: React.FC = () => {
createForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
@@ -112,8 +115,8 @@ const DepositsPage: React.FC = () => {
const handleRefund = async () => {
setSaving(true);
const values = await refundForm.validateFields();
try {
const values = await refundForm.validateFields();
await api.put(`/deposits/${refundModal.id}/refund`, {
refundDate: values.refundDate.format('YYYY-MM-DD'),
deductionAmount: values.deductionAmount || 0,
@@ -125,7 +128,9 @@ const DepositsPage: React.FC = () => {
refundForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
@@ -133,8 +138,8 @@ const DepositsPage: React.FC = () => {
const handleAddInstallment = async () => {
if (installmentModal == null) return;
const values = await installmentForm.validateFields();
try {
const values = await installmentForm.validateFields();
await api.post(`/deposits/${installmentModal}/installments`, {
amount: values.amount,
dueDate: values.dueDate.format('YYYY-MM-DD'),
@@ -144,7 +149,9 @@ const DepositsPage: React.FC = () => {
installmentForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
}
};
@@ -304,7 +311,12 @@ const DepositsPage: React.FC = () => {
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>

View File

@@ -452,7 +452,12 @@ const ExpensesPage: React.FC = () => {
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedRoomKeys,
@@ -575,7 +580,12 @@ const ExpensesPage: React.FC = () => {
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedPersonalKeys,

View File

@@ -522,7 +522,12 @@ const OccupanciesPage: React.FC = () => {
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1300 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
rowSelection={rowSelection}
/>
<Modal

View File

@@ -26,13 +26,14 @@ const OperationLogsPage: React.FC = () => {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [filterModule, setFilterModule] = useState<string | undefined>();
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, pageSize: 20 };
const params: any = { page, pageSize };
if (filterModule) params.module = filterModule;
if (dateRange) {
params.startDate = dateRange[0];
@@ -46,7 +47,7 @@ const OperationLogsPage: React.FC = () => {
message.error(err?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [page, filterModule, dateRange]);
}, [page, pageSize, filterModule, dateRange]);
useEffect(() => {
fetchData();
@@ -159,8 +160,13 @@ const OperationLogsPage: React.FC = () => {
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
pageSize,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
onChange: (nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
},
showTotal: (t) => `${t}`,
}}
/>

View File

@@ -235,7 +235,12 @@ const OrganizationsPage: React.FC = () => {
loading={loading}
locale={{ emptyText: <Empty description="暂无机构" /> }}
scroll={{ x: 1100 }}
pagination={{ pageSize: 20, showTotal: (total) => `${total} 个机构` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total} 个机构`,
}}
/>
<Modal
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}

View File

@@ -156,6 +156,11 @@ const RoomsPage: React.FC = () => {
if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
return result;
}, [data, searchText, filterBuilding, filterStatus]);
const remainingBedSlots = useMemo(() => {
const capacity = Number(drawerRoom?.capacity) || 0;
return Math.max(capacity - beds.length, 0);
}, [drawerRoom?.capacity, beds.length]);
const defaultBatchBedCount = Math.min(4, Math.max(remainingBedSlots, 1));
const handleSave = async () => {
const values = await form.validateFields();
@@ -167,7 +172,7 @@ const RoomsPage: React.FC = () => {
message.success('更新成功');
} else {
await api.post('/rooms', payload);
message.success('创建成功');
message.success(`创建成功,已自动生成 ${values.capacity} 张床位`);
}
setModalOpen(false);
form.resetFields();
@@ -514,7 +519,12 @@ const RoomsPage: React.FC = () => {
scroll={{ x: 1200 }}
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total}`,
}}
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
rowSelection={{
selectedRowKeys,
@@ -629,24 +639,26 @@ const RoomsPage: React.FC = () => {
type="primary"
size="small"
icon={<PlusOutlined />}
disabled={drawerRoom?.status === 'archived'}
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }}
>
</Button>
<Popconfirm
title="批量生成床位"
title={remainingBedSlots > 0 ? '批量生成床位' : '床位已达到额定人数'}
description={
<InputNumber min={1} max={20} defaultValue={4} id="batch-bed-count" style={{ width: 80 }} />
remainingBedSlots > 0
? <InputNumber min={1} max={remainingBedSlots} defaultValue={defaultBatchBedCount} id="batch-bed-count" style={{ width: 80 }} />
: '如需增加床位,请先调整宿舍额定人数'
}
onConfirm={() => {
const input = document.getElementById('batch-bed-count') as HTMLInputElement;
handleBatchBeds(input ? parseInt(input.value) || 4 : 4);
handleBatchBeds(input ? parseInt(input.value) || defaultBatchBedCount : defaultBatchBedCount);
}}
okText="生成"
disabled={drawerRoom?.status === 'archived'}
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
>
<Button size="small" disabled={drawerRoom?.status === 'archived'}></Button>
<Button size="small" disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}></Button>
</Popconfirm>
</div>
<Table

View File

@@ -577,7 +577,12 @@ const StudentsPage: React.FC = () => {
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1410 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
rowSelection={{
selectedRowKeys,

View File

@@ -146,7 +146,12 @@ const TeacherWorkspacePage: React.FC = () => {
columns={classColumns}
dataSource={data.assignedClasses}
rowKey="classId"
pagination={{ pageSize: 20, showTotal: (total) => `${total} 个班级` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total} 个班级`,
}}
/>
) : (
<Empty description="暂无分配的班级" />
@@ -160,7 +165,12 @@ const TeacherWorkspacePage: React.FC = () => {
columns={scheduleColumns}
dataSource={data.todaySchedules}
rowKey="id"
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total}`,
}}
/>
) : (
<Empty description="今日无排课" />
@@ -174,7 +184,12 @@ const TeacherWorkspacePage: React.FC = () => {
columns={studentColumns}
dataSource={data.myStudents}
rowKey="studentId"
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total}`,
}}
/>
) : (
<Empty description="暂无学生" />

View File

@@ -46,13 +46,14 @@ const ROLE_TYPE_LABELS: Record<string, string> = {
academic_teacher: '教务老师',
};
const PAGE_SIZE = 20;
const DEFAULT_PAGE_SIZE = 20;
const TeachersPage: React.FC = () => {
const [data, setData] = useState<TeacherRow[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [search, setSearch] = useState('');
const [profileModal, setProfileModal] = useState<TeacherRow | null>(null);
const [form] = Form.useForm<ProfileFormValues>();
@@ -62,7 +63,7 @@ const TeachersPage: React.FC = () => {
setLoading(true);
try {
const res = await api.get<TeacherListResponse>('/rbac/teachers', {
params: { search: search || undefined, page, pageSize: PAGE_SIZE },
params: { search: search || undefined, page, pageSize },
});
setData(res.list);
setTotal(res.total);
@@ -70,7 +71,7 @@ const TeachersPage: React.FC = () => {
// silent
}
setLoading(false);
}, [page, search]);
}, [page, pageSize, search]);
useEffect(() => {
fetchData();
@@ -199,9 +200,14 @@ const TeachersPage: React.FC = () => {
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
pageSize,
total,
onChange: setPage,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
onChange: (nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
},
showTotal: (t) => `${t}`,
}}
expandable={{

View File

@@ -5,7 +5,7 @@ import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity';
import { Deposit } from '../entities/deposit.entity';
import * as ExcelJS from 'exceljs';
import * as PDFDocument from 'pdfkit';
import PDFDocument from 'pdfkit';
import { Response } from 'express';
@Injectable()

View File

@@ -110,7 +110,9 @@ export class RoomsService {
roomType: dto.roomType ?? parsed.roomType,
capacity: dto.capacity ?? parsed.capacity,
});
return this.repo.save(entity);
const room = await this.repo.save(entity);
await this.createDefaultBeds(room.id, room.capacity);
return room;
}
async update(id: number, dto: UpdateRoomDto) {
@@ -312,7 +314,7 @@ export class RoomsService {
}
// 智能解析房间号
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
await this.repo.save(
const room = await this.repo.save(
this.repo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
@@ -323,6 +325,7 @@ export class RoomsService {
monthlyRate: row.monthlyRate ?? undefined,
}),
);
await this.createDefaultBeds(room.id, room.capacity);
imported++;
}
return {
@@ -353,6 +356,7 @@ export class RoomsService {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
await this.assertCanAddBeds(room, 1);
const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } });
if (existing) throw new BadRequestException('该床位编号已存在');
const bed = this.bedRepo.create({ ...dto, roomId });
@@ -387,6 +391,7 @@ export class RoomsService {
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
this.assertCanAddBedsFromCount(room, existing.length, dto.count);
const numbers = existing.map((b) => {
const match = b.bedNumber.match(/^\d+/);
return match ? parseInt(match[0]) : 0;
@@ -399,6 +404,27 @@ export class RoomsService {
return this.bedRepo.save(beds);
}
private async createDefaultBeds(roomId: number, capacity: number): Promise<void> {
const count = Math.max(capacity ?? 0, 0);
if (count === 0) return;
const beds = Array.from({ length: count }, (_, index) =>
this.bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }),
);
await this.bedRepo.save(beds);
}
private async assertCanAddBeds(room: Room, count: number): Promise<void> {
const existingCount = await this.bedRepo.count({ where: { roomId: room.id } });
this.assertCanAddBedsFromCount(room, existingCount, count);
}
private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void {
const remaining = Math.max((room.capacity ?? 0) - existingCount, 0);
if (count > remaining) {
throw new BadRequestException(`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining}`);
}
}
// ── 柜子管理 ──
async getRoomLockers(roomId: number): Promise<Locker[]> {