feat: 支持表格单元格快捷编辑
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { message } from '../../ui/app-message';
|
||||
import {
|
||||
@@ -52,10 +53,38 @@ import {
|
||||
import './attendance.css';
|
||||
|
||||
const DEFAULT_ATTENDANCE_PERIODS = [
|
||||
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true },
|
||||
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true },
|
||||
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true },
|
||||
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true },
|
||||
{
|
||||
periodKey: 'morning_reading',
|
||||
label: '早自习',
|
||||
startTime: '07:30',
|
||||
endTime: '08:30',
|
||||
sortOrder: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
periodKey: 'morning',
|
||||
label: '早课',
|
||||
startTime: '09:00',
|
||||
endTime: '12:00',
|
||||
sortOrder: 2,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
periodKey: 'afternoon',
|
||||
label: '晚课',
|
||||
startTime: '14:00',
|
||||
endTime: '17:00',
|
||||
sortOrder: 3,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
periodKey: 'evening_study',
|
||||
label: '晚自习',
|
||||
startTime: '18:30',
|
||||
endTime: '21:00',
|
||||
sortOrder: 4,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_META: Record<
|
||||
@@ -319,7 +348,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
[workspace],
|
||||
);
|
||||
|
||||
|
||||
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
|
||||
setStudentKeyword('');
|
||||
setCheckinFilter('all');
|
||||
@@ -345,29 +373,25 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateLessonRecord = useCallback(
|
||||
async (record: AttendanceRecordItem, status: string) => {
|
||||
const previous = record.status;
|
||||
const updateLessonRecord = useCallback(async (record: AttendanceRecordItem, status: string) => {
|
||||
const previous = record.status;
|
||||
setLessonRecords((items) =>
|
||||
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
|
||||
);
|
||||
try {
|
||||
await api.put(`/attendance-records/${record.id}`, { status });
|
||||
} catch (error: unknown) {
|
||||
setLessonRecords((items) =>
|
||||
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
|
||||
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
|
||||
);
|
||||
try {
|
||||
await api.put(`/attendance-records/${record.id}`, { status });
|
||||
} catch (error: unknown) {
|
||||
setLessonRecords((items) =>
|
||||
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
|
||||
);
|
||||
message.error((error as { message?: string })?.message || '更新考勤失败');
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
message.error((error as { message?: string })?.message || '更新考勤失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const now = new Date();
|
||||
const schedules = workspace?.todaySchedules ?? [];
|
||||
const startedCount = schedules.filter(
|
||||
(item) => canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)),
|
||||
const startedCount = schedules.filter((item) =>
|
||||
canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)),
|
||||
).length;
|
||||
const nextSchedule = schedules.find(
|
||||
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
|
||||
@@ -382,7 +406,9 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
<div className="attendance-page teacher-attendance">
|
||||
<section className="attendance-hero attendance-hero--teacher">
|
||||
<div>
|
||||
<span className="attendance-eyebrow">TEACHING DAY · {dayjs().format('MM月DD日 dddd')}</span>
|
||||
<span className="attendance-eyebrow">
|
||||
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
|
||||
</span>
|
||||
<h1>今天,从课程开始</h1>
|
||||
<p>课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。</p>
|
||||
</div>
|
||||
@@ -393,18 +419,33 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
|
||||
<Row gutter={[16, 16]} className="teacher-overview">
|
||||
<Col xs={24} md={8}>
|
||||
<div className="teacher-kpi"><span>今日课程</span><strong>{schedules.length}</strong><small>节</small></div>
|
||||
<div className="teacher-kpi">
|
||||
<span>今日课程</span>
|
||||
<strong>{schedules.length}</strong>
|
||||
<small>节</small>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div className="teacher-kpi"><span>已开始</span><strong>{startedCount}</strong><small>节,可查看考勤</small></div>
|
||||
<div className="teacher-kpi">
|
||||
<span>已开始</span>
|
||||
<strong>{startedCount}</strong>
|
||||
<small>节,可查看考勤</small>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div className="teacher-kpi teacher-kpi--next"><span>下一节</span><strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong><small>{nextSchedule?.subject || '今天没有更多课程'}</small></div>
|
||||
<div className="teacher-kpi teacher-kpi--next">
|
||||
<span>下一节</span>
|
||||
<strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong>
|
||||
<small>{nextSchedule?.subject || '今天没有更多课程'}</small>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div className="attendance-section-heading">
|
||||
<div><span>今日教学节奏</span><h2>我的课程</h2></div>
|
||||
<div>
|
||||
<span>今日教学节奏</span>
|
||||
<h2>我的课程</h2>
|
||||
</div>
|
||||
<span className="attendance-section-note">课程开始后可拉取钉钉考勤记录</span>
|
||||
</div>
|
||||
|
||||
@@ -413,7 +454,12 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
<Card className="attendance-empty-card">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<div><strong>今天还没有课程</strong><p>请联系教务管理员安排课程。</p></div>}
|
||||
description={
|
||||
<div>
|
||||
<strong>今天还没有课程</strong>
|
||||
<p>请联系教务管理员安排课程。</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -435,17 +481,31 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={960}
|
||||
title={null}
|
||||
className="attendance-drawer"
|
||||
>
|
||||
<div className="lesson-record-header">
|
||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
||||
<p>{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}</p>
|
||||
<p>
|
||||
{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} ·{' '}
|
||||
{selectedSchedule?.startTime}–{selectedSchedule?.endTime} ·{' '}
|
||||
{dayjs().format('YYYY-MM-DD')}
|
||||
</p>
|
||||
</div>
|
||||
{lessonSession && (
|
||||
<Alert
|
||||
type={isAttendanceCompleted ? 'success' : 'info'}
|
||||
showIcon
|
||||
title={isAttendanceCompleted ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
|
||||
title={
|
||||
isAttendanceCompleted
|
||||
? '本节课考勤已结算'
|
||||
: '当前打卡结果;课程截止后将自动做最终结算'
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
@@ -478,18 +538,30 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
dataSource={filteredLessonRecords}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: <Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
|
||||
/>,
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '学生', dataIndex: ['student', 'name'],
|
||||
render: (name: string) => <div className="student-cell"><Avatar size={32}>{name?.slice(0, 1)}</Avatar><strong>{name || '-'}</strong></div>,
|
||||
title: '学生',
|
||||
dataIndex: ['student', 'name'],
|
||||
render: (name: string) => (
|
||||
<div className="student-cell">
|
||||
<Avatar size={32}>{name?.slice(0, 1)}</Avatar>
|
||||
<strong>{name || '-'}</strong>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '考勤结果', dataIndex: 'status', width: 230,
|
||||
title: '考勤结果',
|
||||
dataIndex: 'status',
|
||||
width: 230,
|
||||
render: (value: string, record: AttendanceRecordItem) => {
|
||||
const checkedIn = value === 'present' || value === 'late';
|
||||
return (
|
||||
@@ -513,7 +585,16 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
||||
{
|
||||
title: '当前状态',
|
||||
dataIndex: 'status',
|
||||
width: 105,
|
||||
render: (value: string) => (
|
||||
<AttendanceStatusTag
|
||||
status={value === 'present' || value === 'late' ? 'present' : 'absent'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '打卡设备',
|
||||
width: 220,
|
||||
@@ -529,7 +610,11 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Drawer>
|
||||
@@ -553,14 +638,26 @@ const LessonCard: React.FC<{
|
||||
return (
|
||||
<article className={`lesson-card lesson-card--${phaseMeta.tone}`}>
|
||||
<div className="lesson-sequence">{String(index).padStart(2, '0')}</div>
|
||||
<div className="lesson-time"><strong>{schedule.startTime}</strong><span /><strong>{schedule.endTime}</strong></div>
|
||||
<div className="lesson-time">
|
||||
<strong>{schedule.startTime}</strong>
|
||||
<span />
|
||||
<strong>{schedule.endTime}</strong>
|
||||
</div>
|
||||
<div className="lesson-main">
|
||||
<div className="lesson-title-row"><h3>{schedule.subject}</h3><Tag icon={phaseMeta.icon}>{phaseMeta.label}</Tag></div>
|
||||
<p><TeamOutlined /> {className}<span>教室 {schedule.classroomId}</span></p>
|
||||
<div className="lesson-title-row">
|
||||
<h3>{schedule.subject}</h3>
|
||||
<Tag icon={phaseMeta.icon}>{phaseMeta.label}</Tag>
|
||||
</div>
|
||||
<p>
|
||||
<TeamOutlined /> {className}
|
||||
<span>教室 {schedule.classroomId}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="lesson-action">
|
||||
{phase === 'upcoming' ? (
|
||||
<Tooltip title="课程尚未开始"><Button disabled>等待上课</Button></Tooltip>
|
||||
<Tooltip title="课程尚未开始">
|
||||
<Button disabled>等待上课</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button type="primary" onClick={onOpen}>
|
||||
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
|
||||
@@ -571,7 +668,6 @@ const LessonCard: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
interface AdminStudentPanel {
|
||||
key: string;
|
||||
studentId: number;
|
||||
@@ -594,7 +690,11 @@ const ADMIN_METRIC_META = [
|
||||
|
||||
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
|
||||
const priority = ['absent', 'leave', 'present'];
|
||||
return priority.find((item) => records.some((record) => displayAttendanceStatus(record.status) === item)) || 'absent';
|
||||
return (
|
||||
priority.find((item) =>
|
||||
records.some((record) => displayAttendanceStatus(record.status) === item),
|
||||
) || 'absent'
|
||||
);
|
||||
}
|
||||
|
||||
function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] {
|
||||
@@ -621,9 +721,7 @@ function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentP
|
||||
}
|
||||
|
||||
return Array.from(map.values()).map((item) => {
|
||||
const checked = item.records.filter(
|
||||
(record) => record.status === 'present',
|
||||
).length;
|
||||
const checked = item.records.filter((record) => record.status === 'present').length;
|
||||
return {
|
||||
...item,
|
||||
primaryStatus: pickPrimaryStatus(item.records),
|
||||
@@ -660,7 +758,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
|
||||
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
|
||||
|
||||
|
||||
const enabledPeriods = useMemo(
|
||||
() => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
[periods],
|
||||
@@ -712,7 +809,10 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
enabled: period.enabled ?? true,
|
||||
})),
|
||||
};
|
||||
const data = await api.put<AttendancePeriodConfigItem[]>('/attendance-period-configs', payload);
|
||||
const data = await api.put<AttendancePeriodConfigItem[]>(
|
||||
'/attendance-period-configs',
|
||||
payload,
|
||||
);
|
||||
setPeriods(data);
|
||||
setPeriodModalOpen(false);
|
||||
message.success('考勤时段配置已保存');
|
||||
@@ -796,14 +896,16 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
}
|
||||
setRefreshingDingTalk(true);
|
||||
try {
|
||||
const result = await api.post<{ refreshed: number; imported: number; matched: number; errors: string[] }>(
|
||||
'/attendance-records/refresh-dingtalk',
|
||||
{
|
||||
date: attendanceDate.format('YYYY-MM-DD'),
|
||||
classId,
|
||||
session,
|
||||
},
|
||||
);
|
||||
const result = await api.post<{
|
||||
refreshed: number;
|
||||
imported: number;
|
||||
matched: number;
|
||||
errors: string[];
|
||||
}>('/attendance-records/refresh-dingtalk', {
|
||||
date: attendanceDate.format('YYYY-MM-DD'),
|
||||
classId,
|
||||
session,
|
||||
});
|
||||
if (result.errors.length > 0) {
|
||||
message.warning(result.errors[0]);
|
||||
} else {
|
||||
@@ -933,6 +1035,15 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
}
|
||||
};
|
||||
|
||||
const saveAdminRecordCell = async (
|
||||
record: AttendanceRecordItem,
|
||||
field: 'status' | 'remark',
|
||||
value: unknown,
|
||||
) => {
|
||||
await api.put(`/attendance-records/${record.id}`, { [field]: value });
|
||||
message.success('考勤记录已保存');
|
||||
await loadRecords();
|
||||
};
|
||||
|
||||
const studentPanels = useMemo(() => buildAdminStudentPanels(records), [records]);
|
||||
const visibleStudents = useMemo(() => {
|
||||
@@ -952,7 +1063,8 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
const selectedClassOption = classId
|
||||
? classOptions.find((item) => item.classId === classId)
|
||||
: undefined;
|
||||
const selectedClass = selectedClassOption?.className || (classId ? `班级 ${classId}` : '全部班级');
|
||||
const selectedClass =
|
||||
selectedClassOption?.className || (classId ? `班级 ${classId}` : '全部班级');
|
||||
const overviewTeachers = selectedClassOption?.teachers ?? [];
|
||||
const headTeacherNames = classId
|
||||
? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'head_teacher'))
|
||||
@@ -1004,7 +1116,20 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 105,
|
||||
render: (value: string) => <AttendanceStatusTag status={displayAttendanceStatus(value)} />,
|
||||
render: (value: string, record) => (
|
||||
<EditableCell
|
||||
value={displayAttendanceStatus(value)}
|
||||
editor="select"
|
||||
options={ADMIN_CORRECTION_OPTIONS.map((item) => ({
|
||||
value: String(item.value),
|
||||
label: item.label,
|
||||
}))}
|
||||
disabled={!canEdit}
|
||||
onSave={(next) => saveAdminRecordCell(record, 'status', next)}
|
||||
>
|
||||
<AttendanceStatusTag status={displayAttendanceStatus(value)} />
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '签到来源',
|
||||
@@ -1025,7 +1150,16 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
ellipsis: true,
|
||||
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
||||
render: (value: string | null, record) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="textarea"
|
||||
disabled={!canEdit}
|
||||
onSave={(next) => saveAdminRecordCell(record, 'remark', next)}
|
||||
>
|
||||
{value || <span className="muted-text">—</span>}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
@@ -1418,7 +1552,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
|
||||
<Modal
|
||||
open={periodModalOpen}
|
||||
title="考勤时段配置"
|
||||
@@ -1514,7 +1647,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../api';
|
||||
import PermissionButton from '../components/PermissionButton';
|
||||
import EditableCell from '../components/EditableCell';
|
||||
import { message } from '../ui/app-message';
|
||||
|
||||
interface ClassroomOption {
|
||||
@@ -119,6 +120,12 @@ const AttendanceDevicesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: AttendanceDeviceRow, field: string, value: unknown) => {
|
||||
await api.put(`/attendance-devices/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/attendance-devices/${id}`);
|
||||
@@ -130,37 +137,102 @@ const AttendanceDevicesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AttendanceDeviceRow> = [
|
||||
{ title: '设备名称', dataIndex: 'deviceName', width: 180 },
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'deviceName',
|
||||
width: 180,
|
||||
render: (value: string, record) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
required
|
||||
permission="classroom:edit"
|
||||
onSave={(next) => saveCell(record, 'deviceName', next)}
|
||||
>
|
||||
{value}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'SN 码',
|
||||
dataIndex: 'deviceSn',
|
||||
width: 220,
|
||||
render: (value) => <span style={{ fontFamily: 'monospace' }}>{value}</span>,
|
||||
render: (value, record) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
required
|
||||
permission="classroom:edit"
|
||||
onSave={(next) => saveCell(record, 'deviceSn', next)}
|
||||
>
|
||||
<span style={{ fontFamily: 'monospace' }}>{value}</span>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '绑定教室',
|
||||
dataIndex: ['classroom', 'name'],
|
||||
width: 160,
|
||||
render: (_value, record) => record.classroom?.name || `教室 ${record.classroomId}`,
|
||||
render: (_value, record) => (
|
||||
<EditableCell
|
||||
value={record.classroomId}
|
||||
editor="select"
|
||||
options={classrooms.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.building ? `${item.building} · ${item.name}` : item.name,
|
||||
}))}
|
||||
permission="classroom:edit"
|
||||
required
|
||||
onSave={(next) => saveCell(record, 'classroomId', next)}
|
||||
>
|
||||
{record.classroom?.name || `教室 ${record.classroomId}`}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
dataIndex: 'location',
|
||||
render: (value) => value || <span style={{ color: '#999' }}>—</span>,
|
||||
render: (value, record) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
permission="classroom:edit"
|
||||
onSave={(next) => saveCell(record, 'location', next)}
|
||||
>
|
||||
{value || <span style={{ color: '#999' }}>—</span>}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (value: keyof typeof statusMeta) => (
|
||||
<Tag color={statusMeta[value]?.color}>{statusMeta[value]?.text || value}</Tag>
|
||||
render: (value: keyof typeof statusMeta, record) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'active', label: '启用' },
|
||||
{ value: 'disabled', label: '停用' },
|
||||
]}
|
||||
permission="classroom:edit"
|
||||
onSave={(next) => saveCell(record, 'status', next)}
|
||||
>
|
||||
<Tag color={statusMeta[value]?.color}>{statusMeta[value]?.text || value}</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
ellipsis: true,
|
||||
render: (value) => value || <span style={{ color: '#999' }}>—</span>,
|
||||
render: (value, record) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="textarea"
|
||||
permission="classroom:edit"
|
||||
onSave={(next) => saveCell(record, 'notes', next)}
|
||||
>
|
||||
{value || <span style={{ color: '#999' }}>—</span>}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
// ---- Types ----
|
||||
@@ -167,6 +168,15 @@ const ClassesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = useCallback(
|
||||
async (record: ClassItem, field: string, value: unknown) => {
|
||||
await api.put(`/classes/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
},
|
||||
[fetchData],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<ClassItem> = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -174,33 +184,103 @@ const ClassesPage: React.FC = () => {
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||
render: (v: string, r: ClassItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="class:edit"
|
||||
disabled={r.isArchived}
|
||||
onSave={(next) => saveCell(r, 'name', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '编码',
|
||||
dataIndex: 'code',
|
||||
width: 140,
|
||||
render: (v: string, r: ClassItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="class:edit"
|
||||
disabled={r.isArchived}
|
||||
onSave={(next) => saveCell(r, 'code', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '编码', dataIndex: 'code', width: 140 },
|
||||
{
|
||||
title: '班型',
|
||||
dataIndex: 'classType',
|
||||
width: 100,
|
||||
render: (v: string) => <Tag>{TYPE_MAP[v] || v}</Tag>,
|
||||
render: (v: string, r: ClassItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={Object.entries(TYPE_MAP).map(([value, label]) => ({ value, label }))}
|
||||
permission="class:edit"
|
||||
disabled={r.isArchived}
|
||||
onSave={(next) => saveCell(r, 'classType', next)}
|
||||
>
|
||||
<Tag>{TYPE_MAP[v] || v}</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '开班日期',
|
||||
dataIndex: 'startDate',
|
||||
width: 110,
|
||||
render: (v: string | null) => v || '-',
|
||||
render: (v: string | null, r: ClassItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="date"
|
||||
permission="class:edit"
|
||||
disabled={r.isArchived}
|
||||
onSave={(next) => saveCell(r, 'startDate', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '学员',
|
||||
width: 100,
|
||||
render: (_: unknown, r: ClassItem) => `${r.studentCount || 0}/${r.maxStudents || '-'}`,
|
||||
render: (_: unknown, r: ClassItem) => (
|
||||
<EditableCell
|
||||
value={r.maxStudents}
|
||||
editor="number"
|
||||
min={0}
|
||||
permission="class:edit"
|
||||
disabled={r.isArchived}
|
||||
onSave={(next) => saveCell(r, 'maxStudents', next)}
|
||||
>{`${r.studentCount || 0}/${r.maxStudents || '-'}`}</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const cfg = STATUS_MAP[v] || { color: 'default', text: v };
|
||||
return <Tag color={cfg.color}>{cfg.text}</Tag>;
|
||||
},
|
||||
render: (v: string, r: ClassItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={Object.entries(STATUS_MAP).map(([value, item]) => ({
|
||||
value,
|
||||
label: item.text,
|
||||
}))}
|
||||
permission="class:edit"
|
||||
disabled={r.isArchived}
|
||||
onSave={(next) => saveCell(r, 'status', next)}
|
||||
>
|
||||
{(() => {
|
||||
const cfg = STATUS_MAP[v] || { color: 'default', text: v };
|
||||
return <Tag color={cfg.color}>{cfg.text}</Tag>;
|
||||
})()}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -237,7 +317,7 @@ const ClassesPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[saveCell],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,6 +26,7 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
|
||||
@@ -214,6 +215,12 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: any, field: string, value: unknown) => {
|
||||
await api.put(`/classroom-rentals/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/classroom-rentals/${id}`);
|
||||
@@ -279,34 +286,95 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
title: '教室',
|
||||
width: 120,
|
||||
dataIndex: 'classroom',
|
||||
render: (c: any) =>
|
||||
c ? (
|
||||
<span>
|
||||
{c.building ? `${c.building} · ` : ''}
|
||||
{c.name}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
render: (c: any, r: any) => (
|
||||
<EditableCell
|
||||
value={r.classroomId}
|
||||
editor="select"
|
||||
options={classrooms
|
||||
.filter((item) => item.status !== 'archived')
|
||||
.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.building ? `${item.building} · ${item.name}` : item.name,
|
||||
}))}
|
||||
permission="rental:edit"
|
||||
disabled={r.effectiveStatus !== 'active'}
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'classroomId', next)}
|
||||
>
|
||||
{c ? (
|
||||
<span>
|
||||
{c.building ? `${c.building} · ` : ''}
|
||||
{c.name}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '承租机构',
|
||||
width: 100,
|
||||
dataIndex: 'lesseeOrganization',
|
||||
render: (t: any) =>
|
||||
t ? (
|
||||
<Tag
|
||||
color={t.color}
|
||||
style={{ background: t.color, color: '#fff', borderColor: t.color }}
|
||||
>
|
||||
{t.name}
|
||||
</Tag>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
render: (t: any, r: any) => (
|
||||
<EditableCell
|
||||
value={r.lesseeOrganizationId}
|
||||
editor="select"
|
||||
options={organizations
|
||||
.filter((item) => item.status !== 'archived')
|
||||
.map((item) => ({ value: item.id, label: item.name }))}
|
||||
permission="rental:edit"
|
||||
disabled={r.effectiveStatus !== 'active'}
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'lesseeOrganizationId', next)}
|
||||
>
|
||||
{t ? (
|
||||
<Tag
|
||||
color={t.color}
|
||||
style={{ background: t.color, color: '#fff', borderColor: t.color }}
|
||||
>
|
||||
{t.name}
|
||||
</Tag>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '开始日期',
|
||||
dataIndex: 'startDate',
|
||||
width: 110,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="date"
|
||||
permission="rental:edit"
|
||||
disabled={r.effectiveStatus !== 'active'}
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'startDate', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结束日期',
|
||||
dataIndex: 'endDate',
|
||||
width: 110,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="date"
|
||||
permission="rental:edit"
|
||||
disabled={r.effectiveStatus !== 'active'}
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'endDate', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '开始日期', dataIndex: 'startDate', width: 110 },
|
||||
{ title: '结束日期', dataIndex: 'endDate', width: 110 },
|
||||
{
|
||||
title: '时长',
|
||||
width: 80,
|
||||
@@ -319,13 +387,35 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
title: '日租金',
|
||||
dataIndex: 'dailyRate',
|
||||
width: 100,
|
||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
||||
render: (v: any, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="money"
|
||||
min={0.01}
|
||||
permission="rental:edit"
|
||||
disabled={r.effectiveStatus !== 'active'}
|
||||
onSave={(next) => saveCell(r, 'dailyRate', next)}
|
||||
>
|
||||
{v ? `¥${v}` : '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '总额',
|
||||
dataIndex: 'totalAmount',
|
||||
width: 100,
|
||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
||||
render: (v: any, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="money"
|
||||
min={0.01}
|
||||
permission="rental:edit"
|
||||
disabled={r.effectiveStatus !== 'active'}
|
||||
onSave={(next) => saveCell(r, 'totalAmount', next)}
|
||||
>
|
||||
{v ? `¥${v}` : '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -448,7 +538,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[classrooms, organizations],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
@@ -110,6 +111,12 @@ const ClassroomsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: any, field: string, value: unknown) => {
|
||||
await api.put(`/classrooms/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleArchive = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/classrooms/${id}`);
|
||||
@@ -155,16 +162,83 @@ const ClassroomsPage: React.FC = () => {
|
||||
width: 120,
|
||||
dataIndex: 'name',
|
||||
sorter: (a: any, b: any) => a.name.localeCompare(b.name),
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="classroom:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveCell(r, 'name', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '楼栋',
|
||||
dataIndex: 'building',
|
||||
width: 80,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="classroom:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveCell(r, 'building', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '楼层',
|
||||
dataIndex: 'floor',
|
||||
width: 80,
|
||||
render: (v: number, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="number"
|
||||
permission="classroom:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveCell(r, 'floor', next)}
|
||||
>
|
||||
{v ?? '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '楼栋', dataIndex: 'building', width: 80 },
|
||||
{ title: '楼层', dataIndex: 'floor', width: 80 },
|
||||
{
|
||||
title: '类型',
|
||||
width: 90,
|
||||
dataIndex: 'roomType',
|
||||
render: (v: string) => <Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag>,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={['大', '次大', '小'].map((value) => ({ value, label: value }))}
|
||||
permission="classroom:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveCell(r, 'roomType', next)}
|
||||
>
|
||||
<Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '容量',
|
||||
dataIndex: 'capacity',
|
||||
width: 80,
|
||||
render: (v: number, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="number"
|
||||
min={0}
|
||||
permission="classroom:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveCell(r, 'capacity', next)}
|
||||
>
|
||||
{v ?? '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '容量', dataIndex: 'capacity', width: 80 },
|
||||
{
|
||||
title: '状态',
|
||||
width: 100,
|
||||
@@ -175,17 +249,29 @@ const ClassroomsPage: React.FC = () => {
|
||||
) => {
|
||||
const effectiveStatus = record.effectiveStatus || record.status;
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
record.currentUsage
|
||||
? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})`
|
||||
: undefined
|
||||
}
|
||||
<EditableCell
|
||||
value={record.status}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'available', label: '可用' },
|
||||
{ value: 'maintenance', label: '维护中' },
|
||||
]}
|
||||
permission="classroom:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'status', next)}
|
||||
>
|
||||
<Tag color={statusMap[effectiveStatus]?.color}>
|
||||
{statusMap[effectiveStatus]?.text || effectiveStatus}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={
|
||||
record.currentUsage
|
||||
? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Tag color={statusMap[effectiveStatus]?.color}>
|
||||
{statusMap[effectiveStatus]?.text || effectiveStatus}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</EditableCell>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
List,
|
||||
Card,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
@@ -18,6 +17,7 @@ import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
|
||||
|
||||
@@ -314,6 +314,20 @@ const DepositsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveInstallmentCell = async (
|
||||
installmentId: number,
|
||||
field: 'status' | 'paidDate',
|
||||
value: unknown,
|
||||
) => {
|
||||
await api.put(`/deposits/installments/${installmentId}`, { [field]: value });
|
||||
message.success('分期记录已保存');
|
||||
if (detailModal) {
|
||||
const refreshed = await api.get<DepositRecord>(`/deposits/${detailModal.id}`);
|
||||
setDetailModal(refreshed);
|
||||
}
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleDeleteInstallment = async (installmentId: number) => {
|
||||
try {
|
||||
await api.delete(`/deposits/installments/${installmentId}`);
|
||||
@@ -691,49 +705,85 @@ const DepositsPage: React.FC = () => {
|
||||
</PermissionButton>
|
||||
</div>
|
||||
{detailModal.installments && detailModal.installments.length > 0 ? (
|
||||
<List
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
dataSource={detailModal.installments}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
item.status === 'pending' && (
|
||||
<PermissionButton
|
||||
key="pay"
|
||||
permission="deposit:edit"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => handlePayInstallment(item.id)}
|
||||
>
|
||||
标记已缴
|
||||
</PermissionButton>
|
||||
),
|
||||
<Popconfirm
|
||||
key="archive"
|
||||
title="确定归档?"
|
||||
onConfirm={() => handleDeleteInstallment(item.id)}
|
||||
columns={[
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
render: (value: number) => `¥${Number(value).toFixed(2)}`,
|
||||
},
|
||||
{ title: '到期日', dataIndex: 'dueDate' },
|
||||
{
|
||||
title: '实付日',
|
||||
dataIndex: 'paidDate',
|
||||
render: (value: string, item: any) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="date"
|
||||
permission="deposit:edit"
|
||||
onSave={(next) => saveInstallmentCell(item.id, 'paidDate', next)}
|
||||
>
|
||||
<PermissionButton
|
||||
key="del"
|
||||
permission="deposit:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
{value || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (value: string, item: any) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'pending', label: '待缴' },
|
||||
{ value: 'paid', label: '已缴' },
|
||||
{ value: 'overdue', label: '逾期' },
|
||||
]}
|
||||
permission="deposit:edit"
|
||||
onSave={(next) => saveInstallmentCell(item.id, 'status', next)}
|
||||
>
|
||||
<Tag color={installmentStatusMap[value]?.color}>
|
||||
{installmentStatusMap[value]?.text || value}
|
||||
</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, item: any) => (
|
||||
<Space>
|
||||
{item.status === 'pending' && (
|
||||
<PermissionButton
|
||||
permission="deposit:edit"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => handlePayInstallment(item.id)}
|
||||
>
|
||||
标记已缴
|
||||
</PermissionButton>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定归档?"
|
||||
onConfirm={() => handleDeleteInstallment(item.id)}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>,
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={`¥${Number(item.amount).toFixed(2)}`}
|
||||
description={`到期: ${item.dueDate}${item.paidDate ? ` | 缴纳: ${item.paidDate}` : ''}`}
|
||||
/>
|
||||
<Tag color={installmentStatusMap[item.status]?.color}>
|
||||
{installmentStatusMap[item.status]?.text || item.status}
|
||||
</Tag>
|
||||
</List.Item>
|
||||
)}
|
||||
<PermissionButton
|
||||
permission="deposit:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p style={{ color: '#999' }}>暂无分期记录</p>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
@@ -197,6 +198,24 @@ const ExpensesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveRoomCell = useCallback(
|
||||
async (record: any, field: string, value: unknown) => {
|
||||
await api.put(`/expenses/room/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
},
|
||||
[fetchData],
|
||||
);
|
||||
|
||||
const savePersonalCell = useCallback(
|
||||
async (record: any, field: string, value: unknown) => {
|
||||
await api.put(`/expenses/personal/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
},
|
||||
[fetchData],
|
||||
);
|
||||
|
||||
const handleStudentUtility = async () => {
|
||||
const values = await utilityForm.validateFields();
|
||||
setSaving(true);
|
||||
@@ -257,25 +276,87 @@ const ExpensesPage: React.FC = () => {
|
||||
|
||||
const roomColumns = useMemo(
|
||||
() => [
|
||||
{ title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' },
|
||||
{
|
||||
title: '宿舍',
|
||||
width: 120,
|
||||
render: (_: any, r: any) => (
|
||||
<EditableCell
|
||||
value={r.roomId}
|
||||
editor="select"
|
||||
options={rooms.map((item) => ({ value: item.id, label: item.roomNumber }))}
|
||||
permission="expense:edit"
|
||||
required
|
||||
onSave={(next) => saveRoomCell(r, 'roomId', next)}
|
||||
>
|
||||
{r.room?.roomNumber || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '费用类型',
|
||||
width: 100,
|
||||
dataIndex: 'expenseType',
|
||||
render: (v: string) => <Tag>{typeMap[v] || v}</Tag>,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={typeOptions}
|
||||
permission="expense:edit"
|
||||
required
|
||||
onSave={(next) => saveRoomCell(r, 'expenseType', next)}
|
||||
>
|
||||
<Tag>{typeMap[v] || v}</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
width: 100,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
render: (v: number, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="money"
|
||||
min={0.01}
|
||||
permission="expense:edit"
|
||||
required
|
||||
onSave={(next) => saveRoomCell(r, 'amount', next)}
|
||||
>{`¥${Number(v).toFixed(2)}`}</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账单周期',
|
||||
width: 200,
|
||||
render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`,
|
||||
render: (_: any, r: any) => (
|
||||
<EditableCell
|
||||
value={[r.periodStart, r.periodEnd]}
|
||||
editor="date-range"
|
||||
permission="expense:edit"
|
||||
required
|
||||
onSave={async (next) => {
|
||||
const [periodStart, periodEnd] = next as unknown as [string, string];
|
||||
await api.put(`/expenses/room/${r.id}`, { periodStart, periodEnd });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
}}
|
||||
>{`${r.periodStart} ~ ${r.periodEnd}`}</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '说明',
|
||||
dataIndex: 'description',
|
||||
width: 150,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="textarea"
|
||||
permission="expense:edit"
|
||||
onSave={(next) => saveRoomCell(r, 'description', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '说明', dataIndex: 'description', width: 150 },
|
||||
{
|
||||
title: '录入时间',
|
||||
width: 160,
|
||||
@@ -326,21 +407,89 @@ const ExpensesPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[setEditingRoom, roomForm, setRoomModal, fetchData, typeMap],
|
||||
[rooms, typeOptions, typeMap, saveRoomCell, roomForm, fetchData],
|
||||
);
|
||||
|
||||
const personalColumns = useMemo(
|
||||
() => [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{
|
||||
title: '学生',
|
||||
width: 120,
|
||||
render: (_: any, r: any) => (
|
||||
<EditableCell
|
||||
value={r.studentId}
|
||||
editor="select"
|
||||
options={students.map((item) => ({ value: item.id, label: item.name }))}
|
||||
permission="expense:edit"
|
||||
required
|
||||
onSave={(next) => savePersonalCell(r, 'studentId', next)}
|
||||
>
|
||||
{r.student?.name || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '费用类型',
|
||||
width: 100,
|
||||
dataIndex: 'expenseType',
|
||||
render: (v: string) => <Tag color="orange">{typeMap[v] || v}</Tag>,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={personalTypeOptions}
|
||||
permission="expense:edit"
|
||||
required
|
||||
onSave={(next) => savePersonalCell(r, 'expenseType', next)}
|
||||
>
|
||||
<Tag color="orange">{typeMap[v] || v}</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
render: (v: number, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="money"
|
||||
min={0.01}
|
||||
permission="expense:edit"
|
||||
required
|
||||
onSave={(next) => savePersonalCell(r, 'amount', next)}
|
||||
>{`¥${Number(v).toFixed(2)}`}</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'expenseDate',
|
||||
width: 110,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="date"
|
||||
permission="expense:edit"
|
||||
required
|
||||
onSave={(next) => savePersonalCell(r, 'expenseDate', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '说明',
|
||||
dataIndex: 'description',
|
||||
width: 150,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="textarea"
|
||||
permission="expense:edit"
|
||||
onSave={(next) => savePersonalCell(r, 'description', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: '日期', dataIndex: 'expenseDate', width: 110 },
|
||||
{ title: '说明', dataIndex: 'description', width: 150 },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
@@ -386,7 +535,7 @@ const ExpensesPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap],
|
||||
[students, personalTypeOptions, typeMap, savePersonalCell, personalForm, fetchData],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag
|
||||
import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
@@ -93,46 +94,108 @@ const OrganizationsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: OrganizationItem, field: string, value: unknown) => {
|
||||
await api.put(`/organizations/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '机构',
|
||||
dataIndex: 'name',
|
||||
width: 220,
|
||||
render: (name: string, record: OrganizationItem) => (
|
||||
<Space>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: record.color || '#8c8c8c',
|
||||
}}
|
||||
/>
|
||||
<strong>{name}</strong>
|
||||
{record.isHost ? (
|
||||
<Tag color="blue" icon={<BankOutlined />}>
|
||||
本机构
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>外部机构</Tag>
|
||||
)}
|
||||
</Space>
|
||||
<EditableCell
|
||||
value={name}
|
||||
required
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'name', next)}
|
||||
>
|
||||
<Space>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: record.color || '#8c8c8c',
|
||||
}}
|
||||
/>
|
||||
<strong>{name}</strong>
|
||||
{record.isHost ? (
|
||||
<Tag color="blue" icon={<BankOutlined />}>
|
||||
本机构
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>外部机构</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '机构编码',
|
||||
dataIndex: 'code',
|
||||
width: 130,
|
||||
render: (value: string) => <code>{value}</code>,
|
||||
render: (value: string, record: OrganizationItem) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
required
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'code', next)}
|
||||
>
|
||||
<code>{value}</code>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contactName',
|
||||
width: 120,
|
||||
render: (value?: string) => value || '-',
|
||||
render: (value: string | undefined, record: OrganizationItem) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'contactName', next)}
|
||||
>
|
||||
{value || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
width: 140,
|
||||
render: (value: string | undefined, record: OrganizationItem) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'phone', next)}
|
||||
>
|
||||
{value || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
ellipsis: true,
|
||||
render: (value: string | undefined, record: OrganizationItem) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="textarea"
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'notes', next)}
|
||||
>
|
||||
{value || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '电话', dataIndex: 'phone', width: 140, render: (value?: string) => value || '-' },
|
||||
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (value?: string) => value || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Table, Modal, Form, Input, Space, Tag, Popconfirm, Card, Checkbox, Empt
|
||||
import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface PermissionItem {
|
||||
@@ -133,21 +134,74 @@ const RolesPage: React.FC = () => {
|
||||
ai: 'AI 配置',
|
||||
};
|
||||
|
||||
const permissionOptions = useMemo(
|
||||
() =>
|
||||
allPerms
|
||||
.flatMap((group) => group.permissions)
|
||||
.map((item) => ({ value: item.id, label: `${item.group} · ${item.name}` })),
|
||||
[allPerms],
|
||||
);
|
||||
const saveCell = useCallback(
|
||||
async (record: RoleItem, field: string, value: unknown) => {
|
||||
await api.put(`/rbac/roles/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
},
|
||||
[fetchData],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '描述', dataIndex: 'description', width: 200 },
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v: string, r: RoleItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="role:edit"
|
||||
onSave={(next) => saveCell(r, 'name', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
width: 200,
|
||||
render: (v: string, r: RoleItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="textarea"
|
||||
permission="role:edit"
|
||||
onSave={(next) => saveCell(r, 'description', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权限标签',
|
||||
dataIndex: 'permissions',
|
||||
width: 150,
|
||||
render: (perms: PermissionItem[]) =>
|
||||
perms?.length > 0 ? (
|
||||
<Tag color="blue">{perms.length} 个权限</Tag>
|
||||
) : (
|
||||
<Tag color="default">无权限</Tag>
|
||||
),
|
||||
render: (perms: PermissionItem[], record: RoleItem) => (
|
||||
<EditableCell
|
||||
value={perms?.map((item) => item.id) || []}
|
||||
editor="multi-select"
|
||||
options={permissionOptions}
|
||||
permission="role:edit"
|
||||
onSave={(next) => saveCell(record, 'permissionIds', next)}
|
||||
>
|
||||
{perms?.length > 0 ? (
|
||||
<Tag color="blue">{perms.length} 个权限</Tag>
|
||||
) : (
|
||||
<Tag color="default">无权限</Tag>
|
||||
)}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
@@ -186,7 +240,7 @@ const RolesPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[permissionOptions, saveCell],
|
||||
);
|
||||
|
||||
const handleGroupCheckAll = (group: string, checked: boolean) => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
@@ -199,6 +200,26 @@ const RoomsPage: React.FC = () => {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveRoomCell = async (record: any, field: string, value: unknown) => {
|
||||
await api.put(`/rooms/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const saveBedCell = async (record: BedItem, field: string, value: unknown) => {
|
||||
if (!drawerRoom) return;
|
||||
await api.put(`/rooms/${drawerRoom.id}/beds/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchBeds(drawerRoom.id);
|
||||
};
|
||||
|
||||
const saveLockerCell = async (record: LockerItem, field: string, value: unknown) => {
|
||||
if (!drawerRoom) return;
|
||||
await api.put(`/rooms/${drawerRoom.id}/lockers/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchLockers(drawerRoom.id);
|
||||
};
|
||||
const fetchBeds = useCallback(async (roomId: number) => {
|
||||
try {
|
||||
const res = await api.get<BedItem[]>(`/rooms/${roomId}/beds`);
|
||||
@@ -337,27 +358,123 @@ const RoomsPage: React.FC = () => {
|
||||
dataIndex: 'roomNumber',
|
||||
width: 100,
|
||||
sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber),
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="room:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveRoomCell(r, 'roomNumber', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '楼栋',
|
||||
dataIndex: 'building',
|
||||
width: 80,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="room:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveRoomCell(r, 'building', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '楼层',
|
||||
dataIndex: 'floor',
|
||||
width: 80,
|
||||
render: (v: number, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="number"
|
||||
permission="room:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveRoomCell(r, 'floor', next)}
|
||||
>
|
||||
{v ?? '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'roomType',
|
||||
width: 90,
|
||||
render: (v: any, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="room:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveRoomCell(r, 'roomType', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '楼栋', dataIndex: 'building', width: 80 },
|
||||
{ title: '楼层', dataIndex: 'floor', width: 80 },
|
||||
{ title: '类型', dataIndex: 'roomType', width: 90, render: (v: any) => v || '-' },
|
||||
{
|
||||
title: '租赁类型',
|
||||
dataIndex: 'rentalCategory',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
if (v === 'long') return <Tag color="blue">长租</Tag>;
|
||||
if (v === 'short') return <Tag color="green">短租</Tag>;
|
||||
return '-';
|
||||
},
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'long', label: '长租' },
|
||||
{ value: 'short', label: '短租' },
|
||||
]}
|
||||
permission="room:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveRoomCell(r, 'rentalCategory', next)}
|
||||
>
|
||||
{(() => {
|
||||
if (v === 'long') return <Tag color="blue">长租</Tag>;
|
||||
if (v === 'short') return <Tag color="green">短租</Tag>;
|
||||
return '-';
|
||||
})()}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '月租金',
|
||||
dataIndex: 'monthlyRate',
|
||||
width: 100,
|
||||
render: (v: number) => (v ? `¥${v}` : '-'),
|
||||
render: (v: number, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="money"
|
||||
min={0}
|
||||
permission="room:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveRoomCell(r, 'monthlyRate', next)}
|
||||
>
|
||||
{v ? `¥${v}` : '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '额定人数',
|
||||
dataIndex: 'capacity',
|
||||
width: 80,
|
||||
render: (v: number, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="number"
|
||||
min={1}
|
||||
required
|
||||
permission="room:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveRoomCell(r, 'capacity', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '额定人数', dataIndex: 'capacity', width: 80 },
|
||||
{
|
||||
title: '当前入住',
|
||||
width: 80,
|
||||
@@ -377,7 +494,22 @@ const RoomsPage: React.FC = () => {
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||||
render: (s: string, r: any) => (
|
||||
<EditableCell
|
||||
value={s}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'available', label: '可入住' },
|
||||
{ value: 'full', label: '已满' },
|
||||
{ value: 'maintenance', label: '维修中' },
|
||||
]}
|
||||
permission="room:edit"
|
||||
disabled={r.status === 'archived'}
|
||||
onSave={(next) => saveRoomCell(r, 'status', next)}
|
||||
>
|
||||
<Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -778,21 +910,65 @@ const RoomsPage: React.FC = () => {
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '编号', dataIndex: 'bedNumber', width: 80 },
|
||||
{
|
||||
title: '编号',
|
||||
dataIndex: 'bedNumber',
|
||||
width: 80,
|
||||
render: (v: string, r: BedItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="room:edit"
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
onSave={(next) => saveBedCell(r, 'bedNumber', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string) => {
|
||||
const map: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '空闲', color: 'green' },
|
||||
occupied: { text: '占用', color: 'blue' },
|
||||
maintenance: { text: '维修', color: 'orange' },
|
||||
};
|
||||
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
||||
},
|
||||
render: (s: string, r: BedItem) => (
|
||||
<EditableCell
|
||||
value={s}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'available', label: '空闲' },
|
||||
{ value: 'occupied', label: '占用' },
|
||||
{ value: 'maintenance', label: '维修' },
|
||||
]}
|
||||
permission="room:edit"
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
onSave={(next) => saveBedCell(r, 'status', next)}
|
||||
>
|
||||
{(() => {
|
||||
const map: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '空闲', color: 'green' },
|
||||
occupied: { text: '占用', color: 'blue' },
|
||||
maintenance: { text: '维修', color: 'orange' },
|
||||
};
|
||||
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
||||
})()}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
render: (v: string, r: BedItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="textarea"
|
||||
permission="room:edit"
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
onSave={(next) => saveBedCell(r, 'notes', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
@@ -885,21 +1061,65 @@ const RoomsPage: React.FC = () => {
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '编号', dataIndex: 'lockerNumber', width: 80 },
|
||||
{
|
||||
title: '编号',
|
||||
dataIndex: 'lockerNumber',
|
||||
width: 80,
|
||||
render: (v: string, r: LockerItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="room:edit"
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
onSave={(next) => saveLockerCell(r, 'lockerNumber', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string) => {
|
||||
const map: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '空闲', color: 'green' },
|
||||
occupied: { text: '占用', color: 'blue' },
|
||||
maintenance: { text: '维修', color: 'orange' },
|
||||
};
|
||||
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
||||
},
|
||||
render: (s: string, r: LockerItem) => (
|
||||
<EditableCell
|
||||
value={s}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'available', label: '空闲' },
|
||||
{ value: 'occupied', label: '占用' },
|
||||
{ value: 'maintenance', label: '维修' },
|
||||
]}
|
||||
permission="room:edit"
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
onSave={(next) => saveLockerCell(r, 'status', next)}
|
||||
>
|
||||
{(() => {
|
||||
const map: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '空闲', color: 'green' },
|
||||
occupied: { text: '占用', color: 'blue' },
|
||||
maintenance: { text: '维修', color: 'orange' },
|
||||
};
|
||||
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
||||
})()}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
render: (v: string, r: LockerItem) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
editor="textarea"
|
||||
permission="room:edit"
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
onSave={(next) => saveLockerCell(r, 'notes', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
@@ -170,7 +171,14 @@ const StudentsPage: React.FC = () => {
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [searchName, showArchived, filterStatus, filterOrganizationId, filterClassId, filterTeacherId]);
|
||||
}, [
|
||||
searchName,
|
||||
showArchived,
|
||||
filterStatus,
|
||||
filterOrganizationId,
|
||||
filterClassId,
|
||||
filterTeacherId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -213,6 +221,15 @@ const StudentsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = useCallback(
|
||||
async (record: any, field: string, value: unknown) => {
|
||||
await api.put(`/students/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
},
|
||||
[fetchData],
|
||||
);
|
||||
|
||||
const handleArchive = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/students/${id}`);
|
||||
@@ -376,9 +393,15 @@ const StudentsPage: React.FC = () => {
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v: string, record: any) => (
|
||||
<Button type="link" size="small" onClick={() => openDrawer(record.id)}>
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="student:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'name', next)}
|
||||
>
|
||||
{v}
|
||||
</Button>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -407,7 +430,16 @@ const StudentsPage: React.FC = () => {
|
||||
title: '学号',
|
||||
dataIndex: 'studentNo',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-',
|
||||
render: (v: string, record: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="student:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'studentNo', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '身份证',
|
||||
@@ -431,8 +463,36 @@ const StudentsPage: React.FC = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
|
||||
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
|
||||
{
|
||||
title: '民族',
|
||||
dataIndex: 'ethnicity',
|
||||
width: 90,
|
||||
render: (v: string, record: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="student:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'ethnicity', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '紧急联系人',
|
||||
dataIndex: 'emergencyContact',
|
||||
width: 100,
|
||||
render: (v: string, record: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="student:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'emergencyContact', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '紧急联系人电话',
|
||||
dataIndex: 'emergencyPhone',
|
||||
@@ -459,30 +519,68 @@ const StudentsPage: React.FC = () => {
|
||||
title: '所属机构',
|
||||
dataIndex: 'organization',
|
||||
width: 100,
|
||||
render: (organization: { name?: string } | null) =>
|
||||
organization?.name ? (
|
||||
<Tag
|
||||
color="purple"
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{organization.name}
|
||||
</Tag>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
render: (organization: { name?: string } | null, record: any) => (
|
||||
<EditableCell
|
||||
value={record.organizationId}
|
||||
editor="select"
|
||||
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
|
||||
permission="student:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
required
|
||||
onSave={(next) => saveCell(record, 'organizationId', next)}
|
||||
>
|
||||
{organization?.name ? (
|
||||
<Tag
|
||||
color="purple"
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{organization.name}
|
||||
</Tag>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'supervisor',
|
||||
width: 100,
|
||||
render: (v: string, record: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="student:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'supervisor', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{ title: '负责人', dataIndex: 'supervisor', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string) => (
|
||||
<Tag
|
||||
color={statusMap[s]?.color}
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
render: (s: string, record: any) => (
|
||||
<EditableCell
|
||||
value={s}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'active', label: '在读' },
|
||||
{ value: 'graduated', label: '已毕业' },
|
||||
{ value: 'withdrawn', label: '已退训' },
|
||||
]}
|
||||
permission="student:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'status', next)}
|
||||
>
|
||||
{statusMap[s]?.text || s}
|
||||
</Tag>
|
||||
<Tag
|
||||
color={statusMap[s]?.color}
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{statusMap[s]?.text || s}
|
||||
</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -547,7 +645,7 @@ const StudentsPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleViewSensitive, openDrawer, showArchived, organizations],
|
||||
[handleViewSensitive, openDrawer, showArchived, organizations, saveCell],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { EditOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
|
||||
interface TeacherRow {
|
||||
id: number;
|
||||
@@ -101,6 +102,15 @@ const TeachersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveProfileCell = useCallback(
|
||||
async (record: TeacherRow, field: string, value: unknown) => {
|
||||
await api.put(`/rbac/teachers/${record.id}/profile`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
},
|
||||
[fetchData],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
|
||||
@@ -133,14 +143,33 @@ const TeachersPage: React.FC = () => {
|
||||
dataIndex: 'profile',
|
||||
key: 'subjects',
|
||||
width: 130,
|
||||
render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-',
|
||||
render: (p: TeacherRow['profile'], r: TeacherRow) => (
|
||||
<EditableCell
|
||||
value={p?.subjects || []}
|
||||
editor="tags"
|
||||
options={(p?.subjects || []).map((value) => ({ value, label: value }))}
|
||||
permission="teacher:edit"
|
||||
onSave={(next) => saveProfileCell(r, 'subjects', next)}
|
||||
>
|
||||
{p?.subjects?.join('、') || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '入职日期',
|
||||
dataIndex: 'profile',
|
||||
key: 'joinedAt',
|
||||
width: 110,
|
||||
render: (p: TeacherRow['profile']) => p?.joinedAt || '-',
|
||||
render: (p: TeacherRow['profile'], r: TeacherRow) => (
|
||||
<EditableCell
|
||||
value={p?.joinedAt}
|
||||
editor="date"
|
||||
permission="teacher:edit"
|
||||
onSave={(next) => saveProfileCell(r, 'joinedAt', next)}
|
||||
>
|
||||
{p?.joinedAt || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -178,7 +207,7 @@ const TeachersPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[saveProfileCell],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { userProfileResponseToFormValues, type UserProfileResponse } from './user-profile-form';
|
||||
|
||||
@@ -153,31 +154,94 @@ const UsersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = useCallback(
|
||||
async (record: any, field: string, value: unknown) => {
|
||||
await api.put(`/rbac/users/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
},
|
||||
[fetchData],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '用户名', dataIndex: 'username', width: 120 },
|
||||
{ title: '姓名', dataIndex: 'name', width: 120 },
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
width: 120,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="user:edit"
|
||||
disabled={r.isArchived}
|
||||
onSave={(next) => saveCell(r, 'username', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v: string, r: any) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
required
|
||||
permission="user:edit"
|
||||
disabled={r.isArchived}
|
||||
onSave={(next) => saveCell(r, 'name', next)}
|
||||
>
|
||||
{v}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'roles',
|
||||
width: 200,
|
||||
render: (v: any[]) =>
|
||||
v && v.length > 0 ? (
|
||||
v.map((r: any) => (
|
||||
<Tag key={r.id} color="blue">
|
||||
{r.name}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Tag color="default">无角色</Tag>
|
||||
),
|
||||
render: (v: any[], record: any) => (
|
||||
<EditableCell
|
||||
value={v?.map((item) => item.id) || []}
|
||||
editor="multi-select"
|
||||
options={roles.map((item) => ({ value: item.id, label: item.name }))}
|
||||
permission="user:edit"
|
||||
disabled={record.isArchived}
|
||||
onSave={(next) => saveCell(record, 'roleIds', next)}
|
||||
>
|
||||
{v && v.length > 0 ? (
|
||||
v.map((r: any) => (
|
||||
<Tag key={r.id} color="blue">
|
||||
{r.name}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Tag color="default">无角色</Tag>
|
||||
)}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'isActive',
|
||||
width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
||||
render: (v: boolean, r: any) => (
|
||||
<EditableCell
|
||||
value={String(v)}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'true', label: '启用' },
|
||||
{ value: 'false', label: '禁用' },
|
||||
]}
|
||||
permission="user:edit"
|
||||
disabled={r.isArchived}
|
||||
onSave={(next) => saveCell(r, 'isActive', String(next) === 'true')}
|
||||
>
|
||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
@@ -244,7 +308,7 @@ const UsersPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[roles, saveCell],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user