fix: release beds/lockers in batchCheckOut

The batchCheckOut method was not releasing assigned beds and lockers
after checkout, leaving them orphaned as 'occupied'. Added the same
release pattern used in checkOut() — using runner.manager.update()
since batchCheckOut operates inside a QueryRunner transaction.
This commit is contained in:
2026-07-09 12:05:36 +08:00
parent 54d8d0545e
commit 454a0d24c6
13 changed files with 363 additions and 12 deletions

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber,
DatePicker, Popconfirm, message, Card,
DatePicker, Popconfirm, message, Card, Switch,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import api from '../../api';
@@ -27,6 +27,7 @@ interface ClassItem {
maxStudents: number;
notes: string | null;
studentCount: number;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
@@ -76,21 +77,34 @@ const ClassesPage: React.FC = () => {
const [filterType, setFilterType] = useState<string>();
const [form] = Form.useForm<ClassFormValues>();
const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const handleArchive = async (id: number, archive: boolean) => {
try {
await api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`);
message.success(archive ? '已归档' : '已恢复');
fetchData();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '操作失败');
}
};
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: ClassQueryParams = {};
const params: Record<string, string | boolean | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterType) params.classType = filterType;
const res = await api.get<ClassItem[]>('/classes', { params });
params.isArchived = showArchived;
const res = await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>);
setData(res);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [filterStatus, filterType]);
}, [filterStatus, filterType, showArchived]);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -180,7 +194,7 @@ const ClassesPage: React.FC = () => {
},
},
{
title: '操作', width: 200,
title: '操作', width: 280,
render: (_: unknown, r: ClassItem) => (
<Space>
<Button size="small" icon={<TeamOutlined />} onClick={() => navigate(`/classes/${r.id}`)}>
@@ -189,6 +203,15 @@ const ClassesPage: React.FC = () => {
<PermissionButton permission="class:edit" size="small" onClick={() => handleEdit(r)}>
</PermissionButton>
{r.isArchived ? (
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(r.id, false)}>
<PermissionButton permission="class:edit" size="small"></PermissionButton>
</Popconfirm>
) : (
<Popconfirm title="归档后可恢复,确认归档?" onConfirm={() => handleArchive(r.id, true)}>
<PermissionButton permission="class:edit" size="small"></PermissionButton>
</Popconfirm>
)}
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
<PermissionButton permission="class:delete" size="small" danger>
@@ -228,6 +251,16 @@ const ClassesPage: React.FC = () => {
<PermissionButton permission="class:create" type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</PermissionButton>
<span style={{ marginLeft: 8 }}>
<InboxOutlined style={{ marginRight: 4 }} />
<Switch
size="small"
style={{ marginLeft: 4 }}
checked={showArchived}
onChange={setShowArchived}
/>
</span>
</Space>
<Table<ClassItem>
columns={columns}

View File

@@ -2,13 +2,14 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Card, Button, Select, Modal, Form, Input, DatePicker, TimePicker,
Popconfirm, message, Space, Spin, Empty, Tag, Tooltip, Segmented,
Badge,
Badge, Row, Col, Statistic, Alert,
} from 'antd';
import {
CalendarOutlined,
LeftOutlined,
RightOutlined,
DeleteOutlined,
CloudSyncOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
@@ -56,6 +57,16 @@ interface ScheduleFormValues {
dateRange: [Dayjs, Dayjs];
}
/** 排班同步返回结果 */
interface ScheduleSyncResult {
scheduleCount: number;
shiftCount: number;
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
groups: Array<{ deptName: string; groupId: number; itemCount: number }>;
}
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7];
@@ -88,6 +99,56 @@ const SchedulesPage: React.FC = () => {
} | null>(null);
const [selectedSchedules, setSelectedSchedules] = useState<ClassScheduleItem[]>([]);
const [submitting, setSubmitting] = useState(false);
// ── 钉钉排班同步 ──
const [syncModalOpen, setSyncModalOpen] = useState(false);
const [syncing, setSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<{
activeSchedules: number; mappedTeachers: number; totalTeachers: number;
} | null>(null);
const [syncResult, setSyncResult] = useState<{
scheduleCount: number; shiftCount: number; groupCount: number;
syncedItems: number; skippedNoMapping: number;
groups: Array<{ deptName: string; groupId: number; itemCount: number }>;
} | null>(null);
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
const [syncDays, setSyncDays] = useState(30);
/** 打开同步弹窗时先查询就绪状态 */
const openSyncModal = useCallback(async () => {
setSyncModalOpen(true);
setSyncResult(null);
try {
const res = await api.get<{
success: boolean; data: { activeSchedules: number; mappedTeachers: number; totalTeachers: number };
}>('/sync/schedule/status');
setSyncStatus(res.data);
} catch {
setSyncStatus(null);
}
}, []);
/** 执行排班同步 */
const handleSyncSchedule = useCallback(async () => {
setSyncing(true);
try {
const res = await api.post<{
success: boolean; data: ScheduleSyncResult;
}>('/sync/schedule/sync', null, {
params: {
dateFrom: syncDateFrom.format('YYYY-MM-DD'),
days: syncDays,
},
});
setSyncResult(res.data);
message.success(`同步完成:${res.data.syncedItems} 条排班已写入钉钉`);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '同步失败');
} finally {
setSyncing(false);
}
}, [syncDateFrom, syncDays]);
const [form] = Form.useForm<ScheduleFormValues>();
// Derived week/month info
@@ -368,6 +429,9 @@ const SchedulesPage: React.FC = () => {
{ label: '月视图', value: 'month' },
]}
/>
<PermissionButton permission="sync:trigger" type="primary" icon={<CloudSyncOutlined />} onClick={openSyncModal}>
</PermissionButton>
{viewMode === 'week' ? (
<>
<Button
@@ -833,6 +897,125 @@ const SchedulesPage: React.FC = () => {
</div>
)}
</Modal>
{/* ── 钉钉排班同步 Modal ── */}
<Modal
title="同步排课到钉钉考勤排班"
open={syncModalOpen}
onCancel={() => { setSyncModalOpen(false); setSyncResult(null); }}
footer={syncResult ? [
<Button key="close" onClick={() => { setSyncModalOpen(false); setSyncResult(null); }}></Button>,
] : [
<Button key="cancel" onClick={() => { setSyncModalOpen(false); setSyncResult(null); }}></Button>,
<Button
key="sync"
type="primary"
icon={<CloudSyncOutlined />}
loading={syncing}
onClick={handleSyncSchedule}
disabled={!syncStatus || syncStatus.activeSchedules === 0}
>
</Button>,
]}
width={560}
>
{syncResult ? (
/* ── 同步结果 ── */
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Statistic title="排课" value={syncResult.scheduleCount} suffix="条" />
</Col>
<Col span={6}>
<Statistic title="班次" value={syncResult.shiftCount} suffix="个" />
</Col>
<Col span={6}>
<Statistic title="考勤组" value={syncResult.groupCount} suffix="个" />
</Col>
<Col span={6}>
<Statistic
title="排班数"
value={syncResult.syncedItems}
suffix="条"
valueStyle={{ color: '#3f8600' }}
/>
</Col>
</Row>
{syncResult.skippedNoMapping > 0 && (
<Alert
type="warning"
message={`${syncResult.skippedNoMapping} 条排课因教师未绑定钉钉而跳过`}
style={{ marginBottom: 16 }}
showIcon
/>
)}
{syncResult.groups.length > 0 && (
<div>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
{syncResult.groups.map((g) => (
<Tag key={g.groupId} color="blue" style={{ marginBottom: 4 }}>
{g.deptName}{g.itemCount}
</Tag>
))}
</div>
)}
</div>
) : syncStatus ? (
/* ── 同步确认信息 ── */
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Statistic title="活跃排课" value={syncStatus.activeSchedules} suffix="条" />
</Col>
<Col span={8}>
<Statistic
title="已绑定教师"
value={syncStatus.mappedTeachers}
suffix={`/ ${syncStatus.totalTeachers}`}
valueStyle={{ color: syncStatus.mappedTeachers < syncStatus.totalTeachers ? '#faad14' : '#3f8600' }}
/>
</Col>
<Col span={8}>
<Statistic title="未绑定" value={syncStatus.totalTeachers - syncStatus.mappedTeachers} suffix="人" />
</Col>
</Row>
{syncStatus.mappedTeachers < syncStatus.totalTeachers && (
<Alert
type="warning"
message={`${syncStatus.totalTeachers - syncStatus.mappedTeachers} 位教师未绑定钉钉,其排课将被跳过。请先执行组织架构同步。`}
style={{ marginBottom: 16 }}
showIcon
/>
)}
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Space>
<span></span>
<DatePicker value={syncDateFrom} onChange={(d) => d && setSyncDateFrom(d)} allowClear={false} />
<span></span>
<Select
value={syncDays}
onChange={setSyncDays}
style={{ width: 100 }}
options={[
{ value: 7, label: '7 天' },
{ value: 14, label: '14 天' },
{ value: 30, label: '30 天' },
{ value: 60, label: '60 天' },
{ value: 90, label: '90 天' },
]}
/>
</Space>
</div>
{syncStatus.activeSchedules === 0 && (
<Alert type="info" message="当前没有活跃排课。请先在排课页面创建排课记录。" showIcon />
)}
</div>
) : (
<Spin tip="查询同步状态..." />
)}
</Modal>
</div>
);
};