feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -1,310 +1,28 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Form, Input, Modal, Popconfirm, Select, Spin, Steps, Tag, Typography } from 'antd';
import {
CloudUploadOutlined,
DeleteOutlined,
EditOutlined,
LinkOutlined,
PlusOutlined,
SaveOutlined,
SearchOutlined,
} from '@ant-design/icons';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useImmer } from 'use-immer';
import { Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd';
import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import api from '../api';
import { message } from '../ui/app-message';
import { usePermission } from '../hooks/usePermission';
import PermissionButton from './PermissionButton';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../hooks/useApiMutation';
import { validateResponse } from '../utils/validate';
import { jinshujuRulesSchema } from '../api/schemas';
import MatchStep from './MatchStep';
import RuleEditor from './RuleEditor';
import type {
JinshujuEntryRow,
JinshujuFormField,
MatchDecision,
MatchRule,
PreviewResponse,
StudentOption,
} from './JinshujuMatchModal.types';
const { Text } = Typography;
// ── Types ──
interface JinshujuEntryRow {
serialNumber: number;
name: string;
phone: string | null;
suggestedStudent: {
id: number;
name: string;
phone: string | null;
studentNo: string | null;
} | null;
}
interface StudentOption {
id: number;
name: string;
phone: string | null;
studentNo: string | null;
}
interface PreviewResponse {
success: boolean;
entries: JinshujuEntryRow[];
students: StudentOption[];
}
interface MatchRule {
id: number;
name: string;
formToken: string;
mappings: Record<string, string>;
createdAt: string;
}
interface JinshujuFormField {
key: string;
label: string;
type: string;
}
type MatchDecision =
| { action: 'match'; matchStudentId: number }
| { action: 'create'; createName: string; createPhone: string }
| { action: 'skip' };
// ── Constants ──
const ROW_HEIGHT = 72;
const LEFT_WIDTH = 260;
const GAP = 80;
const STUDENT_FIELDS = [
{ key: 'name', label: '姓名' },
{ key: 'phone', label: '手机号' },
{ key: 'idNumber', label: '身份证号' },
{ key: 'gender', label: '性别' },
{ key: 'ethnicity', label: '民族' },
{ key: 'emergencyContact', label: '紧急联系人' },
{ key: 'emergencyPhone', label: '紧急联系电话' },
{ key: 'studentNo', label: '学号' },
];
// ── MatchSelector sub-component ──
interface MatchSelectorProps {
entry: JinshujuEntryRow;
decision: MatchDecision | undefined;
studentOptions: StudentOption[];
onChange: (d: MatchDecision) => void;
}
const MatchSelector: React.FC<MatchSelectorProps> = ({
entry,
decision,
studentOptions,
onChange,
}) => {
const action = decision?.action ?? 'skip';
if (action === 'match') {
const matchD = decision as { action: 'match'; matchStudentId: number };
const matchedStudent = studentOptions.find((s) => s.id === matchD.matchStudentId);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Tag color="blue" icon={<LinkOutlined />}>
</Tag>
<Text style={{ flex: 1 }}>
{matchedStudent?.name ?? '未知'}
{matchedStudent?.studentNo && (
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>
({matchedStudent.studentNo})
</Text>
)}
</Text>
<Button size="small" type="link" danger onClick={() => onChange({ action: 'skip' })}>
</Button>
</div>
);
}
if (action === 'create') {
const createD = decision as { action: 'create'; createName: string; createPhone: string };
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Tag color="green" icon={<PlusOutlined />}>
</Tag>
<Input
size="small"
value={createD.createName}
placeholder="姓名"
style={{ width: 100 }}
onChange={(e) =>
onChange({
action: 'create',
createName: e.target.value,
createPhone: createD.createPhone,
})
}
/>
<Input
size="small"
value={createD.createPhone}
placeholder="手机号"
style={{ width: 120 }}
onChange={(e) =>
onChange({
action: 'create',
createName: createD.createName,
createPhone: e.target.value,
})
}
/>
<Button size="small" type="link" danger onClick={() => onChange({ action: 'skip' })}>
</Button>
</div>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Select
showSearch
size="small"
placeholder="搜索学生…"
style={{ flex: 1 }}
value={undefined}
filterOption={(input, option) =>
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())
}
options={studentOptions.map((s) => ({
value: s.id,
label: `${s.name}${s.phone ? ` (${s.phone})` : ''}${s.studentNo ? ` [${s.studentNo}]` : ''}`,
}))}
onChange={(studentId: number) => onChange({ action: 'match', matchStudentId: studentId })}
/>
<Button
size="small"
type="dashed"
icon={<PlusOutlined />}
onClick={() =>
onChange({
action: 'create',
createName: entry.name || '',
createPhone: entry.phone || '',
})
}
>
</Button>
<Button size="small" type="link" onClick={() => onChange({ action: 'skip' })}>
</Button>
</div>
);
};
// ── Rule Editor sub-component ──
interface RuleEditorProps {
rule: MatchRule | null;
formToken: string;
fields: JinshujuFormField[];
onSave: () => void;
onDelete: (id: number) => void;
onCancel: () => void;
}
const RuleEditor: React.FC<RuleEditorProps> = ({
rule,
formToken,
fields,
onSave,
onDelete,
onCancel,
}) => {
const [name, setName] = useState(rule?.name ?? '');
const [mappings, setMappings] = useState<Record<string, string>>(
rule?.mappings ?? { name: 'field_1', phone: 'field_2' },
);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!name.trim()) {
message.warning('请输入规则名称');
return;
}
setSaving(true);
try {
if (rule) {
await api.put(`/sync/jinshuju/rules/${rule.id}`, { name, mappings });
} else {
await api.post('/sync/jinshuju/rules', { name, formToken, mappings });
}
message.success('规则已保存');
onSave();
} catch (e: unknown) {
const err = e as { message?: string };
if (err?.message) message.error(err.message);
} finally {
setSaving(false);
}
};
return (
<div style={{ padding: '12px 0' }}>
<Input
placeholder="规则名称"
value={name}
onChange={(e) => setName(e.target.value)}
style={{ marginBottom: 12 }}
/>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
</Text>
{STUDENT_FIELDS.map((sf) => (
<div
key={sf.key}
style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}
>
<Text style={{ width: 100, textAlign: 'right', fontSize: 13 }}>{sf.label}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="选择金数据字段"
value={mappings[sf.key]}
style={{ flex: 1 }}
options={fields.map((field) => ({
value: field.key,
label: `${field.label}${field.key}`,
}))}
onChange={(value) =>
setMappings((prev) => {
const next = { ...prev };
if (value) next[sf.key] = value;
else delete next[sf.key];
return next;
})
}
/>
</div>
))}
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
</Button>
{rule && (
<Popconfirm title="确定删除此规则?" onConfirm={() => onDelete(rule.id)}>
<Button danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
<Button onClick={onCancel}></Button>
</div>
</div>
);
};
// ── Main MatchModal ──
interface MatchModalProps {
open: boolean;
onClose: () => void;
@@ -318,7 +36,6 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
const [step, setStep] = useState<'connection' | 'rule' | 'match' | 'applying'>('connection');
const [loading, setLoading] = useState(false);
const [selectedRuleId, setSelectedRuleId] = useState<number | undefined>();
const [rules, setRules] = useState<MatchRule[]>([]);
const [editingRule, setEditingRule] = useState<MatchRule | null>(null);
const [showRuleEditor, setShowRuleEditor] = useState(false);
const [credForm] = Form.useForm();
@@ -326,40 +43,35 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
const [entries, setEntries] = useState<JinshujuEntryRow[]>([]);
const [studentOptions, setStudentOptions] = useState<StudentOption[]>([]);
const [decisions, setDecisions] = useState<Map<number, MatchDecision>>(new Map());
const [decisions, setDecisions] = useImmer<Map<number, MatchDecision>>(new Map());
const leftRef = useRef<HTMLDivElement>(null);
const rightRef = useRef<HTMLDivElement>(null);
const [formFields, setFormFields] = useState<JinshujuFormField[]>([]);
const [formName, setFormName] = useState('');
const [scrollTop, setScrollTop] = useState(0);
// Load rules on open
useEffect(() => {
if (open && canEnterModal) loadRules();
}, [open, canEnterModal]);
// Close and reset when permission is lost
const enteredRef = useRef(false);
useEffect(() => {
if (canEnterModal) {
enteredRef.current = true;
return;
}
if (enteredRef.current) {
enteredRef.current = false;
reset();
onClose();
}
}, [canEnterModal, onClose]);
const loadRules = async () => {
try {
const res = await api.get<{ success: boolean; data: MatchRule[] }>('/sync/jinshuju/rules');
if (res.success) setRules(res.data);
} catch {
/* ignore */
}
};
const {
data: rules = [],
refetch: refetchRules,
} = useQuery<MatchRule[]>({
queryKey: ['sync', 'jinshuju', 'rules'],
enabled: open && canEnterModal,
queryFn: async () => {
try {
const res = await api.get<{ success: boolean; data: MatchRule[] }>(
'/sync/jinshuju/rules',
);
return res.success ? validateResponse<MatchRule[]>(jinshujuRulesSchema, res.data) : [];
} catch {
return [];
}
},
});
const loadRules = useCallback(() => refetchRules(), [refetchRules]);
const deleteRuleMutation = useApiMutation(
async (id: number) => api.delete(`/sync/jinshuju/rules/${id}`),
{ invalidate: [['sync', 'jinshuju', 'rules']] },
);
const handleConnectionNext = async () => {
if (!canTriggerSync) return;
@@ -471,7 +183,9 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
const getDecision = (serial: number): MatchDecision | undefined => decisions.get(serial);
const setDecision = (serial: number, d: MatchDecision) =>
setDecisions((prev) => new Map(prev).set(serial, d));
setDecisions((draft) => {
draft.set(serial, d);
});
const total = entries.length;
const matched = [...decisions.values()].filter((d) => d.action !== 'skip').length;
@@ -566,11 +280,14 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
loadRules();
}}
onDelete={async (id) => {
await api.delete(`/sync/jinshuju/rules/${id}`);
message.success('规则已删除');
if (selectedRuleId === id) setSelectedRuleId(undefined);
setShowRuleEditor(false);
loadRules();
try {
await deleteRuleMutation.mutateAsync(id);
message.success('规则已删除');
if (selectedRuleId === id) setSelectedRuleId(undefined);
setShowRuleEditor(false);
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
onCancel={() => setShowRuleEditor(false)}
/>
@@ -579,128 +296,31 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
);
const renderMatchStep = () => {
const svgHeight = entries.length * ROW_HEIGHT;
const lines: React.ReactNode[] = [];
entries.forEach((entry, i) => {
const y = i * ROW_HEIGHT + ROW_HEIGHT / 2;
const d = getDecision(entry.serialNumber);
const isMatched = d?.action === 'match';
const color = isMatched ? '#1677ff' : '#d9d9d9';
lines.push(
<line
key={entry.serialNumber}
x1={LEFT_WIDTH}
y1={y}
x2={LEFT_WIDTH + GAP}
y2={y}
stroke={color}
strokeWidth={isMatched ? 2 : 1}
strokeDasharray={isMatched ? undefined : '4 4'}
opacity={isMatched ? 0.7 : 0.3}
/>,
);
});
return (
<div style={{ position: 'relative' }}>
<div
style={{
marginBottom: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Text type="secondary">
{total} {matched}
</Text>
<Button size="small" onClick={() => setDecisions(new Map())}>
</Button>
</div>
<div style={{ display: 'flex', position: 'relative' }}>
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: LEFT_WIDTH + GAP,
height: svgHeight,
pointerEvents: 'none',
zIndex: 1,
}}
>
{lines}
</svg>
<div
ref={leftRef}
onScroll={() => handleScroll('left')}
style={{ width: LEFT_WIDTH, maxHeight: 480, overflowY: 'auto', flexShrink: 0 }}
>
{entries.map((entry, i) => {
const d = getDecision(entry.serialNumber);
const isMatched = d?.action === 'match';
return (
<div
key={entry.serialNumber}
style={{
height: ROW_HEIGHT,
padding: '8px 12px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
background: isMatched ? '#f6ffed' : i % 2 === 0 ? '#fafafa' : '#fff',
borderLeft: isMatched ? '3px solid #1677ff' : '3px solid transparent',
}}
>
<Text strong style={{ fontSize: 13 }}>
{entry.name || <Text type="secondary"></Text>}
</Text>
{entry.phone && (
<Text type="secondary" style={{ fontSize: 12 }}>
{entry.phone}
</Text>
)}
<Text type="secondary" style={{ fontSize: 11 }}>
#{entry.serialNumber}
</Text>
</div>
);
})}
</div>
<div style={{ width: GAP, flexShrink: 0 }} />
<div
ref={rightRef}
onScroll={() => handleScroll('right')}
style={{ flex: 1, maxHeight: 480, overflowY: 'auto' }}
>
{entries.map((entry) => (
<div
key={entry.serialNumber}
style={{
height: ROW_HEIGHT,
padding: '8px 12px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<MatchSelector
entry={entry}
decision={getDecision(entry.serialNumber)}
studentOptions={studentOptions}
onChange={(newD) => setDecision(entry.serialNumber, newD)}
/>
</div>
))}
</div>
</div>
</div>
<MatchStep
entries={entries}
studentOptions={studentOptions}
getDecision={getDecision}
onDecisionChange={setDecision}
onClear={() => setDecisions(new Map())}
leftRef={leftRef}
rightRef={rightRef}
onScroll={handleScroll}
total={total}
matched={matched}
/>
);
};
const backCancelButtons = (onBack: () => void) => [
<Button key="back" onClick={onBack}>
</Button>,
<Button key="cancel" onClick={handleClose}>
</Button>,
];
const currentStep = step === 'connection' ? 0 : step === 'rule' ? 1 : 2;
return (
@@ -722,12 +342,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
]
: step === 'rule'
? [
<Button key="back" onClick={() => setStep('connection')}>
</Button>,
<Button key="cancel" onClick={handleClose}>
</Button>,
...backCancelButtons(() => setStep('connection')),
<Button
key="next"
type="primary"
@@ -740,12 +355,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
]
: step === 'match'
? [
<Button key="back" onClick={() => setStep('rule')}>
</Button>,
<Button key="cancel" onClick={handleClose}>
</Button>,
...backCancelButtons(() => setStep('rule')),
canTriggerSync ? (
<PermissionButton
key="apply"
@@ -770,7 +380,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
{step === 'rule' ? renderRuleStep() : null}
{step === 'match' ? renderMatchStep() : null}
{step === 'applying' ? (
<Spin tip="正在同步..." style={{ display: 'block', margin: '48px auto' }} />
<Spin description="正在同步..." style={{ display: 'block', margin: '48px auto' }} />
) : null}
</Modal>
);

View File

@@ -0,0 +1,58 @@
export interface JinshujuEntryRow {
serialNumber: number;
name: string;
phone: string | null;
suggestedStudent: {
id: number;
name: string;
phone: string | null;
studentNo: string | null;
} | null;
}
export interface StudentOption {
id: number;
name: string;
phone: string | null;
studentNo: string | null;
}
export interface PreviewResponse {
success: boolean;
entries: JinshujuEntryRow[];
students: StudentOption[];
}
export interface MatchRule {
id: number;
name: string;
formToken: string;
mappings: Record<string, string>;
createdAt: string;
}
export interface JinshujuFormField {
key: string;
label: string;
type: string;
}
export type MatchDecision =
| { action: 'match'; matchStudentId: number }
| { action: 'create'; createName: string; createPhone: string }
| { action: 'skip' };
export const ROW_HEIGHT = 72;
export const LEFT_WIDTH = 260;
export const GAP = 80;
export const STUDENT_FIELDS = [
{ key: 'name', label: '姓名' },
{ key: 'phone', label: '手机号' },
{ key: 'idNumber', label: '身份证号' },
{ key: 'gender', label: '性别' },
{ key: 'ethnicity', label: '民族' },
{ key: 'emergencyContact', label: '紧急联系人' },
{ key: 'emergencyPhone', label: '紧急联系电话' },
{ key: 'studentNo', label: '学号' },
];

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { Button, Input, Select, Tag, Typography } from 'antd';
import { LinkOutlined, PlusOutlined } from '@ant-design/icons';
import type { JinshujuEntryRow, MatchDecision, StudentOption } from './JinshujuMatchModal.types';
const { Text } = Typography;
interface MatchSelectorProps {
entry: JinshujuEntryRow;
decision: MatchDecision | undefined;
studentOptions: StudentOption[];
onChange: (d: MatchDecision) => void;
}
const MatchSelector: React.FC<MatchSelectorProps> = ({
entry,
decision,
studentOptions,
onChange,
}) => {
const action = decision?.action ?? 'skip';
if (action === 'match') {
const matchD = decision as { action: 'match'; matchStudentId: number };
const matchedStudent = studentOptions.find((s) => s.id === matchD.matchStudentId);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Tag color="blue" icon={<LinkOutlined />}>
</Tag>
<Text style={{ flex: 1 }}>
{matchedStudent?.name ?? '未知'}
{matchedStudent?.studentNo && (
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>
({matchedStudent.studentNo})
</Text>
)}
</Text>
<Button size="small" type="link" danger onClick={() => onChange({ action: 'skip' })}>
</Button>
</div>
);
}
if (action === 'create') {
const createD = decision as { action: 'create'; createName: string; createPhone: string };
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Tag color="green" icon={<PlusOutlined />}>
</Tag>
<Input
size="small"
value={createD.createName}
placeholder="姓名"
style={{ width: 100 }}
onChange={(e) =>
onChange({
action: 'create',
createName: e.target.value,
createPhone: createD.createPhone,
})
}
/>
<Input
size="small"
value={createD.createPhone}
placeholder="手机号"
style={{ width: 120 }}
onChange={(e) =>
onChange({
action: 'create',
createName: createD.createName,
createPhone: e.target.value,
})
}
/>
<Button size="small" type="link" danger onClick={() => onChange({ action: 'skip' })}>
</Button>
</div>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Select
showSearch
size="small"
placeholder="搜索学生…"
style={{ flex: 1 }}
value={undefined}
filterOption={(input, option) =>
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())
}
options={studentOptions.map((s) => ({
value: s.id,
label: `${s.name}${s.phone ? ` (${s.phone})` : ''}${s.studentNo ? ` [${s.studentNo}]` : ''}`,
}))}
onChange={(studentId: number) => onChange({ action: 'match', matchStudentId: studentId })}
/>
<Button
size="small"
type="dashed"
icon={<PlusOutlined />}
onClick={() =>
onChange({
action: 'create',
createName: entry.name || '',
createPhone: entry.phone || '',
})
}
>
</Button>
<Button size="small" type="link" onClick={() => onChange({ action: 'skip' })}>
</Button>
</div>
);
};
export default MatchSelector;

View File

@@ -0,0 +1,156 @@
import React from 'react';
import { Button, Typography } from 'antd';
import MatchSelector from './MatchSelector';
import { GAP, LEFT_WIDTH, ROW_HEIGHT } from './JinshujuMatchModal.types';
import type { JinshujuEntryRow, MatchDecision, StudentOption } from './JinshujuMatchModal.types';
const { Text } = Typography;
interface MatchStepProps {
entries: JinshujuEntryRow[];
studentOptions: StudentOption[];
getDecision: (serial: number) => MatchDecision | undefined;
onDecisionChange: (serial: number, d: MatchDecision) => void;
onClear: () => void;
leftRef: React.RefObject<HTMLDivElement | null>;
rightRef: React.RefObject<HTMLDivElement | null>;
onScroll: (source: 'left' | 'right') => void;
total: number;
matched: number;
}
const MatchStep: React.FC<MatchStepProps> = ({
entries,
studentOptions,
getDecision,
onDecisionChange,
onClear,
leftRef,
rightRef,
onScroll,
total,
matched,
}) => {
const svgHeight = entries.length * ROW_HEIGHT;
const lines: React.ReactNode[] = [];
entries.forEach((entry, i) => {
const y = i * ROW_HEIGHT + ROW_HEIGHT / 2;
const d = getDecision(entry.serialNumber);
const isMatched = d?.action === 'match';
const color = isMatched ? '#1677ff' : '#d9d9d9';
lines.push(
<line
key={entry.serialNumber}
x1={LEFT_WIDTH}
y1={y}
x2={LEFT_WIDTH + GAP}
y2={y}
stroke={color}
strokeWidth={isMatched ? 2 : 1}
strokeDasharray={isMatched ? undefined : '4 4'}
opacity={isMatched ? 0.7 : 0.3}
/>,
);
});
return (
<div style={{ position: 'relative' }}>
<div
style={{
marginBottom: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Text type="secondary">
{total} {matched}
</Text>
<Button size="small" onClick={onClear}>
</Button>
</div>
<div style={{ display: 'flex', position: 'relative' }}>
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: LEFT_WIDTH + GAP,
height: svgHeight,
pointerEvents: 'none',
zIndex: 1,
}}
>
{lines}
</svg>
<div
ref={leftRef}
onScroll={() => onScroll('left')}
style={{ width: LEFT_WIDTH, maxHeight: 480, overflowY: 'auto', flexShrink: 0 }}
>
{entries.map((entry, i) => {
const d = getDecision(entry.serialNumber);
const isMatched = d?.action === 'match';
return (
<div
key={entry.serialNumber}
style={{
height: ROW_HEIGHT,
padding: '8px 12px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
background: isMatched ? '#f6ffed' : i % 2 === 0 ? '#fafafa' : '#fff',
borderLeft: isMatched ? '3px solid #1677ff' : '3px solid transparent',
}}
>
<Text strong style={{ fontSize: 13 }}>
{entry.name || <Text type="secondary"></Text>}
</Text>
{entry.phone && (
<Text type="secondary" style={{ fontSize: 12 }}>
{entry.phone}
</Text>
)}
<Text type="secondary" style={{ fontSize: 11 }}>
#{entry.serialNumber}
</Text>
</div>
);
})}
</div>
<div style={{ width: GAP, flexShrink: 0 }} />
<div
ref={rightRef}
onScroll={() => onScroll('right')}
style={{ flex: 1, maxHeight: 480, overflowY: 'auto' }}
>
{entries.map((entry) => (
<div
key={entry.serialNumber}
style={{
height: ROW_HEIGHT,
padding: '8px 12px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<MatchSelector
entry={entry}
decision={getDecision(entry.serialNumber)}
studentOptions={studentOptions}
onChange={(newD) => onDecisionChange(entry.serialNumber, newD)}
/>
</div>
))}
</div>
</div>
</div>
);
};
export default MatchStep;

View File

@@ -0,0 +1,123 @@
import React, { useState } from 'react';
import { Button, Input, Popconfirm, Select, Typography } from 'antd';
import { DeleteOutlined, SaveOutlined } from '@ant-design/icons';
import api from '../api';
import { message } from '../ui/app-message';
import { useApiMutation } from '../hooks/useApiMutation';
import { STUDENT_FIELDS } from './JinshujuMatchModal.types';
import type { JinshujuFormField, MatchRule } from './JinshujuMatchModal.types';
const { Text } = Typography;
interface RuleEditorProps {
rule: MatchRule | null;
formToken: string;
fields: JinshujuFormField[];
onSave: () => void;
onDelete: (id: number) => void;
onCancel: () => void;
}
const RuleEditor: React.FC<RuleEditorProps> = ({
rule,
formToken,
fields,
onSave,
onDelete,
onCancel,
}) => {
const [name, setName] = useState(rule?.name ?? '');
const [mappings, setMappings] = useState<Record<string, string>>(
rule?.mappings ?? { name: 'field_1', phone: 'field_2' },
);
const [saving, setSaving] = useState(false);
const saveMutation = useApiMutation(
async (payload: { name: string; mappings: Record<string, string> }) =>
rule
? api.put(`/sync/jinshuju/rules/${rule.id}`, payload)
: api.post('/sync/jinshuju/rules', { ...payload, formToken }),
{
invalidate: [['sync', 'jinshuju', 'rules']],
onSuccess: () => {
message.success('规则已保存');
onSave();
},
},
);
const handleSave = async () => {
if (!name.trim()) {
message.warning('请输入规则名称');
return;
}
setSaving(true);
try {
await saveMutation.mutateAsync({ name, mappings });
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
return (
<div style={{ padding: '12px 0' }}>
<Input
placeholder="规则名称"
value={name}
onChange={(e) => setName(e.target.value)}
style={{ marginBottom: 12 }}
/>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
</Text>
{STUDENT_FIELDS.map((sf) => (
<div
key={sf.key}
style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}
>
<Text style={{ width: 100, textAlign: 'right', fontSize: 13 }}>{sf.label}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="选择金数据字段"
value={mappings[sf.key]}
style={{ flex: 1 }}
options={fields.map((field) => ({
value: field.key,
label: `${field.label}${field.key}`,
}))}
onChange={(value) =>
setMappings((prev) => {
const next = { ...prev };
if (value) next[sf.key] = value;
else delete next[sf.key];
return next;
})
}
/>
</div>
))}
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
</Button>
{rule && (
<Popconfirm title="确定删除此规则?" onConfirm={() => onDelete(rule.id)}>
<Button danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
<Button onClick={onCancel}></Button>
</div>
</div>
);
};
export default RuleEditor;

View File

@@ -27,13 +27,21 @@ const statusLabels: Record<string, string> = {
cancelled: '已取消',
};
const escapeHtml = (value: unknown) =>
String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
const HTML_ESCAPE_PAIRS: ReadonlyArray<readonly [string, string]> = [
['&', '&amp;'],
['<', '&lt;'],
['>', '&gt;'],
['"', '&quot;'],
["'", '&#039;'],
];
const escapeHtml = (value: unknown) => {
let text = String(value ?? '');
for (const [from, to] of HTML_ESCAPE_PAIRS) {
text = text.replaceAll(from, to);
}
return text;
};
const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo } from 'react';
import {
App,
Table,
Button,
Modal,
Form,
DatePicker,
@@ -26,6 +28,11 @@ import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message';
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
import { newOperationId } from '../../utils/operation-id';
import { usePermission } from '../../hooks/usePermission';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { billsSchema } from '../../api/schemas';
const statusMap: Record<string, { text: string; color: string }> = {
unpaid: { text: '待支付', color: 'orange' },
@@ -44,8 +51,9 @@ const typeMap: Record<string, string> = {
};
const BillsPage: React.FC = () => {
const [bills, setBills] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const { modal } = App.useApp();
const { hasPermission } = usePermission();
const canPurgeBill = hasPermission('bill:purge');
const [generateModal, setGenerateModal] = useState(false);
const [detailModal, setDetailModal] = useState<any>(null);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
@@ -57,23 +65,48 @@ const BillsPage: React.FC = () => {
const [detailLoading, setDetailLoading] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterExpenseType) params.expenseType = filterExpenseType;
const res = (await api.get('/bills', { params })) as unknown[];
setBills(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [filterStatus, filterExpenseType]);
const {
data: bills = [],
isLoading,
isFetching,
} = useQuery({
queryKey: ['bills', filterStatus, filterExpenseType],
queryFn: async () => {
try {
const params: Record<string, string | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterExpenseType) params.expenseType = filterExpenseType;
return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params }));
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
},
});
const loading = isLoading || isFetching;
useEffect(() => {
fetchData();
}, [fetchData]);
const generateMutation = useApiMutation(
async (payload: { operationId: string; billingMonth: string }) =>
api.post('/bills/generate', payload),
{ invalidate: [['bills']] },
);
const cancelMutation = useApiMutation(
async ({ id, reason }: { id: number; reason: string }) =>
api.post(`/bills/${id}/cancel`, { operationId: newOperationId(), reason }),
{ invalidate: [['bills']] },
);
const archiveMutation = useApiMutation(
async (id: number) => api.delete(`/bills/${id}`),
{ invalidate: [['bills']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/bills/${id}/permanent`),
{ invalidate: [['bills']] },
);
const batchArchiveMutation = useApiMutation(
async (ids: number[]) => api.post('/bills/batch/delete', { ids }),
{ invalidate: [['bills']] },
);
const filteredBills = useMemo(() => {
return bills.filter((b: any) => {
@@ -92,16 +125,15 @@ const BillsPage: React.FC = () => {
setSaving(true);
const values = await generateForm.validateFields();
try {
const res: any = await api.post('/bills/generate', {
const res: any = await generateMutation.mutateAsync({
operationId: newOperationId(),
billingMonth: values.billingMonth.format('YYYY-MM'),
});
message.success(res.message || '生成成功');
setGenerateModal(false);
generateForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '生成失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -121,7 +153,7 @@ const BillsPage: React.FC = () => {
const handleCancel = async (id: number) => {
let reason = '';
Modal.confirm({
modal.confirm({
title: '取消账单并退回已扣余额',
content: (
<Input.TextArea
@@ -139,37 +171,49 @@ const BillsPage: React.FC = () => {
message.error('请输入取消原因');
throw new Error('reason required');
}
await api.post(`/bills/${id}/cancel`, {
operationId: newOperationId(),
reason: reason.trim(),
});
await cancelMutation.mutateAsync({ id, reason: reason.trim() });
message.success('账单已取消,已扣余额已冲正退回');
fetchData();
},
});
};
const handleArchive = async (id: number) => {
try {
await api.delete(`/bills/${id}`);
await archiveMutation.mutateAsync(id);
message.success('账单已归档');
fetchData();
} catch (error: any) {
message.error(error?.message || '归档失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (id: number, studentName: string, period: string) => {
modal.confirm({
title: `永久删除账单(${studentName} ${period}`,
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const batchArchive = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
if (batchLoading) return;
setBatchLoading(true);
try {
await api.post('/bills/batch/delete', { ids: selectedRows });
await batchArchiveMutation.mutateAsync(selectedRows);
message.success(`已归档 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
@@ -216,28 +260,28 @@ const BillsPage: React.FC = () => {
dataIndex: 'sharedAmount',
width: 120,
align: 'right' as const,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
render: (v: number) => `¥${v.toFixed(2)}`,
},
{
title: '个人费用',
dataIndex: 'personalAmount',
width: 120,
align: 'right' as const,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
render: (v: number) => `¥${v.toFixed(2)}`,
},
{
title: '总计',
dataIndex: 'totalAmount',
width: 100,
align: 'right' as const,
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
render: (v: number) => <strong>¥{v.toFixed(2)}</strong>,
},
{
title: '已扣余额',
dataIndex: 'paidAmount',
width: 110,
render: (value: number) => (
<span style={{ color: '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</span>
<span style={{ color: '#389e0d' }}>¥{(value ?? 0).toFixed(2)}</span>
),
},
{
@@ -245,8 +289,8 @@ const BillsPage: React.FC = () => {
dataIndex: 'outstandingAmount',
width: 110,
render: (value: number) => (
<strong style={{ color: Number(value) > 0 ? '#cf1322' : '#389e0d' }}>
¥{Number(value || 0).toFixed(2)}
<strong style={{ color: value > 0 ? '#cf1322' : '#389e0d' }}>
¥{(value ?? 0).toFixed(2)}
</strong>
),
},
@@ -289,6 +333,22 @@ const BillsPage: React.FC = () => {
>
PDF
</PermissionButton>
{record.status === 'cancelled' && canPurgeBill ? (
<Button
size="small"
danger
type="link"
onClick={() =>
handlePurge(
record.id,
record.student?.name || '-',
`${record.periodStart}~${record.periodEnd}`,
)
}
>
</Button>
) : null}
{record.status !== 'cancelled' && (
<PermissionButton
permission="bill:delete"
@@ -320,7 +380,7 @@ const BillsPage: React.FC = () => {
),
},
],
[showDetail, handleArchive, handleCancel, handleExportPdf],
[showDetail, handleArchive, handleCancel, handleExportPdf, canPurgeBill, handlePurge],
);
return (
@@ -511,12 +571,12 @@ const BillsPage: React.FC = () => {
{
title: '宿舍总费用',
dataIndex: 'roomTotalAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
render: (v: number) => `¥${v.toFixed(2)}`,
},
{
title: '应分摊',
dataIndex: 'studentAmount',
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
render: (v: number) => <strong>¥{v.toFixed(2)}</strong>,
},
]}
/>

View File

@@ -0,0 +1,557 @@
import React from 'react';
import {
Button,
Card,
Col,
DatePicker,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Statistic,
Table,
Tag,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { DownloadOutlined, PlusOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useUserStore } from '../../store/user/userStore';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
export interface ClassStudent {
id: number;
studentId: number;
studentName: string;
studentNo: string;
joinDate: string;
leaveDate: string | null;
status: string;
}
export interface ClassTeacher {
id: number;
userId: number;
username: string;
roleType: string;
subject: string | null;
}
export interface ClassScheduleItem {
id: number;
classId: number;
classroomId: number;
classroomName: string;
weekDay: number;
startTime: string;
endTime: string;
attendanceAdvanceMinutes: number;
startDate: string;
endDate: string;
subject: string;
teacherId: number | null;
scheduleType: string;
status: string;
notes: string | null;
}
export interface AttendanceSummary {
total: number;
present: number;
late: number;
absent: number;
leave: number;
presentRate: number;
absentRate: number;
lateRate: number;
leaveRate: number;
}
export interface ClassDetail {
id: number;
name: string;
code: string;
classType: string;
startDate: string | null;
endDate: string | null;
status: string;
maxStudents: number;
notes: string | null;
studentCount: number;
students?: ClassStudent[];
teachers?: ClassTeacher[];
createdAt: string;
updatedAt: string;
}
export interface StudentItem {
id: number;
name: string;
studentNo?: string;
}
export const STATUS_MAP: Record<string, { color: string; text: string }> = {
enrolling: { color: 'blue', text: '招生中' },
active: { color: 'green', text: '在读' },
ended: { color: 'default', text: '结课' },
suspended: { color: 'orange', text: '停课' },
};
export const TYPE_MAP: Record<string, string> = {
culture: '文化课',
professional: '专业课',
bootcamp: '集训营',
sprint: '冲刺营',
};
export const ROLE_MAP: Record<string, string> = {
subject_teacher: '任课老师',
head_teacher: '班主任',
life_teacher: '生活老师',
academic_teacher: '学服老师',
};
export const WEEK_DAY_MAP: Record<number, string> = {
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
7: '周日',
};
export const SCHEDULE_TYPE_MAP: Record<string, string> = {
INTERNAL: '内部排课',
RENTAL: '租赁',
};
export const ClassInfoTab: React.FC<{
detail: ClassDetail;
teachers: ClassTeacher[];
editingInfo: boolean;
editForm: ReturnType<typeof Form.useForm>[0];
onSave: () => void;
onEdit: () => void;
onCancel: () => void;
getTeacherName: (teacher: ClassTeacher) => string;
}> = ({ detail, teachers, editingInfo, editForm, onSave, onEdit, onCancel, getTeacherName }) => {
return (
<div>
{editingInfo ? (
<Form
form={editForm}
layout="vertical"
initialValues={{
name: detail.name,
code: detail.code,
classType: detail.classType,
startDate: detail.startDate ? dayjs(detail.startDate) : undefined,
endDate: detail.endDate ? dayjs(detail.endDate) : undefined,
maxStudents: detail.maxStudents,
status: detail.status,
notes: detail.notes,
}}
>
<Space wrap>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码">
<Input />
</Form.Item>
<Form.Item name="classType" label="班型">
<Select
options={Object.entries(TYPE_MAP).map(([k, v]) => ({
value: k,
label: v,
}))}
/>
</Form.Item>
<Form.Item name="startDate" label="开班">
<DatePicker />
</Form.Item>
<Form.Item name="endDate" label="结课">
<DatePicker />
</Form.Item>
<Form.Item name="maxStudents" label="人数上限">
<InputNumber min={1} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={Object.entries(STATUS_MAP).map(([k, v]) => ({
value: k,
label: v.text,
}))}
/>
</Form.Item>
</Space>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={3} />
</Form.Item>
<Space>
<PermissionButton permission="class:edit" type="primary" onClick={onSave}>
</PermissionButton>
<Button onClick={onCancel}></Button>
</Space>
</Form>
) : (
<div>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item>
<Descriptions.Item label="开班日期">
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
</Descriptions.Item>
<Descriptions.Item label="结课日期">
{detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'}
</Descriptions.Item>
<Descriptions.Item label="学员">
{detail.studentCount}/{detail.maxStudents || '-'}
</Descriptions.Item>
<Descriptions.Item label="班主任">
{(() => {
const headTeacher = teachers.find(
(teacher) => teacher.roleType === 'head_teacher',
);
return headTeacher ? getTeacherName(headTeacher) : '-';
})()}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.notes || '-'}</Descriptions.Item>
</Descriptions>
<PermissionButton permission="class:edit" style={{ marginTop: 16 }} onClick={onEdit}>
</PermissionButton>
</div>
)}
</div>
);
};
export const ClassStudentsTab: React.FC<{
id?: string;
detail?: ClassDetail | null;
students: ClassStudent[];
allStudents: StudentItem[];
selectedStudentIds: number[];
modalOpen: boolean;
onOpen: () => void;
onAdd: () => void;
onClose: () => void;
onRemove: (studentId: number) => void;
onSelect: (ids: number[]) => void;
}> = ({
id,
detail,
students,
allStudents,
selectedStudentIds,
modalOpen,
onOpen,
onAdd,
onClose,
onRemove,
onSelect,
}) => {
const studentColumns: ColumnsType<ClassStudent> = [
{ title: '姓名', dataIndex: 'studentName' },
{ title: '学号', dataIndex: 'studentNo' },
{ title: '加入日期', dataIndex: 'joinDate' },
{ title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' },
{
title: '状态',
dataIndex: 'status',
render: (v: string) => (
<Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '在读' : '已离班'}</Tag>
),
},
{
title: '操作',
render: (_: unknown, r: ClassStudent) =>
r.status === 'active' ? (
<Popconfirm title="确认移除?" onConfirm={() => onRemove(r.studentId)}>
<PermissionButton permission="class:edit" size="small" danger>
</PermissionButton>
</Popconfirm>
) : null,
},
];
return (
<div>
<PermissionButton
permission="class:edit"
icon={<PlusOutlined />}
type="primary"
onClick={onOpen}
style={{ marginBottom: 16, marginRight: 8 }}
>
</PermissionButton>
<PermissionButton
permission="class:view"
icon={<DownloadOutlined />}
onClick={() => {
const token = useUserStore.getState().token;
fetch(`/api/classes/${id}/roster/export`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => {
if (!res.ok) throw new Error('导出失败');
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `班级花名册-${detail?.name || id}.xlsx`;
a.click();
URL.revokeObjectURL(url);
message.success('花名册导出成功');
})
.catch(() => message.error('花名册导出失败'));
}}
>
</PermissionButton>
<Table<ClassStudent>
columns={studentColumns}
dataSource={students}
rowKey="id"
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
<Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose}>
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="选择学员"
value={selectedStudentIds}
onChange={onSelect}
options={allStudents.map((s) => ({
value: s.id,
label: `${s.name} (${s.studentNo || s.id})`,
}))}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
/>
</Modal>
</div>
);
};
export const ClassTeachersTab: React.FC<{
teachers: ClassTeacher[];
allUsers: TeacherCandidateUser[];
teacherRole: string;
teacherSubject: string;
teacherUserId?: number;
modalOpen: boolean;
onOpen: () => void;
onAdd: () => void;
onClose: () => void;
onRemove: (userId: number) => void;
onRoleChange: (role: string) => void;
onSubjectChange: (subject: string) => void;
onUserChange: (userId?: number) => void;
getTeacherName: (teacher: ClassTeacher) => string;
}> = ({
teachers,
allUsers,
teacherRole,
teacherSubject,
teacherUserId,
modalOpen,
onOpen,
onAdd,
onClose,
onRemove,
onRoleChange,
onSubjectChange,
onUserChange,
getTeacherName,
}) => {
const teacherColumns: ColumnsType<ClassTeacher> = [
{ title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
{
title: '角色',
dataIndex: 'roleType',
render: (v: string) => <Tag>{ROLE_MAP[v] || v}</Tag>,
},
{
title: '科目',
dataIndex: 'subject',
render: (v: string | null) => v || '-',
},
{
title: '操作',
render: (_: unknown, r: ClassTeacher) => (
<Popconfirm title="确认移除?" onConfirm={() => onRemove(r.userId)}>
<PermissionButton permission="class:edit" size="small" danger>
</PermissionButton>
</Popconfirm>
),
},
];
return (
<div>
<PermissionButton
permission="class:edit"
icon={<PlusOutlined />}
type="primary"
onClick={onOpen}
style={{ marginBottom: 16 }}
>
</PermissionButton>
<Table<ClassTeacher>
columns={teacherColumns}
dataSource={teachers}
rowKey="id"
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
<Modal title="添加教师" open={modalOpen} onOk={onAdd} onCancel={onClose}>
<Space orientation="vertical" style={{ width: '100%' }}>
<Select
style={{ width: '100%' }}
showSearch
optionFilterProp="label"
placeholder="搜索姓名、用户名、角色或学科"
value={teacherUserId}
onChange={onUserChange}
options={buildTeacherCandidateOptions(allUsers)}
notFoundContent="没有可分配的工作人员账号"
/>
<Select
style={{ width: '100%' }}
value={teacherRole}
onChange={onRoleChange}
options={Object.entries(ROLE_MAP).map(([k, v]) => ({
value: k,
label: v,
}))}
/>
{teacherRole === 'subject_teacher' && (
<Input
placeholder="任教科目"
value={teacherSubject}
onChange={(e) => onSubjectChange(e.target.value)}
/>
)}
</Space>
</Modal>
</div>
);
};
export const ClassScheduleTab: React.FC<{
schedules: ClassScheduleItem[];
scheduleDateRange: [dayjs.Dayjs | null, dayjs.Dayjs | null];
onRangeChange: (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null]) => void;
}> = ({ schedules, scheduleDateRange, onRangeChange }) => {
const scheduleColumns: ColumnsType<ClassScheduleItem> = [
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
{
title: '时间',
render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}`,
},
{
title: '签到窗口',
render: (_: unknown, r: ClassScheduleItem) =>
`课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课`,
},
{
title: '日期范围',
render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}`,
},
{ title: '科目', dataIndex: 'subject' },
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
{
title: '状态',
dataIndex: 'status',
render: (v: string) => (
<Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '启用' : v}</Tag>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, maxWidth: '100%' }} wrap>
<DatePicker.RangePicker
value={scheduleDateRange}
onChange={(dates) => onRangeChange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}
placeholder={['开始日期', '结束日期']}
/>
</Space>
<Table<ClassScheduleItem>
columns={scheduleColumns}
dataSource={schedules}
rowKey="id"
scroll={{ x: 'max-content' }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
</div>
);
};
export const ClassAttendanceTab: React.FC<{
attendanceSummary: AttendanceSummary | null;
attendanceDateRange: [dayjs.Dayjs | null, dayjs.Dayjs | null];
onRangeChange: (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null]) => void;
}> = ({ attendanceSummary, attendanceDateRange, onRangeChange }) => {
return (
<div>
<Space style={{ marginBottom: 16, maxWidth: '100%' }} wrap>
<DatePicker.RangePicker
value={attendanceDateRange}
onChange={(dates) => onRangeChange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}
placeholder={['开始日期', '结束日期']}
/>
</Space>
{attendanceSummary && (
<Row gutter={[16, 16]}>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="总记录" value={attendanceSummary.total} />
</Card>
</Col>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="出勤率" value={attendanceSummary.presentRate} suffix="%" />
</Card>
</Col>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
</Card>
</Col>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
</Card>
</Col>
</Row>
)}
</div>
);
};

View File

@@ -1,155 +1,30 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useUserStore } from '../../store/user/userStore';
import {
Card,
Tabs,
Descriptions,
Table,
Button,
Space,
Select,
Modal,
Tag,
Popconfirm,
Form,
Input,
DatePicker,
InputNumber,
Row,
Col,
Statistic,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
import React, { useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router';
import { Button, Card, Form, Space, Tabs, Tag } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
// ---- Types ----
interface ClassStudent {
id: number;
studentId: number;
studentName: string;
studentNo: string;
joinDate: string;
leaveDate: string | null;
status: string;
}
interface ClassTeacher {
id: number;
userId: number;
username: string;
roleType: string;
subject: string | null;
}
interface ClassScheduleItem {
id: number;
classId: number;
classroomId: number;
classroomName: string;
weekDay: number;
startTime: string;
endTime: string;
attendanceAdvanceMinutes: number;
startDate: string;
endDate: string;
subject: string;
teacherId: number | null;
scheduleType: string;
status: string;
notes: string | null;
}
interface AttendanceSummary {
total: number;
present: number;
late: number;
absent: number;
leave: number;
presentRate: number;
absentRate: number;
lateRate: number;
leaveRate: number;
}
interface ClassDetail {
id: number;
name: string;
code: string;
classType: string;
startDate: string | null;
endDate: string | null;
status: string;
maxStudents: number;
notes: string | null;
studentCount: number;
students?: ClassStudent[];
teachers?: ClassTeacher[];
createdAt: string;
updatedAt: string;
}
interface StudentItem {
id: number;
name: string;
studentNo?: string;
}
type UserItem = TeacherCandidateUser;
// ---- Constants ----
const STATUS_MAP: Record<string, { color: string; text: string }> = {
enrolling: { color: 'blue', text: '招生中' },
active: { color: 'green', text: '在读' },
ended: { color: 'default', text: '结课' },
suspended: { color: 'orange', text: '停课' },
};
const TYPE_MAP: Record<string, string> = {
culture: '文化课',
professional: '专业课',
bootcamp: '集训营',
sprint: '冲刺营',
};
const ROLE_MAP: Record<string, string> = {
subject_teacher: '任课老师',
head_teacher: '班主任',
life_teacher: '生活老师',
academic_teacher: '学服老师',
};
const WEEK_DAY_MAP: Record<number, string> = {
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
7: '周日',
};
const SCHEDULE_TYPE_MAP: Record<string, string> = {
INTERNAL: '内部排课',
RENTAL: '租赁',
};
// ---- Component ----
import { useQuery } from '@tanstack/react-query';
import type { TeacherCandidateUser } from './teacher-candidate';
import { getErrorMessage } from '../../utils/error';
import {
ClassAttendanceTab,
ClassInfoTab,
ClassScheduleTab,
ClassStudentsTab,
ClassTeachersTab,
STATUS_MAP,
type ClassDetail,
type ClassTeacher,
type StudentItem,
type AttendanceSummary,
type ClassScheduleItem,
} from './ClassDetailTabs';
const ClassDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [detail, setDetail] = useState<ClassDetail | null>(null);
const [students, setStudents] = useState<ClassStudent[]>([]);
const [teachers, setTeachers] = useState<ClassTeacher[]>([]);
const [loading, setLoading] = useState(false);
const [editForm] = Form.useForm();
const [editingInfo, setEditingInfo] = useState(false);
@@ -160,85 +35,93 @@ const ClassDetailPage: React.FC = () => {
// Teacher modal state
const [teacherModalOpen, setTeacherModalOpen] = useState(false);
const [allUsers, setAllUsers] = useState<UserItem[]>([]);
const [teacherRole, setTeacherRole] = useState('subject_teacher');
const [teacherSubject, setTeacherSubject] = useState('');
const [teacherUserId, setTeacherUserId] = useState<number>();
// Schedule & attendance state
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
const [scheduleDateRange, setScheduleDateRange] = useState<
[dayjs.Dayjs | null, dayjs.Dayjs | null]
>([null, null]);
const [attendanceSummary, setAttendanceSummary] = useState<AttendanceSummary | null>(null);
const [attendanceDateRange, setAttendanceDateRange] = useState<
[dayjs.Dayjs | null, dayjs.Dayjs | null]
>([null, null]);
const fetchDetail = useCallback(async () => {
setLoading(true);
try {
const res = (await api.get(`/classes/${id}`)) as ClassDetail;
setDetail(res);
setStudents(res.students || []);
setTeachers(res.teachers || []);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载失败');
} finally {
setLoading(false);
}
}, [id]);
const {
data: detailResult = { detail: null, students: [], teachers: [] },
isLoading: detailLoading,
isFetching: detailFetching,
refetch: refetchDetail,
} = useQuery<{
detail: ClassDetail | null;
students: ClassDetail['students'];
teachers: ClassDetail['teachers'];
}>({
queryKey: ['classes', 'detail', id],
queryFn: async () => {
try {
const res = (await api.get(`/classes/${id}`)) as ClassDetail;
return { detail: res, students: res.students || [], teachers: res.teachers || [] };
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败'));
return { detail: null, students: [], teachers: [] };
}
},
});
const detail = detailResult.detail;
const students = detailResult.students ?? [];
const teachers = detailResult.teachers ?? [];
const loading = detailLoading || detailFetching;
const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]);
const fetchUsers = useCallback(async () => {
try {
const res = (await api.get('/rbac/users')) as UserItem[];
setAllUsers(res || []);
} catch {
setAllUsers([]);
}
}, []);
const { data: allUsers = [], refetch: refetchUsers } = useQuery<TeacherCandidateUser[]>({
queryKey: ['rbac', 'users', 'all'],
queryFn: async () => {
try {
return (await api.get('/rbac/users')) as TeacherCandidateUser[];
} catch {
return [];
}
},
});
const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]);
useEffect(() => {
fetchDetail();
fetchUsers();
}, [fetchDetail, fetchUsers]);
const { data: schedules = [] } = useQuery<ClassScheduleItem[]>({
queryKey: ['classes', 'schedule', id, scheduleDateRange],
queryFn: async () => {
if (!id) return [];
try {
const params: Record<string, string> = {};
if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');
if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');
return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载课表失败'));
return [];
}
},
});
const fetchSchedules = useCallback(async () => {
if (!id) return;
try {
const params: Record<string, string> = {};
if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');
if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');
const res = await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params });
setSchedules(res || []);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载课表失败');
}
}, [id, scheduleDateRange]);
useEffect(() => {
fetchSchedules();
}, [fetchSchedules]);
const fetchAttendanceSummary = useCallback(async () => {
if (!id) return;
try {
const params: Record<string, string> = {};
if (attendanceDateRange?.[0]) params.startDate = attendanceDateRange[0].format('YYYY-MM-DD');
if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD');
const res = await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, { params });
setAttendanceSummary(res || null);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载出勤汇总失败');
}
}, [id, attendanceDateRange]);
useEffect(() => {
fetchAttendanceSummary();
}, [fetchAttendanceSummary]);
const { data: attendanceSummary = null } = useQuery<AttendanceSummary | null>({
queryKey: ['classes', 'attendance-summary', id, attendanceDateRange],
queryFn: async () => {
if (!id) return null;
try {
const params: Record<string, string> = {};
if (attendanceDateRange?.[0])
params.startDate = attendanceDateRange[0].format('YYYY-MM-DD');
if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD');
return (
(await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, {
params,
})) || null
);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载出勤汇总失败'));
return null;
}
},
});
const handleSaveInfo = async () => {
try {
@@ -257,8 +140,7 @@ const ClassDetailPage: React.FC = () => {
fetchDetail();
message.success('已更新');
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '更新失败');
message.error(getErrorMessage(e, '更新失败'));
}
};
@@ -268,8 +150,7 @@ const ClassDetailPage: React.FC = () => {
fetchDetail();
message.success('已移除');
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '移除失败');
message.error(getErrorMessage(e, '移除失败'));
}
};
@@ -282,8 +163,7 @@ const ClassDetailPage: React.FC = () => {
fetchDetail();
message.success('已添加');
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '添加失败');
message.error(getErrorMessage(e, '添加失败'));
}
};
@@ -299,8 +179,7 @@ const ClassDetailPage: React.FC = () => {
fetchDetail();
message.success('已添加');
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '添加失败');
message.error(getErrorMessage(e, '添加失败'));
}
};
@@ -310,8 +189,7 @@ const ClassDetailPage: React.FC = () => {
fetchDetail();
message.success('已移除');
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '移除失败');
message.error(getErrorMessage(e, '移除失败'));
}
};
@@ -324,8 +202,7 @@ const ClassDetailPage: React.FC = () => {
setSelectedStudentIds([]);
setStudentModalOpen(true);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载学员列表失败');
message.error(getErrorMessage(e, '加载学员列表失败'));
}
};
@@ -337,8 +214,7 @@ const ClassDetailPage: React.FC = () => {
setTeacherSubject('');
setTeacherModalOpen(true);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载用户列表失败');
message.error(getErrorMessage(e, '加载用户列表失败'));
}
};
@@ -347,82 +223,6 @@ const ClassDetailPage: React.FC = () => {
if (!detail) return null;
const studentColumns: ColumnsType<ClassStudent> = [
{ title: '姓名', dataIndex: 'studentName' },
{ title: '学号', dataIndex: 'studentNo' },
{ title: '加入日期', dataIndex: 'joinDate' },
{ title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' },
{
title: '状态',
dataIndex: 'status',
render: (v: string) => (
<Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '在读' : '已离班'}</Tag>
),
},
{
title: '操作',
render: (_: unknown, r: ClassStudent) =>
r.status === 'active' ? (
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
<PermissionButton permission="class:edit" size="small" danger>
</PermissionButton>
</Popconfirm>
) : null,
},
];
const teacherColumns: ColumnsType<ClassTeacher> = [
{ title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
{
title: '角色',
dataIndex: 'roleType',
render: (v: string) => <Tag>{ROLE_MAP[v] || v}</Tag>,
},
{
title: '科目',
dataIndex: 'subject',
render: (v: string | null) => v || '-',
},
{
title: '操作',
render: (_: unknown, r: ClassTeacher) => (
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveTeacher(r.userId)}>
<PermissionButton permission="class:edit" size="small" danger>
</PermissionButton>
</Popconfirm>
),
},
];
const scheduleColumns: ColumnsType<ClassScheduleItem> = [
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
{
title: '时间',
render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}`,
},
{
title: '签到窗口',
render: (_: unknown, r: ClassScheduleItem) =>
`课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课`,
},
{
title: '日期范围',
render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}`,
},
{ title: '科目', dataIndex: 'subject' },
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
{
title: '状态',
dataIndex: 'status',
render: (v: string) => (
<Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '启用' : v}</Tag>
),
},
];
return (
<Card
title={
@@ -443,325 +243,91 @@ const ClassDetailPage: React.FC = () => {
key: 'info',
label: '基本信息',
children: (
<div>
{editingInfo ? (
<Form
form={editForm}
layout="vertical"
initialValues={{
name: detail.name,
code: detail.code,
classType: detail.classType,
startDate: detail.startDate ? dayjs(detail.startDate) : undefined,
endDate: detail.endDate ? dayjs(detail.endDate) : undefined,
maxStudents: detail.maxStudents,
status: detail.status,
notes: detail.notes,
}}
>
<Space wrap>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码">
<Input />
</Form.Item>
<Form.Item name="classType" label="班型">
<Select
options={Object.entries(TYPE_MAP).map(([k, v]) => ({
value: k,
label: v,
}))}
/>
</Form.Item>
<Form.Item name="startDate" label="开班">
<DatePicker />
</Form.Item>
<Form.Item name="endDate" label="结课">
<DatePicker />
</Form.Item>
<Form.Item name="maxStudents" label="人数上限">
<InputNumber min={1} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={Object.entries(STATUS_MAP).map(([k, v]) => ({
value: k,
label: v.text,
}))}
/>
</Form.Item>
</Space>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={3} />
</Form.Item>
<Space>
<PermissionButton
permission="class:edit"
type="primary"
onClick={handleSaveInfo}
>
</PermissionButton>
<Button onClick={() => setEditingInfo(false)}></Button>
</Space>
</Form>
) : (
<div>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="班型">
{TYPE_MAP[detail.classType]}
</Descriptions.Item>
<Descriptions.Item label="开班日期">
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
</Descriptions.Item>
<Descriptions.Item label="结课日期">
{detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'}
</Descriptions.Item>
<Descriptions.Item label="学员">
{detail.studentCount}/{detail.maxStudents || '-'}
</Descriptions.Item>
<Descriptions.Item label="班主任">
{(() => {
const headTeacher = teachers.find(
(teacher) => teacher.roleType === 'head_teacher',
);
return headTeacher ? getTeacherName(headTeacher) : '-';
})()}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.notes || '-'}</Descriptions.Item>
</Descriptions>
<PermissionButton
permission="class:edit"
style={{ marginTop: 16 }}
onClick={() => {
editForm.setFieldsValue({
name: detail.name,
code: detail.code,
classType: detail.classType,
startDate: detail.startDate ? dayjs(detail.startDate) : undefined,
endDate: detail.endDate ? dayjs(detail.endDate) : undefined,
maxStudents: detail.maxStudents,
status: detail.status,
notes: detail.notes,
});
setEditingInfo(true);
}}
>
</PermissionButton>
</div>
)}
</div>
<ClassInfoTab
detail={detail}
teachers={teachers}
editingInfo={editingInfo}
editForm={editForm}
onSave={handleSaveInfo}
onEdit={() => {
editForm.setFieldsValue({
name: detail.name,
code: detail.code,
classType: detail.classType,
startDate: detail.startDate ? dayjs(detail.startDate) : undefined,
endDate: detail.endDate ? dayjs(detail.endDate) : undefined,
maxStudents: detail.maxStudents,
status: detail.status,
notes: detail.notes,
});
setEditingInfo(true);
}}
onCancel={() => setEditingInfo(false)}
getTeacherName={getTeacherName}
/>
),
},
{
key: 'students',
label: `花名册 (${students.filter((s) => s.status === 'active').length})`,
children: (
<div>
<PermissionButton
permission="class:edit"
icon={<PlusOutlined />}
type="primary"
onClick={openStudentModal}
style={{ marginBottom: 16, marginRight: 8 }}
>
</PermissionButton>
<PermissionButton
permission="class:view"
icon={<DownloadOutlined />}
onClick={() => {
const token = useUserStore.getState().token;
fetch(`/api/classes/${id}/roster/export`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => {
if (!res.ok) throw new Error('导出失败');
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `班级花名册-${detail?.name || id}.xlsx`;
a.click();
URL.revokeObjectURL(url);
message.success('花名册导出成功');
})
.catch(() => message.error('花名册导出失败'));
}}
>
</PermissionButton>
<Table<ClassStudent>
columns={studentColumns}
dataSource={students}
rowKey="id"
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
<Modal
title="添加学员"
open={studentModalOpen}
onOk={handleAddStudents}
onCancel={() => setStudentModalOpen(false)}
>
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="选择学员"
value={selectedStudentIds}
onChange={setSelectedStudentIds}
options={allStudents.map((s) => ({
value: s.id,
label: `${s.name} (${s.studentNo || s.id})`,
}))}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
/>
</Modal>
</div>
<ClassStudentsTab
id={id}
detail={detail}
students={students}
allStudents={allStudents}
selectedStudentIds={selectedStudentIds}
modalOpen={studentModalOpen}
onOpen={openStudentModal}
onAdd={handleAddStudents}
onClose={() => setStudentModalOpen(false)}
onRemove={handleRemoveStudent}
onSelect={setSelectedStudentIds}
/>
),
},
{
key: 'teachers',
label: `教师 (${teachers.length})`,
children: (
<div>
<PermissionButton
permission="class:edit"
icon={<PlusOutlined />}
type="primary"
onClick={openTeacherModal}
style={{ marginBottom: 16 }}
>
</PermissionButton>
<Table<ClassTeacher>
columns={teacherColumns}
dataSource={teachers}
rowKey="id"
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
<Modal
title="添加教师"
open={teacherModalOpen}
onOk={handleAddTeacher}
onCancel={() => setTeacherModalOpen(false)}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Select
style={{ width: '100%' }}
showSearch
optionFilterProp="label"
placeholder="搜索姓名、用户名、角色或学科"
value={teacherUserId}
onChange={setTeacherUserId}
options={buildTeacherCandidateOptions(allUsers)}
notFoundContent="没有可分配的工作人员账号"
/>
<Select
style={{ width: '100%' }}
value={teacherRole}
onChange={setTeacherRole}
options={Object.entries(ROLE_MAP).map(([k, v]) => ({
value: k,
label: v,
}))}
/>
{teacherRole === 'subject_teacher' && (
<Input
placeholder="任教科目"
value={teacherSubject}
onChange={(e) => setTeacherSubject(e.target.value)}
/>
)}
</Space>
</Modal>
</div>
<ClassTeachersTab
teachers={teachers}
allUsers={allUsers}
teacherRole={teacherRole}
teacherSubject={teacherSubject}
teacherUserId={teacherUserId}
modalOpen={teacherModalOpen}
onOpen={openTeacherModal}
onAdd={handleAddTeacher}
onClose={() => setTeacherModalOpen(false)}
onRemove={handleRemoveTeacher}
onRoleChange={setTeacherRole}
onSubjectChange={setTeacherSubject}
onUserChange={setTeacherUserId}
getTeacherName={getTeacherName}
/>
),
},
{
key: 'schedule',
label: '课表',
children: (
<div>
<Space style={{ marginBottom: 16, maxWidth: '100%' }} wrap>
<DatePicker.RangePicker
value={scheduleDateRange}
onChange={(dates) =>
setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])
}
placeholder={['开始日期', '结束日期']}
/>
</Space>
<Table<ClassScheduleItem>
columns={scheduleColumns}
dataSource={schedules}
rowKey="id"
scroll={{ x: 'max-content' }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
}}
/>
</div>
<ClassScheduleTab
schedules={schedules}
scheduleDateRange={scheduleDateRange}
onRangeChange={setScheduleDateRange}
/>
),
},
{
key: 'attendance-summary',
label: '出勤汇总',
children: (
<div>
<Space style={{ marginBottom: 16, maxWidth: '100%' }} wrap>
<DatePicker.RangePicker
value={attendanceDateRange}
onChange={(dates) =>
setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])
}
placeholder={['开始日期', '结束日期']}
/>
</Space>
{attendanceSummary && (
<Row gutter={[16, 16]}>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="总记录" value={attendanceSummary.total} />
</Card>
</Col>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic
title="出勤率"
value={attendanceSummary.presentRate}
suffix="%"
/>
</Card>
</Col>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
</Card>
</Col>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
</Card>
</Col>
</Row>
)}
</div>
<ClassAttendanceTab
attendanceSummary={attendanceSummary}
attendanceDateRange={attendanceDateRange}
onRangeChange={setAttendanceDateRange}
/>
),
},
]}

View File

@@ -1,5 +1,11 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useState, useMemo, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { classesSchema } from '../../api/schemas';
import {
App,
Table,
Button,
Input,
@@ -17,14 +23,13 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
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 ----
import { usePermission } from '../../hooks/usePermission';
interface ClassItem {
id: number;
@@ -56,8 +61,6 @@ interface ClassFormValues {
notes?: string;
}
// ---- Constants ----
const STATUS_MAP: Record<string, { color: string; text: string }> = {
enrolling: { color: 'blue', text: '招生中' },
active: { color: 'green', text: '在读' },
@@ -72,12 +75,11 @@ const TYPE_MAP: Record<string, string> = {
sprint: '冲刺营',
};
// ---- Component ----
const ClassesPage: React.FC = () => {
const { modal } = App.useApp();
const navigate = useNavigate();
const [data, setData] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(false);
const { hasPermission } = usePermission();
const canPurgeClass = hasPermission('class:purge');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ClassItem | null>(null);
const [searchText, setSearchText] = useState('');
@@ -89,34 +91,74 @@ const ClassesPage: React.FC = () => {
const handleArchive = async (id: number, archive: boolean) => {
try {
await api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`);
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
fetchData();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | boolean | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterType) params.classType = filterType;
params.isArchived = showArchived;
const res = await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>);
setData(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
} finally {
setLoading(false);
}
}, [filterStatus, filterType, showArchived]);
const handlePurge = (record: ClassItem) => {
modal.confirm({
title: `永久删除班级「${record.name}」?`,
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
useEffect(() => {
fetchData();
}, [fetchData]);
const {
data = [],
isLoading,
isFetching,
} = useQuery<ClassItem[]>({
queryKey: ['classes', filterStatus, filterType, showArchived],
queryFn: async () => {
try {
const params: Record<string, string | boolean | undefined> = {};
if (filterStatus) params.status = filterStatus;
if (filterType) params.classType = filterType;
params.isArchived = showArchived;
return validateResponse<ClassItem[]>(
classesSchema,
await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>),
);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
},
});
const loading = isLoading || isFetching;
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
editing ? api.put(`/classes/${editing.id}`, payload) : api.post('/classes', payload),
{ invalidate: [['classes']] },
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: ClassItem; field: string; value: unknown }) =>
api.put(`/classes/${record.id}`, { [field]: value }),
{ invalidate: [['classes']] },
);
const archiveMutation = useApiMutation(
async ({ id, archive }: { id: number; archive: boolean }) =>
api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`),
{ invalidate: [['classes']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/classes/${id}/permanent`),
{ invalidate: [['classes']] },
);
const filtered = useMemo(() => {
if (!searchText) return data;
@@ -152,17 +194,11 @@ const ClassesPage: React.FC = () => {
startDate: values.startDate?.format('YYYY-MM-DD'),
endDate: values.endDate?.format('YYYY-MM-DD'),
};
if (editing) {
await api.put(`/classes/${editing.id}`, payload);
message.success('更新成功');
} else {
await api.post('/classes', payload);
message.success('创建成功');
}
await saveMutation.mutateAsync(payload);
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -170,11 +206,14 @@ 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();
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[fetchData],
[saveCellMutation],
);
const columns: ColumnsType<ClassItem> = useMemo(
@@ -196,6 +235,7 @@ const ClassesPage: React.FC = () => {
</EditableCell>
),
},
{
title: '编码',
dataIndex: 'code',
@@ -212,6 +252,7 @@ const ClassesPage: React.FC = () => {
</EditableCell>
),
},
{
title: '班型',
dataIndex: 'classType',
@@ -229,6 +270,7 @@ const ClassesPage: React.FC = () => {
</EditableCell>
),
},
{
title: '开班日期',
dataIndex: 'startDate',
@@ -245,6 +287,7 @@ const ClassesPage: React.FC = () => {
</EditableCell>
),
},
{
title: '学员',
width: 100,
@@ -259,6 +302,7 @@ const ClassesPage: React.FC = () => {
>{`${r.studentCount || 0}/${r.maxStudents || '-'}`}</EditableCell>
),
},
{
title: '状态',
dataIndex: 'status',
@@ -282,6 +326,7 @@ const ClassesPage: React.FC = () => {
</EditableCell>
),
},
{
title: '操作',
width: 280,
@@ -298,11 +343,18 @@ const ClassesPage: React.FC = () => {
</PermissionButton>
{r.isArchived ? (
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(r.id, false)}>
<PermissionButton permission="class:edit" size="small">
</PermissionButton>
</Popconfirm>
<>
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(r.id, false)}>
<PermissionButton permission="class:edit" size="small">
</PermissionButton>
</Popconfirm>
{canPurgeClass ? (
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
</Button>
) : null}
</>
) : (
<Popconfirm
title="归档后可恢复,确认归档?"
@@ -317,7 +369,7 @@ const ClassesPage: React.FC = () => {
),
},
],
[saveCell],
[saveCell, canPurgeClass, handlePurge],
);
return (

View File

@@ -0,0 +1,340 @@
import React from 'react';
import {
Button,
Empty,
Popconfirm,
Space,
Table,
Tag,
Tooltip,
Upload,
} from 'antd';
import {
CheckOutlined,
FileTextOutlined,
StopOutlined,
UploadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
const RENTAL_FIELDS = {
classroomId: 'classroomId',
lesseeOrganizationId: 'lesseeOrganizationId',
startDate: 'startDate',
endDate: 'endDate',
dailyRate: 'dailyRate',
totalAmount: 'totalAmount',
} as const;
export interface RentalTableProps {
data: any[];
loading: boolean;
classrooms: any[];
organizations: any[];
canPurgeRental: boolean;
hasPermission: (permission: string) => boolean;
onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;
onEdit: (record: any) => void;
onAction: (id: number, action: 'cancel' | 'end') => void;
onArchive: (id: number) => void;
onPurge: (id: number, name: string) => void;
onDownloadContract: (id: number, filename?: string) => void;
onDeleteContract: (id: number) => void;
onUploadContract: (id: number, formData: FormData) => Promise<unknown>;
}
export const RentalTable: React.FC<RentalTableProps> = ({
data,
loading,
classrooms,
organizations,
canPurgeRental,
hasPermission,
onSaveCell,
onEdit,
onAction,
onArchive,
onPurge,
onDownloadContract,
onDeleteContract,
onUploadContract,
}) => {
const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({
value,
field,
record,
editor,
min,
required,
options,
children,
}: {
value: unknown;
field: string;
record: R;
editor?: React.ComponentProps<typeof EditableCell>['editor'];
min?: number;
required?: boolean;
options?: Array<{ value: string | number; label: string }>;
children?: React.ReactNode;
}) => (
<EditableCell
value={value}
editor={editor}
min={min}
required={required}
options={options}
permission="rental:edit"
disabled={record.effectiveStatus !== 'active'}
onSave={async (next) => {
await onSaveCell(record, field, next);
}}
>
{children ?? String(value ?? '-')}
</EditableCell>
);
const columns = [
{
title: '教室',
width: 120,
dataIndex: 'classroom',
render: (c: any, r: any) => (
<EditableRentalCell
value={r.classroomId}
field={RENTAL_FIELDS.classroomId}
record={r}
editor="select"
options={classrooms
.filter((item) => item.status !== 'archived')
.map((item) => ({
value: item.id,
label: item.building ? `${item.building} · ${item.name}` : item.name,
}))}
required
>
{c ? (
<span>
{c.building ? `${c.building} · ` : ''}
{c.name}
</span>
) : (
'-'
)}
</EditableRentalCell>
),
},
{
title: '承租机构',
width: 100,
dataIndex: 'lesseeOrganization',
render: (t: any, r: any) => (
<EditableRentalCell
value={r.lesseeOrganizationId}
field={RENTAL_FIELDS.lesseeOrganizationId}
record={r}
editor="select"
options={organizations
.filter((item) => item.status !== 'archived')
.map((item) => ({ value: item.id, label: item.name }))}
required
>
{t ? (
<Tag
color={t.color}
style={{ background: t.color, color: '#fff', borderColor: t.color }}
>
{t.name}
</Tag>
) : (
'-'
)}
</EditableRentalCell>
),
},
{
title: '开始日期',
dataIndex: 'startDate',
width: 110,
render: (v: string, r: any) => (
<EditableRentalCell value={v} field={RENTAL_FIELDS.startDate} record={r} editor="date" required>
{v}
</EditableRentalCell>
),
},
{
title: '结束日期',
dataIndex: 'endDate',
width: 110,
render: (v: string, r: any) => (
<EditableRentalCell value={v} field={RENTAL_FIELDS.endDate} record={r} editor="date" required>
{v}
</EditableRentalCell>
),
},
{
title: '时长',
width: 80,
render: (_: any, r: any) => {
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
return `${d}`;
},
},
{
title: '日租金',
dataIndex: 'dailyRate',
width: 100,
render: (v: any, r: any) => (
<EditableRentalCell value={v} field={RENTAL_FIELDS.dailyRate} record={r} editor="money" min={0.01}>
{v ? `¥${v}` : '-'}
</EditableRentalCell>
),
},
{
title: '总额',
dataIndex: 'totalAmount',
width: 100,
render: (v: any, r: any) => (
<EditableRentalCell value={v} field={RENTAL_FIELDS.totalAmount} record={r} editor="money" min={0.01}>
{v ? `¥${v}` : '-'}
</EditableRentalCell>
),
},
{
title: '状态',
dataIndex: 'effectiveStatus',
width: 90,
render: (status: string) => {
const config: Record<string, { text: string; color: string }> = {
active: { text: '进行中', color: 'green' },
ended: { text: '已结束', color: 'default' },
cancelled: { text: '已取消', color: 'red' },
};
return <Tag color={config[status]?.color}>{config[status]?.text || status}</Tag>;
},
},
{
title: '合同',
width: 120,
dataIndex: 'contractPath',
render: (v: string, r: any) =>
v ? (
<Space>
<Tooltip title={r.contractOriginalName}>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => onDownloadContract(r.id, r.contractOriginalName)}
>
</Button>
</Tooltip>
{hasPermission('rental:edit') ? (
<Popconfirm title="移除合同文件?" onConfirm={() => onDeleteContract(r.id)}>
<Button size="small" danger icon={<StopOutlined />} aria-label="移除合同文件" />
</Popconfirm>
) : null}
</Space>
) : hasPermission('rental:edit') ? (
<Upload
accept="application/pdf"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
if (file.size > 10 * 1024 * 1024) {
message.error('文件不能超过 10MB');
onError?.(new Error('size'));
return;
}
const formData = new FormData();
formData.append('file', file);
try {
await onUploadContract(r.id, formData);
message.success('合同已上传');
onSuccess?.({});
} catch (e) {
onError?.(e as Error);
}
}}
>
<Button size="small" icon={<UploadOutlined />}>
PDF
</Button>
</Upload>
) : (
'-'
),
},
{
title: '操作',
width: 150,
render: (_: any, record: any) => (
<Space>
{record.effectiveStatus === 'active' && (
<>
<PermissionButton permission="rental:edit" size="small" onClick={() => onEdit(record)}>
</PermissionButton>
<Popconfirm title="确定取消该租赁?" onConfirm={() => onAction(record.id, 'cancel')}>
<PermissionButton
permission="rental:edit"
size="small"
danger
icon={<StopOutlined />}
>
</PermissionButton>
</Popconfirm>
{!dayjs(record.startDate).isAfter(dayjs(), 'day') && (
<Popconfirm title="确定今天结束该租赁?" onConfirm={() => onAction(record.id, 'end')}>
<PermissionButton permission="rental:edit" size="small" icon={<CheckOutlined />}>
</PermissionButton>
</Popconfirm>
)}
</>
)}
{record.effectiveStatus !== 'active' && (
<Popconfirm
title="确定归档该租赁订单?合同文件会保留。"
onConfirm={() => onArchive(record.id)}
>
<PermissionButton permission="rental:delete" size="small" danger>
</PermissionButton>
</Popconfirm>
)}
{record.status === 'cancelled' && canPurgeRental ? (
<Button
size="small"
danger
type="link"
onClick={() => onPurge(record.id, record.lesseeOrganization?.name || `订单${record.id}`)}
>
</Button>
) : null}
</Space>
),
},
];
return (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
scroll={{ x: 1200 }}
/>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useImmer } from 'use-immer';
import {
Table,
Button,
App,
Modal,
Form,
Select,
@@ -9,39 +9,32 @@ import {
InputNumber,
Input,
Space,
Tag,
Popconfirm,
Upload,
Tooltip,
Empty,
} from 'antd';
import {
PlusOutlined,
UploadOutlined,
FileTextOutlined,
StopOutlined,
CheckOutlined,
} from '@ant-design/icons';
import { PlusOutlined } from '@ant-design/icons';
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';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { getErrorMessage } from '../../utils/error';
import { validateResponse } from '../../utils/validate';
import { classroomsSchema, organizationsSchema, rentalsSchema } from '../../api/schemas';
import { RentalTable } from './RentalTable';
interface UnavailableDatesResponse {
dates: string[];
}
export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) =>
`${classroomId}:${date.format('YYYY-MM')}`;
const ClassroomRentalsPage: React.FC = () => {
const { modal } = App.useApp();
const { hasPermission, hasAnyPermission } = usePermission();
const [data, setData] = useState<any[]>([]);
const [classrooms, setClassrooms] = useState<any[]>([]);
const [organizations, setOrganizations] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const canPurgeRental = hasPermission('rental:purge');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form] = Form.useForm();
@@ -49,12 +42,110 @@ const ClassroomRentalsPage: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string | undefined>();
const [searchText, setSearchText] = useState('');
const [saving, setSaving] = useState(false);
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set());
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
const unavailableRequestVersion = useRef(0);
const [unavailableDatesLoading, setUnavailableDatesLoading] = useState(false);
const selectedClassroomId = Form.useWatch('classroomId', form);
const {
data = [],
isLoading,
isFetching,
} = useQuery<any[]>({
queryKey: ['classroom-rentals', filterMonth],
queryFn: async () => {
try {
const params: any = {};
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
params.includeEnded = true;
return validateResponse<any[]>(
rentalsSchema,
await api.get('/classroom-rentals', { params }),
);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
},
});
const {
data: meta = { classrooms: [], organizations: [] },
} = useQuery<{ classrooms: any[]; organizations: any[] }>({
queryKey: ['classroom-rentals', 'meta'],
enabled: hasAnyPermission('rental:create', 'rental:edit'),
queryFn: async () => {
try {
const [cr, tn]: any = await Promise.all([
api.get('/classrooms'),
api.get('/organizations', { params: { scope: 'all' } }),
]);
return {
classrooms: validateResponse<any[]>(classroomsSchema, cr),
organizations: validateResponse<any[]>(organizationsSchema, tn),
};
} catch (e: any) {
message.error(e?.message || '加载教室列表失败');
return { classrooms: [], organizations: [] };
}
},
});
const classrooms = meta.classrooms;
const organizations = meta.organizations;
const loading = isLoading || isFetching;
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
editing
? api.put(`/classroom-rentals/${editing.id}`, payload)
: api.post('/classroom-rentals', payload),
{
invalidate: [['classroom-rentals']],
onError: (error: unknown) => {
const e = error as {
conflicts?: Array<{ organizationName?: string; startDate?: string; endDate?: string }>;
};
if (e?.conflicts?.length) {
const list = e.conflicts
.map((c) => `${c.organizationName}(${c.startDate}~${c.endDate})`)
.join('、');
message.error(`时间段冲突:${list}`);
} else {
message.error(getErrorMessage(error, '操作失败'));
}
},
},
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
api.put(`/classroom-rentals/${record.id}`, { [field]: value }),
{ invalidate: [['classroom-rentals']] },
);
const deleteMutation = useApiMutation(
async (id: number) => api.delete(`/classroom-rentals/${id}`),
{ invalidate: [['classroom-rentals']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/classroom-rentals/${id}/permanent`),
{ invalidate: [['classroom-rentals']] },
);
const actionMutation = useApiMutation(
async ({ id, action }: { id: number; action: 'cancel' | 'end' }) =>
api.put(`/classroom-rentals/${id}/${action}`),
{ invalidate: [['classroom-rentals']] },
);
const deleteContractMutation = useApiMutation(
async (id: number) => api.delete(`/classroom-rentals/${id}/contract`),
{ invalidate: [['classroom-rentals']] },
);
const uploadContractMutation = useApiMutation(
async ({ id, formData }: { id: number; formData: FormData }) =>
api.post(`/classroom-rentals/${id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
{ invalidate: [['classroom-rentals']] },
);
const filteredData = useMemo(() => {
return data.filter((r: any) => {
if (filterStatus && r.effectiveStatus !== filterStatus) return false;
@@ -66,40 +157,6 @@ const ClassroomRentalsPage: React.FC = () => {
});
}, [data, searchText, filterStatus]);
const fetchData = async () => {
setLoading(true);
try {
const params: any = {};
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
params.includeEnded = true;
const res: any = await api.get('/classroom-rentals', { params });
setData(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setLoading(false);
};
const fetchMeta = async () => {
try {
const [cr, tn]: any = await Promise.all([
api.get('/classrooms'),
api.get('/organizations', { params: { scope: 'all' } }),
]);
setClassrooms(cr);
setOrganizations(tn);
} catch (e: any) {
message.error(e?.message || '加载教室列表失败');
}
};
useEffect(() => {
if (hasAnyPermission('rental:create', 'rental:edit')) fetchMeta();
}, [hasAnyPermission]);
useEffect(() => {
fetchData();
}, [filterMonth]);
const resetUnavailableDates = () => {
unavailableRequestVersion.current += 1;
loadedUnavailableMonths.current.clear();
@@ -127,10 +184,8 @@ const ClassroomRentalsPage: React.FC = () => {
},
);
if (requestVersion !== unavailableRequestVersion.current) return;
setUnavailableDates((current) => {
const next = new Set(current);
response.dates.forEach((item) => next.add(item));
return next;
setUnavailableDates((draft) => {
response.dates.forEach((item) => draft.add(item));
});
} catch (e: any) {
loadedUnavailableMonths.current.delete(key);
@@ -190,75 +245,85 @@ const ClassroomRentalsPage: React.FC = () => {
notes: values.notes,
};
try {
if (editing) {
await api.put(`/classroom-rentals/${editing.id}`, payload);
message.success('更新成功');
} else {
await api.post('/classroom-rentals', payload);
message.success('创建成功');
}
await saveMutation.mutateAsync(payload);
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) {
if (e?.conflicts?.length) {
const list = e.conflicts
.map((c: any) => `${c.organizationName}(${c.startDate}~${c.endDate})`)
.join('、');
message.error(`时间段冲突:${list}`);
} else {
message.error(e?.message || '操作失败');
}
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: any, field: string, value: unknown) => {
await api.put(`/classroom-rentals/${record.id}`, { [field]: value });
message.success('已保存');
await fetchData();
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}`);
await deleteMutation.mutateAsync(id);
message.success('已归档');
fetchData();
} catch (e: any) {
message.error(e?.message || '归档失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (id: number, name: string) => {
modal.confirm({
title: `永久删除租赁订单(${name}`,
content: '删除后不可恢复,排课与合同文件将被清除(存在考勤记录时将无法删除)。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handleRentalAction = async (id: number, action: 'cancel' | 'end') => {
try {
await api.put(`/classroom-rentals/${id}/${action}`);
await actionMutation.mutateAsync({ id, action });
message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
} catch {
} catch (e) {
console.error('下载合同失败', e);
message.error('下载失败(可能文件已丢失)');
}
};
const handleDeleteContract = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}/contract`);
await deleteContractMutation.mutateAsync(id);
message.success('合同已移除');
fetchData();
} catch (e: any) {
message.error(e?.message || '移除失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleUploadContract = async (id: number, formData: FormData) => {
return uploadContractMutation.mutateAsync({ id, formData });
};
const openEdit = (record: any) => {
setEditing(record);
resetUnavailableDates();
@@ -280,271 +345,6 @@ const ClassroomRentalsPage: React.FC = () => {
);
};
const columns = useMemo(
() => [
{
title: '教室',
width: 120,
dataIndex: 'classroom',
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, 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: '时长',
width: 80,
render: (_: any, r: any) => {
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
return `${d}`;
},
},
{
title: '日租金',
dataIndex: 'dailyRate',
width: 100,
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, 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: '状态',
dataIndex: 'effectiveStatus',
width: 90,
render: (status: string) => {
const config: Record<string, { text: string; color: string }> = {
active: { text: '进行中', color: 'green' },
ended: { text: '已结束', color: 'default' },
cancelled: { text: '已取消', color: 'red' },
};
return <Tag color={config[status]?.color}>{config[status]?.text || status}</Tag>;
},
},
{
title: '合同',
width: 120,
dataIndex: 'contractPath',
render: (v: string, r: any) =>
v ? (
<Space>
<Tooltip title={r.contractOriginalName}>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => handleDownloadContract(r.id, r.contractOriginalName)}
>
</Button>
</Tooltip>
{hasPermission('rental:edit') ? (
<Popconfirm title="移除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<StopOutlined />} aria-label="移除合同文件" />
</Popconfirm>
) : null}
</Space>
) : hasPermission('rental:edit') ? (
<Upload
accept="application/pdf"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
if (file.size > 10 * 1024 * 1024) {
message.error('文件不能超过 10MB');
onError?.(new Error('size'));
return;
}
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/classroom-rentals/${r.id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success('合同已上传');
onSuccess?.({});
fetchData();
} catch (e: any) {
message.error(e?.message || '上传失败');
onError?.(e);
}
}}
>
<Button size="small" icon={<UploadOutlined />}>
PDF
</Button>
</Upload>
) : (
'-'
),
},
{
title: '操作',
width: 150,
render: (_: any, record: any) => (
<Space>
{record.effectiveStatus === 'active' && (
<>
<PermissionButton
permission="rental:edit"
size="small"
onClick={() => openEdit(record)}
>
</PermissionButton>
<Popconfirm
title="确定取消该租赁?"
onConfirm={() => handleRentalAction(record.id, 'cancel')}
>
<PermissionButton
permission="rental:edit"
size="small"
danger
icon={<StopOutlined />}
>
</PermissionButton>
</Popconfirm>
{!dayjs(record.startDate).isAfter(dayjs(), 'day') && (
<Popconfirm
title="确定今天结束该租赁?"
onConfirm={() => handleRentalAction(record.id, 'end')}
>
<PermissionButton
permission="rental:edit"
size="small"
icon={<CheckOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</>
)}
{record.effectiveStatus !== 'active' && (
<Popconfirm
title="确定归档该租赁订单?合同文件会保留。"
onConfirm={() => handleDelete(record.id)}
>
<PermissionButton permission="rental:delete" size="small" danger>
</PermissionButton>
</Popconfirm>
)}
</Space>
),
},
],
[classrooms, organizations, hasPermission],
);
return (
<div>
<div
@@ -601,19 +401,21 @@ const ClassroomRentalsPage: React.FC = () => {
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
<RentalTable
data={filteredData}
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
scroll={{ x: 1200 }}
classrooms={classrooms}
organizations={organizations}
canPurgeRental={canPurgeRental}
hasPermission={hasPermission}
onSaveCell={saveCell}
onEdit={openEdit}
onAction={handleRentalAction}
onArchive={handleDelete}
onPurge={handlePurge}
onDownloadContract={handleDownloadContract}
onDeleteContract={handleDeleteContract}
onUploadContract={handleUploadContract}
/>
<Modal
title={editing ? '编辑租赁' : '新增租赁'}

View File

@@ -1,4 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { validateResponse } from '../../utils/validate';
import { classroomScheduleSchema } from '../../api/schemas';
import {
DatePicker,
Card,
@@ -18,6 +21,7 @@ import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message';
import { getErrorMessage } from '../../utils/error';
interface ScheduleData {
year: number;
@@ -34,27 +38,25 @@ interface ScheduleData {
const ClassroomSchedulePage: React.FC = () => {
const [month, setMonth] = useState<Dayjs>(dayjs());
const [loading, setLoading] = useState(false);
const [data, setData] = useState<ScheduleData | null>(null);
const [detailModal, setDetailModal] = useState<any>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res: any = await api.get('/classroom-rentals/schedule', {
params: { year: month.year(), month: month.month() + 1 },
});
setData(res);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [month]);
useEffect(() => {
fetchData();
}, [fetchData]);
const { data, isLoading, isFetching } = useQuery<ScheduleData | null>({
queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()],
queryFn: async () => {
try {
return validateResponse<ScheduleData | null>(
classroomScheduleSchema,
await api.get('/classroom-rentals/schedule', {
params: { year: month.year(), month: month.month() + 1 },
}),
);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return null;
}
},
});
const loading = isLoading || isFetching;
// 按楼栋+楼层分组教室
const groups = useMemo(() => {
@@ -88,8 +90,7 @@ const ClassroomSchedulePage: React.FC = () => {
const res: any = await api.get(`/classroom-rentals/${rentalId}`);
setDetailModal(res);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载详情失败');
message.error(getErrorMessage(e, '加载详情失败'));
}
};

View File

@@ -1,5 +1,10 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { classroomsSchema } from '../../api/schemas';
import {
App,
Table,
Button,
Modal,
@@ -50,9 +55,8 @@ const typeColor: Record<string, string> = {
};
const ClassroomsPage: React.FC = () => {
const { modal } = App.useApp();
const { hasPermission } = usePermission();
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [showArchived, setShowArchived] = useState(false);
@@ -62,6 +66,56 @@ const ClassroomsPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const {
data = [],
isLoading,
isFetching,
} = useQuery<any[]>({
queryKey: ['classrooms', showArchived],
queryFn: async () => {
try {
return validateResponse<any[]>(
classroomsSchema,
await api.get('/classrooms', { params: { includeArchived: showArchived } }),
);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
},
});
const loading = isLoading || isFetching;
const saveMutation = useApiMutation(
async (values: Record<string, unknown>) =>
editing ? api.put(`/classrooms/${editing.id}`, values) : api.post('/classrooms', values),
{ invalidate: [['classrooms']] },
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
api.put(`/classrooms/${record.id}`, { [field]: value }),
{ invalidate: [['classrooms']] },
);
const archiveMutation = useApiMutation(
async (id: number) => api.delete(`/classrooms/${id}`),
{ invalidate: [['classrooms']] },
);
const restoreMutation = useApiMutation(
async (id: number) => api.put(`/classrooms/${id}/restore`),
{ invalidate: [['classrooms']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/classrooms/${id}/permanent`),
{ invalidate: [['classrooms']] },
);
const importMutation = useApiMutation(
async (formData: FormData) =>
api.post('/classrooms/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
{ invalidate: [['classrooms']] },
);
const filteredData = useMemo(() => {
let result = data;
if (searchText) {
@@ -77,69 +131,67 @@ const ClassroomsPage: React.FC = () => {
return result;
}, [data, searchText, filterStatus]);
const fetchData = async () => {
setLoading(true);
try {
const res: any = await api.get('/classrooms', { params: { includeArchived: showArchived } });
setData(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setLoading(false);
};
useEffect(() => {
fetchData();
}, [showArchived]);
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await api.put(`/classrooms/${editing.id}`, values);
message.success('更新成功');
} else {
await api.post('/classrooms', values);
message.success('创建成功');
}
await saveMutation.mutateAsync(values);
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: any, field: string, value: unknown) => {
await api.put(`/classrooms/${record.id}`, { [field]: value });
message.success('已保存');
await fetchData();
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleArchive = async (id: number) => {
try {
await api.delete(`/classrooms/${id}`);
await archiveMutation.mutateAsync(id);
message.success('已归档');
fetchData();
} catch (e: any) {
message.error(e?.message || '归档失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleRestore = async (id: number) => {
try {
await api.put(`/classrooms/${id}/restore`);
await restoreMutation.mutateAsync(id);
message.success('已恢复');
fetchData();
} catch (e: any) {
message.error(e?.message || '恢复失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (id: number, name: string) => {
modal.confirm({
title: `永久删除教室「${name}」?`,
content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handleDownloadTemplate = () => {
const baseURL = import.meta.env.PROD
? '/api'
@@ -177,6 +229,7 @@ const ClassroomsPage: React.FC = () => {
</EditableCell>
),
},
{
title: '楼栋',
dataIndex: 'building',
@@ -192,6 +245,7 @@ const ClassroomsPage: React.FC = () => {
</EditableCell>
),
},
{
title: '楼层',
dataIndex: 'floor',
@@ -208,6 +262,7 @@ const ClassroomsPage: React.FC = () => {
</EditableCell>
),
},
{
title: '类型',
width: 90,
@@ -225,6 +280,7 @@ const ClassroomsPage: React.FC = () => {
</EditableCell>
),
},
{
title: '容量',
dataIndex: 'capacity',
@@ -242,6 +298,7 @@ const ClassroomsPage: React.FC = () => {
</EditableCell>
),
},
{
title: '状态',
width: 100,
@@ -278,22 +335,35 @@ const ClassroomsPage: React.FC = () => {
);
},
},
{
title: '操作',
width: 180,
render: (_: any, record: any) => (
<Space>
{record.status === 'archived' ? (
<Popconfirm title="确定恢复此教室?" onConfirm={() => handleRestore(record.id)}>
<PermissionButton
permission="classroom:edit"
size="small"
icon={<UndoOutlined />}
type="link"
>
</PermissionButton>
</Popconfirm>
<>
<Popconfirm title="确定恢复此教室?" onConfirm={() => handleRestore(record.id)}>
<PermissionButton
permission="classroom:edit"
size="small"
icon={<UndoOutlined />}
type="link"
>
</PermissionButton>
</Popconfirm>
{hasPermission('classroom:purge') ? (
<Button
size="small"
danger
type="link"
onClick={() => handlePurge(record.id, record.name)}
>
</Button>
) : null}
</>
) : (
<>
<PermissionButton
@@ -327,7 +397,7 @@ const ClassroomsPage: React.FC = () => {
),
},
],
[],
[handlePurge, hasPermission],
);
return (
@@ -413,15 +483,11 @@ const ClassroomsPage: React.FC = () => {
const formData = new FormData();
formData.append('file', file);
try {
const res: any = await api.post('/classrooms/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const res: any = await importMutation.mutateAsync(formData);
message.success(res.message);
onSuccess?.(res);
fetchData();
} catch (e: any) {
message.error(e?.message || '导入失败');
onError?.(e);
} catch (e) {
onError?.(e as Error);
}
}}
>

View File

@@ -0,0 +1,128 @@
import React from 'react';
export const COLORS = [
'#007AFF',
'#34C759',
'#FF9500',
'#FF3B30',
'#5AC8FA',
'#AF52DE',
'#FF2D55',
'#FFCC00',
];
export interface BillStatRow {
status: string;
count: string;
total: string;
}
export interface ClassAttendanceRank {
className: string;
present: number;
total: number;
rate: number;
}
export interface ClassroomOccupancy {
name: string;
building: string;
capacity: number;
scheduleDays: number;
rentalCount: number;
occupancy: number;
}
export interface ClassroomUtilStats {
totalClassrooms: number;
inUseCount: number;
utilizationRate: string;
scheduleCount: number;
rentalCount: number;
}
export interface AttendanceTrendRow {
date: string;
rate: string;
}
export interface IncomeTrendRow {
month: string;
amount: number;
}
export interface OccupancyByBuildingRow {
building: string;
count: string;
}
export interface ExpenseByTypeRow {
type: string;
total: string;
}
export interface GanttOccupancy {
studentName: string;
studentId?: string;
checkInDate: string;
checkOutDate: string | null;
billingStartDate?: string;
billingEndDate?: string;
}
export interface GanttRoom {
roomNumber: string;
occupancies: GanttOccupancy[];
}
export interface DashboardStats {
totalRooms: number;
totalStudents: number;
occupiedBeds: number;
totalCapacity: number;
occupancyRate: string;
billStats: BillStatRow[];
classroomCount: number;
classroomOccupancyRate: string;
todayAttendanceRate?: string;
monthlyIncome: number;
classCount: number;
teacherCount: number;
pendingDeposits: number;
activeRentals: number;
todayPresent: number;
occupancyByBuilding: OccupancyByBuildingRow[];
attendanceByStatus: Record<string, number>;
expenseByType: ExpenseByTypeRow[];
attendanceTrend: AttendanceTrendRow[];
incomeTrend: IncomeTrendRow[];
}
export const attendanceLabelMap: Record<string, string> = {
present: '出勤',
absent: '缺勤',
late: '迟到',
early: '早退',
leave: '请假',
};
export const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 };
export const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 };
export const TODO_CARD_BASE: React.CSSProperties = {
cursor: 'pointer',
transition: 'box-shadow 0.2s, transform 0.2s',
borderRadius: 8,
height: '100%',
};
export const TODO_CARD_WARN: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #FF9500',
background: '#fff7e6',
};
export const TODO_CARD_DANGER: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #FF3B30',
background: '#fff1f0',
};
export const TODO_CARD_OK: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #34C759',
background: '#f0fff4',
};
export const TODO_CARD_DRAFT: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #AF52DE',
background: '#f9f0ff',
};

View File

@@ -0,0 +1,262 @@
import type { EChartsOption } from '../../components/ECharts';
import {
attendanceLabelMap,
COLORS,
type AttendanceTrendRow,
type ClassAttendanceRank,
type ClassroomOccupancy,
type DashboardStats,
type ExpenseByTypeRow,
type GanttRoom,
type IncomeTrendRow,
} from './Dashboard.types';
export function buildAttendanceRingOption(stats: DashboardStats | null): EChartsOption {
return {
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({
name: attendanceLabelMap[status] ?? status,
value: count,
})),
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
},
],
color: COLORS,
};
}
export function buildRoomRankingBarOption(
roomRanking: Array<{ roomNumber: string; total: string }>,
): EChartsOption {
return {
tooltip: {},
grid: { left: 80, right: 20, bottom: 30, top: 10 },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: roomRanking.map((r) => r.roomNumber).reverse(),
inverse: false,
},
series: [
{
type: 'bar',
data: roomRanking.map((r) => Number(r.total)).reverse(),
itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },
},
],
};
}
export function buildClassRankingOption(
rows: ClassAttendanceRank[],
color: string,
): EChartsOption {
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (v: number) => `${v}%`,
},
grid: { left: 80, right: 30, bottom: 30, top: 10 },
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
yAxis: {
type: 'category',
data: rows.map((r) => r.className),
inverse: true,
},
series: [
{
type: 'bar',
data: rows.map((r) => r.rate),
itemStyle: { color, borderRadius: [0, 4, 4, 0] },
label: { show: true, position: 'right', formatter: '{c}%' },
},
],
};
}
export function buildAttendanceLineOption(rows: AttendanceTrendRow[]): EChartsOption {
return {
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, bottom: 30, top: 10 },
xAxis: {
type: 'category',
data: rows.map((d) => d.date),
axisLabel: { rotate: 45, fontSize: 10 },
},
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
series: [
{
type: 'line',
data: rows.map((d) => parseFloat(d.rate) || 0),
smooth: true,
lineStyle: { color: '#007AFF', width: 2 },
itemStyle: { color: '#007AFF' },
areaStyle: { color: 'rgba(0,122,255,0.1)' },
},
],
};
}
export function buildIncomeLineOption(rows: IncomeTrendRow[]): EChartsOption {
return {
tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` },
grid: { left: 70, right: 20, bottom: 30, top: 10 },
xAxis: {
type: 'category',
data: rows.map((d) => d.month),
},
yAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}` },
},
series: [
{
type: 'line',
data: rows.map((d) => d.amount),
smooth: true,
lineStyle: { color: '#34C759', width: 2 },
itemStyle: { color: '#34C759' },
areaStyle: { color: 'rgba(52,199,89,0.1)' },
},
],
};
}
export function buildExpensePieOption(
rows: ExpenseByTypeRow[],
expenseTypeMap: Record<string, string>,
): EChartsOption {
return {
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
color: COLORS,
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
data: rows.map((e) => ({
name: expenseTypeMap[e.type] ?? e.type,
value: Number(e.total),
})),
},
],
};
}
export function buildClassroomHeatmapOption(
classroomOccupancy: ClassroomOccupancy[],
): EChartsOption {
return {
tooltip: {
formatter: (p: {
name: string;
data: { scheduleDays: number; rentalCount: number; occupancy: number };
}) =>
`${p.name}<br/>排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`,
},
grid: { left: 100, right: 20, bottom: 30, top: 10 },
xAxis: { type: 'value', max: 1 },
yAxis: {
type: 'category',
data: classroomOccupancy.map((r) => r.name),
inverse: true,
},
visualMap: {
min: 0,
max: 1,
orient: 'horizontal',
left: 'center',
bottom: 0,
inRange: {
color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'],
},
},
series: [
{
type: 'bar',
data: classroomOccupancy.map((r) => ({
name: r.name,
value: r.occupancy,
scheduleDays: r.scheduleDays,
rentalCount: r.rentalCount,
occupancy: r.occupancy,
})),
itemStyle: { borderRadius: [0, 4, 4, 0] },
label: {
show: true,
position: 'right',
formatter: (p: { data: { occupancy: number } }) =>
`${(p.data.occupancy * 100).toFixed(0)}%`,
},
},
],
};
}
export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
return {
tooltip: {
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
`${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`,
},
grid: { left: 100, right: 30, bottom: 40, top: 20 },
xAxis: { type: 'time' },
yAxis: { type: 'category', data: ganttData.map((r) => r.roomNumber), inverse: true },
dataZoom: [
{ type: 'slider', xAxisIndex: 0, bottom: 10, height: 20 },
{ type: 'inside', xAxisIndex: 0 },
],
series: [
{
type: 'custom',
renderItem: (
_params: unknown,
api: {
value: (i: number) => string | boolean;
coord: (p: [string | number, string | number]) => [number, number];
size: (p: [number, number]) => [number, number];
},
) => {
const cat = String(api.value(0));
const startDate = String(api.value(1));
const endDate = String(api.value(2));
const isActive = Boolean(api.value(3));
const start = api.coord([startDate, cat]);
const end = api.coord([endDate, cat]);
const height = api.size([0, 1])[1] * 0.6;
const rectShape = {
x: start[0],
y: start[1] - height / 2,
width: Math.max(end[0] - start[0], 2),
height,
};
return {
type: 'rect' as const,
shape: rectShape,
style: { fill: isActive ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 },
};
},
encode: { x: [1, 2], y: 0 },
data: ganttData.flatMap((r) =>
(r.occupancies || []).map((o) => ({
name: o.studentName,
value: [
r.roomNumber,
o.checkInDate,
o.checkOutDate || new Date().toISOString().slice(0, 10),
!o.checkOutDate,
] as [string, string, string, boolean],
})),
),
},
],
};
}

View File

@@ -0,0 +1,85 @@
import React, { type CSSProperties } from 'react';
import { Card, Col, Row } from 'antd';
import { useIntersectionObserver } from 'usehooks-ts';
import ReactECharts from '../../components/ECharts';
import type { ClassroomOccupancy, GanttRoom } from './Dashboard.types';
import { buildClassroomHeatmapOption, buildGanttOption } from './DashboardCharts';
const useInViewport = (rootMargin = '200px') => {
const { ref, isIntersecting } = useIntersectionObserver({
rootMargin,
freezeOnceVisible: true,
});
return { ref, inView: isIntersecting };
};
const LazySection: React.FC<{
title: string;
vp: { ref: (node?: Element | null) => void; inView: boolean };
minHeight: number;
style?: CSSProperties;
children: React.ReactNode;
}> = ({ title, vp, minHeight, style, children }) => {
return (
<div ref={vp.ref} style={style}>
{vp.inView ? (
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card title={title}>{children}</Card>
</Col>
</Row>
) : (
<Card title={title} style={{ minHeight }}>
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
</Card>
)}
</div>
);
};
export const ClassroomHeatmapCard: React.FC<{
data: ClassroomOccupancy[];
isMobile: boolean;
}> = ({ data, isMobile }) => {
const vp = useInViewport('200px');
return (
<LazySection
title="教室占用热力图"
vp={vp}
minHeight={isMobile ? 340 : 440}
style={{ marginBottom: 24 }}
>
{data.length > 0 ? (
<ReactECharts
option={buildClassroomHeatmapOption(data)}
style={{ width: '100%', height: isMobile ? 300 : 400 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</LazySection>
);
};
export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
data,
isMobile,
}) => {
const vp = useInViewport('200px');
return (
<LazySection
title="入住时间线(甘特图)"
vp={vp}
minHeight={isMobile ? 340 : 490}
>
{data.length > 0 ? (
<ReactECharts
option={buildGanttOption(data)}
style={{ width: '100%', height: isMobile ? 300 : 450 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</LazySection>
);
};

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { Card, Col, Row } from 'antd';
import {
ArrowRightOutlined,
BankOutlined,
DollarOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types';
export const DashboardTodoCards: React.FC<{
absentCount: number;
draftCount: number;
draftTotal: number;
pendingDeposits: number;
}> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => {
const navigate = useNavigate();
return (
<Card title="待办与异常" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card
style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/attendance')}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<ExclamationCircleOutlined
style={{ fontSize: 28, color: absentCount > 0 ? '#FF9500' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: absentCount > 0 ? '#FF9500' : '#999',
}}
>
{absentCount}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div>
{absentCount > 0 ? (
<div style={{ fontSize: 12, color: '#FF9500', marginTop: 4 }}></div>
) : (
<div style={{ fontSize: 12, color: '#34C759', marginTop: 4 }}></div>
)}
</div>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card
style={draftCount > 0 ? TODO_CARD_DRAFT : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/bills')}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<DollarOutlined
style={{ fontSize: 28, color: draftCount > 0 ? '#AF52DE' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: draftCount > 0 ? '#AF52DE' : '#999',
}}
>
{draftCount}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div>
<div
style={{ fontSize: 12, color: draftCount > 0 ? '#AF52DE' : '#999', marginTop: 4 }}
>
{draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'}
</div>
</div>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card
style={pendingDeposits > 0 ? TODO_CARD_DANGER : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/deposits')}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<BankOutlined
style={{ fontSize: 28, color: pendingDeposits > 0 ? '#FF3B30' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
}}
>
¥{pendingDeposits.toLocaleString()}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}>退</div>
<div
style={{
fontSize: 12,
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
marginTop: 4,
}}
>
{pendingDeposits > 0 ? '需要处理' : '暂无待退'}
</div>
</div>
</Card>
</Col>
</Row>
</Card>
);
};

View File

@@ -1,4 +1,15 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { validateResponse } from '../../utils/validate';
import {
classAttendanceRankingSchema,
classroomOccupanciesSchema,
classroomUtilStatsSchema,
dashboardStatsSchema,
expenseTypesSchema,
ganttRoomsSchema,
roomRankingSchema,
} from '../../api/schemas';
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse } from 'antd';
import {
TeamOutlined,
@@ -11,460 +22,142 @@ import {
FileProtectOutlined,
ReadOutlined,
CalendarOutlined,
ArrowRightOutlined,
ExclamationCircleOutlined,
DollarOutlined,
} from '@ant-design/icons';
import ReactECharts, { type EChartsOption } from '../../components/ECharts';
import ReactECharts from '../../components/ECharts';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import api from '../../api';
import { message } from '../../ui/app-message';
import {
buildAttendanceLineOption,
buildAttendanceRingOption,
buildClassRankingOption,
buildExpensePieOption,
buildIncomeLineOption,
buildRoomRankingBarOption,
} from './DashboardCharts';
import { ClassroomHeatmapCard, GanttCard } from './DashboardLazyCards';
import {
MARGIN_BOTTOM_16_STYLE,
SECTION_ROW_STYLE,
type ClassAttendanceRank,
type ClassroomOccupancy,
type ClassroomUtilStats,
type DashboardStats,
type GanttRoom,
} from './Dashboard.types';
import { DashboardTodoCards } from './DashboardTodoCards';
const { RangePicker } = DatePicker;
const COLORS = [
'#007AFF',
'#34C759',
'#FF9500',
'#FF3B30',
'#5AC8FA',
'#AF52DE',
'#FF2D55',
'#FFCC00',
];
interface BillStatRow {
status: string;
count: string;
total: string;
}
interface ClassAttendanceRank {
className: string;
present: number;
total: number;
rate: number;
}
interface ClassroomOccupancy {
name: string;
building: string;
capacity: number;
scheduleDays: number;
rentalCount: number;
occupancy: number;
}
interface ClassroomUtilStats {
totalClassrooms: number;
inUseCount: number;
utilizationRate: string;
scheduleCount: number;
rentalCount: number;
}
interface AttendanceTrendRow {
date: string;
rate: string;
}
interface IncomeTrendRow {
month: string;
amount: number;
}
interface OccupancyByBuildingRow {
building: string;
count: string;
}
interface ExpenseByTypeRow {
type: string;
total: string;
}
interface GanttOccupancy {
studentName: string;
studentId?: string;
checkInDate: string;
checkOutDate: string | null;
billingStartDate?: string;
billingEndDate?: string;
}
interface GanttRoom {
roomNumber: string;
occupancies: GanttOccupancy[];
}
interface DashboardStats {
totalRooms: number;
totalStudents: number;
occupiedBeds: number;
totalCapacity: number;
occupancyRate: string;
billStats: BillStatRow[];
classroomCount: number;
classroomOccupancyRate: string;
todayAttendanceRate: string;
monthlyIncome: number;
classCount: number;
teacherCount: number;
pendingDeposits: number;
activeRentals: number;
todayPresent: number;
occupancyByBuilding: OccupancyByBuildingRow[];
attendanceByStatus: Record<string, number>;
expenseByType: ExpenseByTypeRow[];
attendanceTrend: AttendanceTrendRow[];
incomeTrend: IncomeTrendRow[];
}
const attendanceLabelMap: Record<string, string> = {
present: '出勤',
absent: '缺勤',
late: '迟到',
early: '早退',
leave: '请假',
};
const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 };
const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 };
// ─── 待办卡片样式 ───
const TODO_CARD_BASE: React.CSSProperties = {
cursor: 'pointer',
transition: 'box-shadow 0.2s, transform 0.2s',
borderRadius: 8,
height: '100%',
};
const TODO_CARD_WARN: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #FF9500',
background: '#fff7e6',
};
const TODO_CARD_DANGER: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #FF3B30',
background: '#fff1f0',
};
const TODO_CARD_OK: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #34C759',
background: '#f0fff4',
};
const TODO_CARD_DRAFT: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #AF52DE',
background: '#f9f0ff',
};
// ─── IntersectionObserver 自定义 hook ───
// 用 callback ref 注册 observer避免元素在首屏 loading 后才挂载、
// 而 effect 因依赖不变不再重跑导致 observer 从未注册的问题。
const useInViewport = (rootMargin = '200px') => {
const [inView, setInView] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const ref = useCallback(
(el: HTMLDivElement | null) => {
observerRef.current?.disconnect();
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ rootMargin },
);
observer.observe(el);
observerRef.current = observer;
},
[rootMargin],
);
return { ref, inView };
};
const DashboardPage: React.FC = () => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [classRanking, setClassRanking] = useState<{
top: ClassAttendanceRank[];
bottom: ClassAttendanceRank[];
}>({ top: [], bottom: [] });
const [classroomOccupancy, setClassroomOccupancy] = useState<ClassroomOccupancy[]>([]);
const [ganttData, setGanttData] = useState<GanttRoom[]>([]);
const [roomRanking, setRoomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]);
const [classroomUtil, setClassroomUtil] = useState<ClassroomUtilStats | null>(null);
const [loading, setLoading] = useState(true);
const [refreshLoading, setRefreshLoading] = useState(false);
const loadedRef = useRef(false);
const [period, setPeriod] = useState<[string, string]>([
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().endOf('month').format('YYYY-MM-DD'),
]);
const fetchData = useCallback(async () => {
const isRefresh = loadedRef.current;
if (isRefresh) {
setRefreshLoading(true);
} else {
setLoading(true);
}
try {
const [s, rr, cr, g, co, cu] = await Promise.all([
api.get<DashboardStats>('/dashboard/stats'),
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>(
'/dashboard/class-attendance-ranking',
),
api.get<GanttRoom[]>('/dashboard/gantt', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy'),
api.get<ClassroomUtilStats>('/dashboard/classroom-utilization'),
]);
setStats(s);
setRoomRanking(rr);
setClassRanking(cr);
setGanttData(g);
setClassroomOccupancy(co);
setClassroomUtil(cu);
loadedRef.current = true;
} catch (e) {
console.error(e);
message.error('数据加载失败,请稍后重试');
}
setLoading(false);
setRefreshLoading(false);
}, [period]);
const {
data: fetchResult = {
stats: null,
classRanking: { top: [], bottom: [] },
classroomOccupancy: [],
ganttData: [],
roomRanking: [],
classroomUtil: null,
},
isLoading,
isFetching,
} = useQuery<{
stats: DashboardStats | null;
classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] };
classroomOccupancy: ClassroomOccupancy[];
ganttData: GanttRoom[];
roomRanking: Array<{ roomNumber: string; total: string }>;
classroomUtil: ClassroomUtilStats | null;
}>({
queryKey: ['dashboard', period],
queryFn: async () => {
try {
const [s, rr, cr, g, co, cu] = await Promise.all([
api.get<DashboardStats>('/dashboard/stats'),
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>(
'/dashboard/class-attendance-ranking',
),
api.get<GanttRoom[]>('/dashboard/gantt', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy'),
api.get<ClassroomUtilStats>('/dashboard/classroom-utilization'),
]);
return {
stats: validateResponse<DashboardStats>(dashboardStatsSchema, s),
roomRanking: validateResponse<Array<{ roomNumber: string; total: string }>>(
roomRankingSchema,
rr,
),
classRanking: validateResponse<{
top: ClassAttendanceRank[];
bottom: ClassAttendanceRank[];
}>(classAttendanceRankingSchema, cr),
ganttData: validateResponse<GanttRoom[]>(ganttRoomsSchema, g),
classroomOccupancy: validateResponse<ClassroomOccupancy[]>(
classroomOccupanciesSchema,
co,
),
classroomUtil: validateResponse<ClassroomUtilStats>(classroomUtilStatsSchema, cu),
};
} catch (e) {
console.error(e);
message.error('数据加载失败,请稍后重试');
return {
stats: null,
classRanking: { top: [], bottom: [] },
classroomOccupancy: [],
ganttData: [],
roomRanking: [],
classroomUtil: null,
};
}
},
});
const stats = fetchResult.stats;
const classRanking = fetchResult.classRanking;
const classroomOccupancy = fetchResult.classroomOccupancy;
const ganttData = fetchResult.ganttData;
const roomRanking = fetchResult.roomRanking;
const classroomUtil = fetchResult.classroomUtil;
const loading = isLoading;
const refreshLoading = isFetching && !isLoading;
useEffect(() => {
fetchData();
}, [fetchData]);
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
useEffect(() => {
api
.get<Array<{ code: string; name: string }>>('/expense-types')
.then((types) => {
const { data: expenseTypeMap = {} } = useQuery<Record<string, string>>({
queryKey: ['expense-types', 'map'],
queryFn: async () => {
try {
const types = validateResponse<Array<{ code: string; name: string }>>(
expenseTypesSchema,
await api.get<Array<{ code: string; name: string }>>('/expense-types'),
);
const map: Record<string, string> = {};
for (const t of types) map[t.code] = t.name;
setExpenseTypeMap(map);
})
.catch(() => {});
}, []);
// ─── 图表 option 计算(保留全部原有逻辑) ───
// 今日出勤状态分布环图
const attendanceRingOption = useMemo<EChartsOption>(
() => ({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({
name: attendanceLabelMap[status] ?? status,
value: count,
})),
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
},
],
color: COLORS,
}),
[stats?.attendanceByStatus],
);
// 宿舍费用排行
const barOption = useMemo<EChartsOption>(
() => ({
tooltip: {},
grid: { left: 80, right: 20, bottom: 30, top: 10 },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: roomRanking.map((r) => r.roomNumber).reverse(),
inverse: false,
},
series: [
{
type: 'bar',
data: roomRanking.map((r) => Number(r.total)).reverse(),
itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },
},
],
}),
[roomRanking],
);
// 班级考勤排行 - 前5
const classRankingTopOption: EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (v: number) => `${v}%`,
return map;
} catch {
return {};
}
},
grid: { left: 80, right: 30, bottom: 30, top: 10 },
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
yAxis: {
type: 'category',
data: classRanking.top.map((r) => r.className),
inverse: true,
},
series: [
{
type: 'bar',
data: classRanking.top.map((r) => r.rate),
itemStyle: { color: '#34C759', borderRadius: [0, 4, 4, 0] },
label: { show: true, position: 'right', formatter: '{c}%' },
},
],
};
});
// 班级考勤排行 - 后5
const classRankingBottomOption: EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (v: number) => `${v}%`,
},
grid: { left: 80, right: 30, bottom: 30, top: 10 },
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
yAxis: {
type: 'category',
data: classRanking.bottom.map((r) => r.className),
inverse: true,
},
series: [
{
type: 'bar',
data: classRanking.bottom.map((r) => r.rate),
itemStyle: { color: '#FF3B30', borderRadius: [0, 4, 4, 0] },
label: { show: true, position: 'right', formatter: '{c}%' },
},
],
};
// 考勤趋势折线图
const attendanceLineOption: EChartsOption = {
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, bottom: 30, top: 10 },
xAxis: {
type: 'category',
data: (stats?.attendanceTrend || []).map((d: { date: string }) => d.date),
axisLabel: { rotate: 45, fontSize: 10 },
},
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
series: [
{
type: 'line',
data: (stats?.attendanceTrend || []).map((d: { rate: string }) => parseFloat(d.rate) || 0),
smooth: true,
lineStyle: { color: '#007AFF', width: 2 },
itemStyle: { color: '#007AFF' },
areaStyle: { color: 'rgba(0,122,255,0.1)' },
},
],
};
// 收入趋势折线图
const incomeLineOption: EChartsOption = {
tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` },
grid: { left: 70, right: 20, bottom: 30, top: 10 },
xAxis: {
type: 'category',
data: (stats?.incomeTrend || []).map((d: { month: string }) => d.month),
},
yAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}` },
},
series: [
{
type: 'line',
data: (stats?.incomeTrend || []).map((d: { amount: number }) => d.amount),
smooth: true,
lineStyle: { color: '#34C759', width: 2 },
itemStyle: { color: '#34C759' },
areaStyle: { color: 'rgba(52,199,89,0.1)' },
},
],
};
// 入住时间线(甘特图)
const ganttOption = useMemo<EChartsOption>(
() => ({
tooltip: {
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
`${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`,
},
grid: { left: 100, right: 30, bottom: 40, top: 20 },
xAxis: { type: 'time' },
yAxis: { type: 'category', data: ganttData.map((r) => r.roomNumber), inverse: true },
dataZoom: [
{ type: 'slider', xAxisIndex: 0, bottom: 10, height: 20 },
{ type: 'inside', xAxisIndex: 0 },
],
series: [
{
type: 'custom',
renderItem: (
_params: unknown,
api: {
value: (i: number) => string | boolean;
coord: (p: [string | number, string | number]) => [number, number];
size: (p: [number, number]) => [number, number];
},
) => {
const [cat, startDate, endDate, isActive] = [
api.value(0),
api.value(1),
api.value(2),
api.value(3),
] as unknown as [string, string, string, boolean];
const start = api.coord([startDate, cat]);
const end = api.coord([endDate, cat]);
const height = api.size([0, 1])[1] * 0.6;
const rectShape = {
x: start[0],
y: start[1] - height / 2,
width: Math.max(end[0] - start[0], 2),
height,
};
return {
type: 'rect' as const,
shape: rectShape,
style: { fill: isActive ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 },
};
},
encode: { x: [1, 2], y: 0 },
data: ganttData.flatMap((r) =>
(r.occupancies || []).map((o) => ({
name: o.studentName,
value: [
r.roomNumber,
o.checkInDate,
o.checkOutDate || new Date().toISOString().slice(0, 10),
!o.checkOutDate,
] as [string, string, string, boolean],
})),
),
},
],
}),
[ganttData],
);
// ─── 懒加载 hooks ───
const classroomHeatmapVp = useInViewport('200px');
const ganttVp = useInViewport('200px');
// ─── 待办卡片数据 ───
const absentCount = stats?.attendanceByStatus?.['absent'] ?? 0;
const attendanceTotal = stats
? Object.values(stats.attendanceByStatus).reduce((sum, n) => sum + Number(n || 0), 0)
: 0;
const presentCount = stats?.attendanceByStatus?.present ?? 0;
const todayAttendanceRate =
stats?.todayAttendanceRate ??
(attendanceTotal > 0 ? ((presentCount / attendanceTotal) * 100).toFixed(1) : '0');
const draftBill = (stats?.billStats ?? []).find((b) => b.status === 'draft');
const draftCount = draftBill ? Number(draftBill.count) : 0;
const draftTotal = draftBill ? Number(draftBill.total) : 0;
@@ -499,118 +192,12 @@ const DashboardPage: React.FC = () => {
</div>
{/* ═══════════ 待办与异常 ═══════════ */}
<Card title="待办与异常" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[16, 16]}>
{/* 今日缺勤 */}
<Col xs={24} sm={8}>
<Card
style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/attendance')}
>
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<ExclamationCircleOutlined
style={{ fontSize: 28, color: absentCount > 0 ? '#FF9500' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: absentCount > 0 ? '#FF9500' : '#999',
}}
>
{absentCount}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div>
{absentCount > 0 ? (
<div style={{ fontSize: 12, color: '#FF9500', marginTop: 4 }}></div>
) : (
<div style={{ fontSize: 12, color: '#34C759', marginTop: 4 }}></div>
)}
</div>
</Card>
</Col>
{/* 待处理账单 */}
<Col xs={24} sm={8}>
<Card
style={draftCount > 0 ? TODO_CARD_DRAFT : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/bills')}
>
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<DollarOutlined
style={{ fontSize: 28, color: draftCount > 0 ? '#AF52DE' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: draftCount > 0 ? '#AF52DE' : '#999',
}}
>
{draftCount}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div>
<div
style={{ fontSize: 12, color: draftCount > 0 ? '#AF52DE' : '#999', marginTop: 4 }}
>
{draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'}
</div>
</div>
</Card>
</Col>
{/* 待退押金 */}
<Col xs={24} sm={8}>
<Card
style={pendingDeposits > 0 ? TODO_CARD_DANGER : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/deposits')}
>
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<BankOutlined
style={{ fontSize: 28, color: pendingDeposits > 0 ? '#FF3B30' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
}}
>
¥{pendingDeposits.toLocaleString()}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}>退</div>
<div
style={{
fontSize: 12,
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
marginTop: 4,
}}
>
{pendingDeposits > 0 ? '需要处理' : '暂无待退'}
</div>
</div>
</Card>
</Col>
</Row>
</Card>
<DashboardTodoCards
absentCount={absentCount}
draftCount={draftCount}
draftTotal={draftTotal}
pendingDeposits={pendingDeposits}
/>
{/* ═══════════ 核心 KPI ═══════════ */}
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
@@ -638,7 +225,7 @@ const DashboardPage: React.FC = () => {
<Card>
<Statistic
title="今日出勤率"
value={stats?.todayAttendanceRate || 0}
value={todayAttendanceRate}
suffix="%"
prefix={<UserSwitchOutlined />}
/>
@@ -809,7 +396,7 @@ const DashboardPage: React.FC = () => {
<Card title="考勤趋势近30天">
{(stats?.attendanceTrend || []).length > 0 ? (
<ReactECharts
option={attendanceLineOption}
option={buildAttendanceLineOption(stats?.attendanceTrend ?? [])}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -821,7 +408,7 @@ const DashboardPage: React.FC = () => {
<Card title="今日出勤状态分布">
{Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? (
<ReactECharts
option={attendanceRingOption}
option={buildAttendanceRingOption(stats)}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -837,7 +424,7 @@ const DashboardPage: React.FC = () => {
<Card title="班级出勤率 TOP 5">
{classRanking.top.length > 0 ? (
<ReactECharts
option={classRankingTopOption}
option={buildClassRankingOption(classRanking.top, '#34C759')}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -849,7 +436,7 @@ const DashboardPage: React.FC = () => {
<Card title="班级出勤率 末位 5">
{classRanking.bottom.length > 0 ? (
<ReactECharts
option={classRankingBottomOption}
option={buildClassRankingOption(classRanking.bottom, '#FF3B30')}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -865,24 +452,7 @@ const DashboardPage: React.FC = () => {
<Card title="费用类型分布">
{(stats?.expenseByType ?? []).length > 0 ? (
<ReactECharts
option={
{
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
color: COLORS,
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
data: (stats?.expenseByType ?? []).map((e) => ({
name: expenseTypeMap[e.type] ?? e.type,
value: Number(e.total),
})),
},
],
} satisfies EChartsOption
}
option={buildExpensePieOption(stats?.expenseByType ?? [], expenseTypeMap)}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -894,7 +464,7 @@ const DashboardPage: React.FC = () => {
<Card title="宿舍费用排行 TOP 20">
{roomRanking.length > 0 ? (
<ReactECharts
option={barOption}
option={buildRoomRankingBarOption(roomRanking)}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -910,7 +480,7 @@ const DashboardPage: React.FC = () => {
<Card title="月度收入趋势">
{(stats?.incomeTrend || []).length > 0 ? (
<ReactECharts
option={incomeLineOption}
option={buildIncomeLineOption(stats?.incomeTrend ?? [])}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -921,102 +491,10 @@ const DashboardPage: React.FC = () => {
</Row>
{/* ═══════════ 图表:教室占用热力图(懒加载) ═══════════ */}
<div ref={classroomHeatmapVp.ref} style={SECTION_ROW_STYLE}>
{classroomHeatmapVp.inView ? (
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card title="教室占用热力图">
{classroomOccupancy.length > 0 ? (
<ReactECharts
option={
{
tooltip: {
formatter: (p: {
name: string;
data: { scheduleDays: number; rentalCount: number; occupancy: number };
}) =>
`${p.name}<br/>排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`,
},
grid: { left: 100, right: 20, bottom: 30, top: 10 },
xAxis: { type: 'value', max: 1 },
yAxis: {
type: 'category',
data: classroomOccupancy.map((r) => r.name),
inverse: true,
},
visualMap: {
min: 0,
max: 1,
orient: 'horizontal',
left: 'center',
bottom: 0,
inRange: {
color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'],
},
},
series: [
{
type: 'bar',
data: classroomOccupancy.map((r) => ({
name: r.name,
value: r.occupancy,
scheduleDays: r.scheduleDays,
rentalCount: r.rentalCount,
occupancy: r.occupancy,
})),
itemStyle: { borderRadius: [0, 4, 4, 0] },
label: {
show: true,
position: 'right',
formatter: (p: { data: { occupancy: number } }) =>
`${(p.data.occupancy * 100).toFixed(0)}%`,
},
},
],
} satisfies EChartsOption
}
style={{ width: '100%', height: isMobile ? 300 : 400 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
)}
</Card>
</Col>
</Row>
) : (
<Card title="教室占用热力图" style={{ minHeight: isMobile ? 340 : 440 }}>
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
</Card>
)}
</div>
<ClassroomHeatmapCard data={classroomOccupancy} isMobile={isMobile} />
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
<div ref={ganttVp.ref}>
{ganttVp.inView ? (
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card title="入住时间线(甘特图)">
{ganttData.length > 0 ? (
<ReactECharts
option={ganttOption}
style={{ width: '100%', height: isMobile ? 300 : 450 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
)}
</Card>
</Col>
</Row>
) : (
<Card title="入住时间线(甘特图)" style={{ minHeight: isMobile ? 340 : 490 }}>
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
</Card>
)}
</div>
<GanttCard data={ganttData} isMobile={isMobile} />
</div>
);
};

View File

@@ -0,0 +1,423 @@
import React from 'react';
import {
Card,
DatePicker,
Empty,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
} from 'antd';
import { DollarOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import type { DepositStudentLookup } from './deposit-student-option';
export interface DepositRecord {
id: number;
studentId: number;
amount: number;
status: string;
paidDate: string;
refundDate?: string | null;
notes?: string | null;
installments?: Array<{
id: number;
amount: number;
dueDate: string;
paidDate?: string | null;
status: string;
}>;
student?: DepositStudentLookup;
}
export interface EligibleStudent {
studentId: number;
studentName: string;
studentNo?: string | null;
roomId: number;
roomNumber: string;
building?: string | null;
roomType?: string | null;
capacity: number;
depositAmount: number;
}
export const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '有余额', color: 'green' },
refunded: { text: '已全退', color: 'blue' },
depleted: { text: '已扣完', color: 'red' },
};
export const installmentStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待缴', color: 'orange' },
paid: { text: '已缴', color: 'green' },
};
export const roomTypeOptions = [
{ value: '单人间', label: '单人间' },
{ value: '四人间', label: '四人间' },
];
export const suggestedDepositByRoomType: Record<string, number> = {
单人间: 200,
四人间: 100,
};
export interface DepositModalsProps {
batchModal: boolean;
createModal: boolean;
refundModal: DepositRecord | null;
detailModal: DepositRecord | null;
installmentModal: number | null;
batchForm: ReturnType<typeof Form.useForm>[0];
createForm: ReturnType<typeof Form.useForm>[0];
refundForm: ReturnType<typeof Form.useForm>[0];
installmentForm: ReturnType<typeof Form.useForm>[0];
saving: boolean;
batchRoomType: string;
effectiveSelectedEligibleIds: number[];
eligibleStudents: EligibleStudent[];
eligibleLoading: boolean;
eligibleColumns: Array<{ title: string; render?: unknown; dataIndex?: string }>;
studentOptions: Array<{ value: number; label: string }>;
onBatchRoomTypeChange: (roomType: string) => void;
onBatchCreate: () => void;
onCreate: () => void;
onRefund: () => void;
onAddInstallment: () => void;
onPayInstallment: (installmentId: number) => void;
onSaveInstallmentCell: (
installmentId: number,
field: 'status' | 'paidDate',
value: unknown,
) => void;
onDeleteInstallment: (installmentId: number) => void;
onCloseBatch: () => void;
onCloseCreate: () => void;
onCloseRefund: () => void;
onCloseDetail: () => void;
onCloseInstallment: () => void;
onOpenInstallment: (id: number) => void;
onSelectEligible: (ids: number[]) => void;
}
export const DepositModals: React.FC<DepositModalsProps> = ({
batchModal,
createModal,
refundModal,
detailModal,
installmentModal,
batchForm,
createForm,
refundForm,
installmentForm,
saving,
batchRoomType,
effectiveSelectedEligibleIds,
eligibleStudents,
eligibleLoading,
eligibleColumns,
studentOptions,
onBatchRoomTypeChange,
onBatchCreate,
onCreate,
onRefund,
onAddInstallment,
onPayInstallment,
onSaveInstallmentCell,
onDeleteInstallment,
onCloseBatch,
onCloseCreate,
onCloseRefund,
onCloseDetail,
onCloseInstallment,
onOpenInstallment,
onSelectEligible,
}) => {
return (
<>
<Modal
title="按房型批量收取押金"
open={batchModal}
onOk={onBatchCreate}
onCancel={onCloseBatch}
okText="确认批量收取"
confirmLoading={saving}
okButtonProps={{ disabled: effectiveSelectedEligibleIds.length === 0 }}
width={760}
>
<Form form={batchForm} layout="vertical">
<Space style={{ width: '100%' }} align="start" wrap>
<Form.Item
name="roomType"
label="房型"
rules={[{ required: true, message: '请选择房型' }]}
>
<Select
style={{ width: 140 }}
options={roomTypeOptions}
onChange={onBatchRoomTypeChange}
/>
</Form.Item>
<Form.Item
name="amount"
label="每人收取金额(元)"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: 180 }} />
</Form.Item>
<Form.Item
name="paidDate"
label="收取日期"
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker style={{ width: 180 }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
</Space>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder={`${batchRoomType}押金`} />
</Form.Item>
</Form>
<div style={{ marginBottom: 8 }}>
<strong>{effectiveSelectedEligibleIds.length}</strong> / {eligibleStudents.length}{' '}
{suggestedDepositByRoomType[batchRoomType] && (
<span style={{ color: '#999', marginLeft: 8 }}>
¥{suggestedDepositByRoomType[batchRoomType]}
</span>
)}
</div>
<Table
size="small"
columns={eligibleColumns as never}
dataSource={eligibleStudents}
rowKey="studentId"
loading={eligibleLoading}
locale={{ emptyText: <Empty description="暂无符合条件的在住人员" /> }}
pagination={{ pageSize: 6, showSizeChanger: false }}
rowSelection={{
selectedRowKeys: effectiveSelectedEligibleIds,
onChange: (keys) => onSelectEligible(keys as number[]),
}}
/>
</Modal>
<Modal
title="收取押金"
open={createModal}
onOk={onCreate}
onCancel={onCloseCreate}
okText="确认"
confirmLoading={saving}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="studentId"
label="学生"
rules={[{ required: true, message: '请选择学生' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="搜索并选择学生"
options={studentOptions}
/>
</Form.Item>
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
<Modal
title={`退还押金 - ${refundModal?.student?.name}`}
open={!!refundModal}
onOk={onRefund}
onCancel={onCloseRefund}
okText="确认退还"
confirmLoading={saving}
>
<Form form={refundForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
</div>
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
<Modal
title={`押金详情 - ${detailModal?.student?.name}`}
open={!!detailModal}
onCancel={onCloseDetail}
footer={null}
width={640}
>
{detailModal && (
<div>
<Card size="small" style={{ marginBottom: 16 }}>
<p>
<strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}
</p>
<p>
<strong>:</strong> {detailModal.paidDate}
</p>
<p>
<strong>:</strong>{' '}
<Tag color={statusMap[detailModal.status]?.color}>
{statusMap[detailModal.status]?.text || detailModal.status}
</Tag>
</p>
{detailModal.notes && (
<p>
<strong>:</strong> {detailModal.notes}
</p>
)}
</Card>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}
>
<h4 style={{ margin: 0 }}></h4>
<PermissionButton
permission="deposit:edit"
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => onOpenInstallment(detailModal.id)}
>
</PermissionButton>
</div>
{detailModal.installments && detailModal.installments.length > 0 ? (
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={detailModal.installments}
columns={[
{
title: '金额',
dataIndex: 'amount',
render: (value: number) => `¥${value.toFixed(2)}`,
},
{ title: '到期日', dataIndex: 'dueDate' },
{
title: '实付日',
dataIndex: 'paidDate',
render: (value: string, item: any) => (
<EditableCell
value={value}
editor="date"
permission="deposit:edit"
onSave={async (next) =>
onSaveInstallmentCell(item.id, 'paidDate', next)
}
>
{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={async (next) =>
onSaveInstallmentCell(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={() => onPayInstallment(item.id)}
>
</PermissionButton>
)}
<Popconfirm
title="确定归档?"
onConfirm={() => onDeleteInstallment(item.id)}
>
<PermissionButton
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
),
},
]}
/>
) : (
<p style={{ color: '#999' }}></p>
)}
</div>
)}
</Modal>
<Modal
title="添加分期"
open={installmentModal != null}
onOk={onAddInstallment}
onCancel={onCloseInstallment}
okText="确认"
>
<Form form={installmentForm} layout="vertical">
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</>
);
};

View File

@@ -0,0 +1,148 @@
import React from 'react';
import { Button, Empty, Popconfirm, Space, Table, Tag } from 'antd';
import { DeleteOutlined, InboxOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { statusMap } from './DepositModals';
import type { DepositRecord } from './DepositModals';
export interface DepositTableProps {
data: any[];
loading: boolean;
canPurgeDeposit: boolean;
refundForm: ReturnType<typeof import('antd').Form.useForm>[0];
onDetail: (record: DepositRecord) => void;
onRefund: (record: DepositRecord) => void;
onArchive: (id: number) => Promise<unknown> | unknown;
onPurge: (id: number) => Promise<unknown> | unknown;
}
export const DepositTable: React.FC<DepositTableProps> = ({
data,
loading,
canPurgeDeposit,
refundForm,
onDetail,
onRefund,
onArchive,
onPurge,
}) => {
const columns = [
{ title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' },
{
title: '当前可用押金',
dataIndex: 'amount',
width: 130,
render: (v: number) => `¥${Number(v || 0).toFixed(2)}`,
},
{
title: '房间',
width: 120,
render: (_: unknown, r: any) =>
r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-',
},
{ title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' },
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' },
{
title: '状态',
dataIndex: 'status',
render: (s: string) =>
s === 'unpaid' ? (
<Tag color="default"></Tag>
) : (
<Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>
),
},
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' },
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
{
title: '操作',
width: 240,
render: (_: unknown, record: any) => {
const hasDeposit = typeof record.id === 'number';
return (
<Space>
{hasDeposit && (
<PermissionButton permission="deposit:view" size="small" onClick={() => onDetail(record)}>
</PermissionButton>
)}
{record.status === 'paid' && hasDeposit && (
<PermissionButton
permission="deposit:refund"
size="small"
type="primary"
onClick={() => {
onRefund(record);
refundForm.setFieldsValue({ refundDate: dayjs() });
}}
>
退
</PermissionButton>
)}
{hasDeposit && (
<Popconfirm
title="确定归档?"
onConfirm={async () => {
try {
await onArchive(record.id);
message.success('归档成功');
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
>
<PermissionButton
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
{record.status === 'archived' && hasDeposit && canPurgeDeposit ? (
<Popconfirm
title="确定永久删除该押金记录?"
description="删除后不可恢复,分期记录将一并清除。"
okText="永久删除"
okButtonProps={{ danger: true }}
onConfirm={async () => {
try {
await onPurge(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
>
<Button size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
) : null}
</Space>
);
},
},
];
return (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>
);
};

View File

@@ -1,89 +1,41 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useCallback, useMemo, useState } from 'react';
import {
Table,
Modal,
Form,
Select,
DatePicker,
InputNumber,
Input,
Select,
Space,
Tag,
Popconfirm,
Card,
Empty,
} from 'antd';
import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-design/icons';
import { PlusOutlined, TeamOutlined } from '@ant-design/icons';
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';
const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '有余额', color: 'green' },
refunded: { text: '已全退', color: 'blue' },
depleted: { text: '已扣完', color: 'red' },
};
const installmentStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待缴', color: 'orange' },
paid: { text: '已缴', color: 'green' },
};
const roomTypeOptions = [
{ value: '单人间', label: '单人间' },
{ value: '四人间', label: '四人间' },
];
const suggestedDepositByRoomType: Record<string, number> = {
单人间: 200,
四人间: 100,
};
interface DepositRecord {
id: number;
studentId: number;
amount: number;
status: string;
paidDate: string;
refundDate?: string | null;
notes?: string | null;
installments?: Array<{
id: number;
amount: number;
dueDate: string;
paidDate?: string | null;
status: string;
}>;
student?: DepositStudentLookup;
}
interface EligibleStudent {
studentId: number;
studentName: string;
studentNo?: string | null;
roomId: number;
roomNumber: string;
building?: string | null;
roomType?: string | null;
capacity: number;
depositAmount: number;
}
const isFormValidationError = (error: unknown) =>
typeof error === 'object' &&
error !== null &&
Array.isArray((error as { errorFields?: unknown }).errorFields);
import { usePermission } from '../../hooks/usePermission';
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import {
depositStudentLookupsSchema,
depositsSchema,
eligibleStudentsSchema,
} from '../../api/schemas';
import {
DepositModals,
roomTypeOptions,
suggestedDepositByRoomType,
} from './DepositModals';
import type { DepositRecord, EligibleStudent } from './DepositModals';
import { DepositTable } from './DepositTable';
const DepositsPage: React.FC = () => {
const [data, setData] = useState<DepositRecord[]>([]);
const [students, setStudents] = useState<DepositStudentLookup[]>([]);
const [eligibleStudents, setEligibleStudents] = useState<EligibleStudent[]>([]);
const { hasPermission } = usePermission();
const canPurgeDeposit = hasPermission('deposit:purge');
const [selectedEligibleStudentIds, setSelectedEligibleStudentIds] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [eligibleLoading, setEligibleLoading] = useState(false);
const [selectionTouched, setSelectionTouched] = useState(false);
const [eligibleRoomType, setEligibleRoomType] = useState<string | undefined>(undefined);
const queryClient = useQueryClient();
const [createModal, setCreateModal] = useState(false);
const [batchModal, setBatchModal] = useState(false);
const [refundModal, setRefundModal] = useState<DepositRecord | null>(null);
@@ -99,47 +51,115 @@ const DepositsPage: React.FC = () => {
const [batchRoomType, setBatchRoomType] = useState<string>('四人间');
const [saving, setSaving] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [d, s] = await Promise.all([
api.get<DepositRecord[]>('/deposits'),
api.get<DepositStudentLookup[]>('/deposits/student-lookups'),
]);
setData(d);
setStudents(s);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
} finally {
setLoading(false);
}
}, []);
const {
data: fetchResult = { data: [], students: [] },
isLoading,
isFetching,
} = useQuery<{ data: DepositRecord[]; students: DepositStudentLookup[] }>({
queryKey: ['deposits'],
queryFn: async () => {
try {
const [d, s] = await Promise.all([
api.get<DepositRecord[]>('/deposits'),
api.get<DepositStudentLookup[]>('/deposits/student-lookups'),
]);
return {
data: validateResponse<DepositRecord[]>(depositsSchema, d),
students: validateResponse<DepositStudentLookup[]>(depositStudentLookupsSchema, s),
};
} catch (e: any) {
message.error(e?.message || '加载失败');
return { data: [], students: [] };
}
},
});
const data = fetchResult.data;
const students = fetchResult.students;
const loading = isLoading || isFetching;
const fetchEligibleStudents = useCallback(async (roomType?: string) => {
setEligibleLoading(true);
try {
const params = roomType ? `?roomType=${encodeURIComponent(roomType)}` : '';
const rows = await api.get<EligibleStudent[]>(`/deposits/eligible-students${params}`);
setEligibleStudents(rows);
setSelectedEligibleStudentIds(rows.map((item) => item.studentId));
} catch (e: any) {
message.error(e?.message || '加载在住人员失败');
} finally {
setEligibleLoading(false);
}
}, []);
const invalidateDeposits: QueryKey[] = [['deposits'], ['deposits', 'eligible']];
const createMutation = useApiMutation(
async (payload: Record<string, unknown>) => api.post('/deposits', payload),
{ invalidate: invalidateDeposits },
);
const batchCreateMutation = useApiMutation(
async (payload: Record<string, unknown>) => api.post('/deposits/batch', payload),
{ invalidate: invalidateDeposits },
);
const refundMutation = useApiMutation(
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
api.put(`/deposits/${id}/refund`, payload),
{ invalidate: invalidateDeposits },
);
const addInstallmentMutation = useApiMutation(
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
api.post(`/deposits/${id}/installments`, payload),
{ invalidate: [['deposits']] },
);
const payInstallmentMutation = useApiMutation(
async (installmentId: number) => api.post(`/deposits/installments/${installmentId}/pay`),
{ invalidate: [['deposits']] },
);
const saveInstallmentCellMutation = useApiMutation(
async ({
installmentId,
field,
value,
}: {
installmentId: number;
field: 'status' | 'paidDate';
value: unknown;
}) => api.put(`/deposits/installments/${installmentId}`, { [field]: value }),
{ invalidate: [['deposits']] },
);
const deleteInstallmentMutation = useApiMutation(
async (installmentId: number) => api.delete(`/deposits/installments/${installmentId}`),
{ invalidate: [['deposits']] },
);
const archiveMutation = useApiMutation(
async (id: number) => api.delete(`/deposits/${id}`),
{ invalidate: invalidateDeposits },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/deposits/${id}/permanent`),
{ invalidate: invalidateDeposits },
);
useEffect(() => {
fetchData();
}, [fetchData]);
const {
data: eligibleStudents = [],
isFetching: eligibleFetching,
} = useQuery<EligibleStudent[]>({
queryKey: ['deposits', 'eligible', eligibleRoomType],
queryFn: async () => {
const params: Record<string, string> = {};
if (eligibleRoomType) params.roomType = eligibleRoomType;
return validateResponse<EligibleStudent[]>(
eligibleStudentsSchema,
await api.get('/deposits/eligible', { params }),
);
},
});
const eligibleLoading = eligibleFetching;
const effectiveSelectedEligibleIds = selectionTouched
? selectedEligibleStudentIds
: eligibleStudents.map((item) => item.studentId);
const fetchEligibleStudents = useCallback(
(roomType?: string) => {
setEligibleRoomType(roomType);
queryClient.invalidateQueries({ queryKey: ['deposits', 'eligible'] });
},
[queryClient],
);
useEffect(() => {
fetchEligibleStudents(filterRoomType);
}, [fetchEligibleStudents, filterRoomType]);
const changeFilterRoomType = (value: string | undefined) => {
setFilterRoomType(value);
setSelectionTouched(false);
fetchEligibleStudents(value);
};
const depositByStudentId = useMemo(() => {
const map = new Map<number, DepositRecord>();
data.forEach((item) => map.set(item.studentId, item));
for (const item of data) map.set(item.studentId, item);
return map;
}, [data]);
@@ -176,7 +196,6 @@ const DepositsPage: React.FC = () => {
};
});
}
return data.filter((d) => {
if (searchText) {
const s = searchText.toLowerCase();
@@ -192,10 +211,11 @@ const DepositsPage: React.FC = () => {
const openBatchModal = (roomType = filterRoomType || '四人间') => {
const amount = suggestedDepositByRoomType[roomType] ?? 100;
setBatchRoomType(roomType);
setSelectionTouched(false);
fetchEligibleStudents(roomType);
batchForm.resetFields();
batchForm.setFieldsValue({ roomType, amount, paidDate: dayjs() });
setBatchModal(true);
fetchEligibleStudents(roomType);
};
const handleBatchRoomTypeChange = (roomType: string) => {
@@ -207,55 +227,38 @@ const DepositsPage: React.FC = () => {
};
const handleCreate = async () => {
setSaving(true);
try {
const values = await createForm.validateFields();
await api.post('/deposits', {
await createMutation.mutateAsync({
studentId: values.studentId,
amount: values.amount,
paidDate: values.paidDate.format('YYYY-MM-DD'),
notes: values.notes,
});
message.success('押金金额已增加');
message.success('押金收取成功');
setCreateModal(false);
createForm.resetFields();
fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleBatchCreate = async () => {
if (selectedEligibleStudentIds.length === 0) {
message.warning('请选择至少一名学生');
return;
}
setSaving(true);
try {
const values = await batchForm.validateFields();
await api.post('/deposits/batch', {
studentIds: selectedEligibleStudentIds,
await batchCreateMutation.mutateAsync({
studentIds: effectiveSelectedEligibleIds,
amount: values.amount,
paidDate: values.paidDate.format('YYYY-MM-DD'),
notes: values.notes,
roomType: values.roomType,
});
message.success(`已为 ${selectedEligibleStudentIds.length} 人批量收取押金`);
message.success('批量收取成功');
setBatchModal(false);
batchForm.resetFields();
await fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
setSelectionTouched(false);
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
@@ -264,19 +267,18 @@ const DepositsPage: React.FC = () => {
setSaving(true);
try {
const values = await refundForm.validateFields();
await api.put(`/deposits/${refundModal.id}/refund`, {
refundDate: values.refundDate.format('YYYY-MM-DD'),
notes: values.notes,
await refundMutation.mutateAsync({
id: refundModal.id,
payload: {
refundDate: values.refundDate.format('YYYY-MM-DD'),
notes: values.notes,
},
});
message.success('退还操作完成');
setRefundModal(null);
refundForm.resetFields();
fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -286,31 +288,27 @@ const DepositsPage: React.FC = () => {
if (installmentModal == null) return;
try {
const values = await installmentForm.validateFields();
await api.post(`/deposits/${installmentModal}/installments`, {
amount: values.amount,
dueDate: values.dueDate.format('YYYY-MM-DD'),
await addInstallmentMutation.mutateAsync({
id: installmentModal,
payload: {
amount: values.amount,
dueDate: values.dueDate.format('YYYY-MM-DD'),
},
});
message.success('分期已添加');
setInstallmentModal(null);
installmentForm.resetFields();
fetchData();
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePayInstallment = async (installmentId: number) => {
try {
await api.put(`/deposits/installments/${installmentId}`, {
paidDate: dayjs().format('YYYY-MM-DD'),
status: 'paid',
});
await payInstallmentMutation.mutateAsync(installmentId);
message.success('分期已标记为已缴');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
@@ -319,117 +317,27 @@ const DepositsPage: React.FC = () => {
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);
try {
await saveInstallmentCellMutation.mutateAsync({ installmentId, field, value });
message.success('分期记录已保存');
if (detailModal) {
const refreshed = await api.get<DepositRecord>(`/deposits/${detailModal.id}`);
setDetailModal(refreshed);
}
} catch {
// 错误提示由 useApiMutation 统一处理
}
await fetchData();
};
const handleDeleteInstallment = async (installmentId: number) => {
try {
await api.delete(`/deposits/installments/${installmentId}`);
await deleteInstallmentMutation.mutateAsync(installmentId);
message.success('分期已归档');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const columns = useMemo(
() => [
{ title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' },
{
title: '当前可用押金',
dataIndex: 'amount',
width: 130,
render: (v: number) => `¥${Number(v || 0).toFixed(2)}`,
},
{
title: '房间',
width: 120,
render: (_: unknown, r: any) =>
r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-',
},
{ title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' },
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' },
{
title: '状态',
dataIndex: 'status',
render: (s: string) =>
s === 'unpaid' ? (
<Tag color="default"></Tag>
) : (
<Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>
),
},
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' },
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
{
title: '操作',
width: 240,
render: (_: unknown, record: any) => {
const hasDeposit = typeof record.id === 'number';
return (
<Space>
{hasDeposit && (
<PermissionButton
permission="deposit:view"
size="small"
onClick={() => {
setDetailModal(record);
}}
>
</PermissionButton>
)}
{record.status === 'paid' && hasDeposit && (
<PermissionButton
permission="deposit:refund"
size="small"
type="primary"
onClick={() => {
setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs() });
}}
>
退
</PermissionButton>
)}
{hasDeposit && (
<Popconfirm
title="确定归档?"
onConfirm={async () => {
try {
await api.delete(`/deposits/${record.id}`);
message.success('归档成功');
fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
message.error(e?.message || '归档失败');
}
}}
>
<PermissionButton
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
);
},
},
],
[fetchData, fetchEligibleStudents, filterRoomType, refundForm],
);
const eligibleColumns = [
{
title: '学生',
@@ -475,7 +383,7 @@ const DepositsPage: React.FC = () => {
allowClear
style={{ width: 130 }}
value={filterRoomType}
onChange={(v) => setFilterRoomType(v)}
onChange={changeFilterRoomType}
options={roomTypeOptions}
/>
<Select
@@ -514,301 +422,55 @@ const DepositsPage: React.FC = () => {
</PermissionButton>
</Space>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
<DepositTable
data={filteredData}
loading={loading || (!!filterRoomType && eligibleLoading)}
scroll={{ x: 1200 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
canPurgeDeposit={canPurgeDeposit}
refundForm={refundForm}
onDetail={(record) => setDetailModal(record)}
onRefund={(record) => setRefundModal(record)}
onArchive={(id) => archiveMutation.mutateAsync(id)}
onPurge={(id) => purgeMutation.mutateAsync(id)}
/>
<DepositModals
batchModal={batchModal}
createModal={createModal}
refundModal={refundModal}
detailModal={detailModal}
installmentModal={installmentModal}
batchForm={batchForm}
createForm={createForm}
refundForm={refundForm}
installmentForm={installmentForm}
saving={saving}
batchRoomType={batchRoomType}
effectiveSelectedEligibleIds={effectiveSelectedEligibleIds}
eligibleStudents={eligibleStudents}
eligibleLoading={eligibleLoading}
eligibleColumns={eligibleColumns}
studentOptions={studentOptions}
onBatchRoomTypeChange={handleBatchRoomTypeChange}
onBatchCreate={handleBatchCreate}
onCreate={handleCreate}
onRefund={handleRefund}
onAddInstallment={handleAddInstallment}
onPayInstallment={handlePayInstallment}
onSaveInstallmentCell={saveInstallmentCell}
onDeleteInstallment={handleDeleteInstallment}
onCloseBatch={() => setBatchModal(false)}
onCloseCreate={() => setCreateModal(false)}
onCloseRefund={() => setRefundModal(null)}
onCloseDetail={() => setDetailModal(null)}
onCloseInstallment={() => setInstallmentModal(null)}
onOpenInstallment={(id) => {
setInstallmentModal(id);
installmentForm.resetFields();
}}
onSelectEligible={(ids) => {
setSelectedEligibleStudentIds(ids);
setSelectionTouched(true);
}}
/>
{/* Batch Create Modal */}
<Modal
title="按房型批量收取押金"
open={batchModal}
onOk={handleBatchCreate}
onCancel={() => setBatchModal(false)}
okText="确认批量收取"
confirmLoading={saving}
okButtonProps={{ disabled: selectedEligibleStudentIds.length === 0 }}
width={760}
>
<Form form={batchForm} layout="vertical">
<Space style={{ width: '100%' }} align="start" wrap>
<Form.Item
name="roomType"
label="房型"
rules={[{ required: true, message: '请选择房型' }]}
>
<Select
style={{ width: 140 }}
options={roomTypeOptions}
onChange={handleBatchRoomTypeChange}
/>
</Form.Item>
<Form.Item
name="amount"
label="每人收取金额(元)"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: 180 }} />
</Form.Item>
<Form.Item
name="paidDate"
label="收取日期"
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker style={{ width: 180 }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
</Space>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder={`${batchRoomType}押金`} />
</Form.Item>
</Form>
<div style={{ marginBottom: 8 }}>
<strong>{selectedEligibleStudentIds.length}</strong> / {eligibleStudents.length}
{suggestedDepositByRoomType[batchRoomType] && (
<span style={{ color: '#999', marginLeft: 8 }}>
¥{suggestedDepositByRoomType[batchRoomType]}
</span>
)}
</div>
<Table
size="small"
columns={eligibleColumns}
dataSource={eligibleStudents}
rowKey="studentId"
loading={eligibleLoading}
locale={{ emptyText: <Empty description="暂无符合条件的在住人员" /> }}
pagination={{ pageSize: 6, showSizeChanger: false }}
rowSelection={{
selectedRowKeys: selectedEligibleStudentIds,
onChange: (keys) => setSelectedEligibleStudentIds(keys as number[]),
}}
/>
</Modal>
{/* Create Modal */}
<Modal
title="收取押金"
open={createModal}
onOk={handleCreate}
onCancel={() => setCreateModal(false)}
okText="确认"
confirmLoading={saving}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="studentId"
label="学生"
rules={[{ required: true, message: '请选择学生' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="搜索并选择学生"
options={studentOptions}
/>
</Form.Item>
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{/* Refund Modal */}
<Modal
title={`退还押金 - ${refundModal?.student?.name}`}
open={!!refundModal}
onOk={handleRefund}
onCancel={() => setRefundModal(null)}
okText="确认退还"
confirmLoading={saving}
>
<Form form={refundForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
</div>
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{/* Detail Modal */}
<Modal
title={`押金详情 - ${detailModal?.student?.name}`}
open={!!detailModal}
onCancel={() => setDetailModal(null)}
footer={null}
width={640}
>
{detailModal && (
<div>
<Card size="small" style={{ marginBottom: 16 }}>
<p>
<strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}
</p>
<p>
<strong>:</strong> {detailModal.paidDate}
</p>
<p>
<strong>:</strong>{' '}
<Tag color={statusMap[detailModal.status]?.color}>
{statusMap[detailModal.status]?.text || detailModal.status}
</Tag>
</p>
{detailModal.notes && (
<p>
<strong>:</strong> {detailModal.notes}
</p>
)}
</Card>
{/* Installments Section */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}
>
<h4 style={{ margin: 0 }}></h4>
<PermissionButton
permission="deposit:edit"
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setInstallmentModal(detailModal.id);
installmentForm.resetFields();
}}
>
</PermissionButton>
</div>
{detailModal.installments && detailModal.installments.length > 0 ? (
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={detailModal.installments}
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)}
>
{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
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
),
},
]}
/>
) : (
<p style={{ color: '#999' }}></p>
)}
</div>
)}
</Modal>
{/* Add Installment Modal */}
<Modal
title="添加分期"
open={installmentModal != null}
onOk={handleAddInstallment}
onCancel={() => setInstallmentModal(null)}
okText="确认"
>
<Form form={installmentForm} layout="vertical">
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</div>
);
};

View File

@@ -1,8 +1,6 @@
import React from 'react';
import { DatePicker, Form, Input, Modal, Select } from 'antd';
import type { FormInstance } from 'antd';
import type { ClassOption, ExamFormValues } from './types';
import { EXAM_TYPE_OPTIONS } from './types';
import { DatePicker, Form, Input, Modal, Select, type FormInstance } from 'antd';
import { EXAM_TYPE_OPTIONS, type ClassOption, type ExamFormValues } from './types';
interface Props {
open: boolean;
@@ -32,24 +30,42 @@ const ExamFormModal: React.FC<Props> = ({
width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="examType" label="考试类型" rules={[{ required: true, message: '请选择考试类型' }]}>
<Form.Item
name="examType"
label="考试类型"
rules={[{ required: true, message: '请选择考试类型' }]}
>
<Select options={EXAM_TYPE_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item name="examName" label="考试名称" rules={[{ required: true, message: '请输入考试名称' }]}>
<Form.Item
name="examName"
label="考试名称"
rules={[{ required: true, message: '请输入考试名称' }]}
>
<Input placeholder="如2026 年 7 月月考" />
</Form.Item>
<Form.Item name="subject" label="科目" rules={[{ required: true, message: '请输入科目' }]}>
<Input placeholder="如:数学" />
</Form.Item>
<Form.Item name="examDate" label="考试日期" rules={[{ required: true, message: '请选择考试日期' }]}>
<Form.Item
name="examDate"
label="考试日期"
rules={[{ required: true, message: '请选择考试日期' }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="classId" label="考试班级" rules={[{ required: true, message: '请选择考试班级' }]}>
<Form.Item
name="classId"
label="考试班级"
rules={[{ required: true, message: '请选择考试班级' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择在读班级"
options={classes.filter((item) => !item.isArchived).map((item) => ({ value: item.id, label: item.name }))}
options={classes
.filter((item) => !item.isArchived)
.map((item) => ({ value: item.id, label: item.name }))}
/>
</Form.Item>
</Form>

View File

@@ -1,16 +1,21 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import { Alert, Button, Card, Descriptions, Empty, Space, Spin, Table, Tag, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router';
import api from '../../api';
import EditableCell from '../../components/EditableCell';
import { useViewSensitive } from '../../hooks/useViewSensitive';
import { maskPhone } from '../../utils/sensitive';
import { message } from '../../ui/app-message';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { examDetailSchema } from '../../api/schemas';
import { usePermission } from '../../hooks/usePermission';
import type { ExamItem } from './types';
import './style.css';
import { getErrorMessage } from '../../utils/error';
interface ScoreRow {
id: number;
@@ -50,30 +55,38 @@ const PhoneCell: React.FC<{ row: ScoreRow }> = ({ row }) => {
const ExamDetailPage: React.FC = () => {
const { id } = useParams();
const navigate = useNavigate();
const [detail, setDetail] = useState<ExamDetail | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
setDetail(await api.get<ExamDetail>(`/exams/${id}`));
} catch (error) {
message.error((error as { message?: string })?.message || '加载考试失败');
} finally {
setLoading(false);
}
}, [id]);
const { data: detail, isLoading, isFetching } = useQuery<ExamDetail | null>({
queryKey: ['exams', 'detail', id],
queryFn: async () => {
try {
return validateResponse<ExamDetail>(
examDetailSchema,
await api.get<ExamDetail>(`/exams/${id}`),
);
} catch (error) {
message.error(getErrorMessage(error, '加载考试失败'));
return null;
}
},
});
const loading = isLoading || isFetching;
useEffect(() => {
void load();
}, [load]);
const saveScoreMutation = useApiMutation(
async ({ rowId, score }: { rowId: number; score: number | null }) =>
api.put(`/exams/${id}/scores/${rowId}`, { score }),
{ invalidate: [['exams', 'detail', id]] },
);
const saveScore = useCallback(
async (row: ScoreRow, value: number | undefined) => {
await api.put(`/exams/${id}/scores/${row.id}`, { score: value ?? null });
message.success('成绩已保存');
await load();
try {
await saveScoreMutation.mutateAsync({ rowId: row.id, score: value ?? null });
message.success('成绩已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[id, load],
[saveScoreMutation],
);
const columns = useMemo<ColumnsType<ScoreRow>>(() => {

View File

@@ -1,22 +1,51 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Card, Checkbox, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Space, Switch, Tag } from 'antd';
import { CalendarOutlined, InboxOutlined, PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
import React, { useMemo, useState } from 'react';
import { useDebounceValue } from 'usehooks-ts';
import {
App,
Button,
Card,
Checkbox,
Col,
Empty,
Form,
Input,
Popconfirm,
Progress,
Row,
Select,
Space,
Switch,
Tag,
} from 'antd';
import {
CalendarOutlined,
DeleteOutlined,
InboxOutlined,
PlusOutlined,
SearchOutlined,
TeamOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import api from '../../api';
import { message } from '../../ui/app-message';
import ExamFormModal from './ExamFormModal';
import { selectAllExamIds, toggleExamSelection } from './selection';
import type { ClassOption, ExamFormValues, ExamItem } from './types';
import { EXAM_TYPE_OPTIONS } from './types';
import { EXAM_TYPE_OPTIONS, type ClassOption, type ExamFormValues, type ExamItem } from './types';
import './style.css';
import { usePermission } from '../../hooks/usePermission';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { classOptionsSchema, examsSchema } from '../../api/schemas';
import { getErrorMessage } from '../../utils/error';
const ExamsPage: React.FC = () => {
const { modal } = App.useApp();
const navigate = useNavigate();
const { hasPermission } = usePermission();
const canPurgeExam = hasPermission('exam:purge');
const [form] = Form.useForm<ExamFormValues>();
const [data, setData] = useState<ExamItem[]>([]);
const [classes, setClasses] = useState<ClassOption[]>([]);
const [loading, setLoading] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
@@ -25,38 +54,96 @@ const ExamsPage: React.FC = () => {
const [classId, setClassId] = useState<number>();
const [showArchived, setShowArchived] = useState(false);
const [selectedExamIds, setSelectedExamIds] = useState<number[]>([]);
const [debouncedFilters] = useDebounceValue(
{ keyword, examType, classId, showArchived },
200,
);
const loadClasses = useCallback(async () => {
const result = await api.get<ClassOption[]>('/classes');
setClasses(result ?? []);
}, []);
const { data: classes = [] } = useQuery<ClassOption[]>({
queryKey: ['exams', 'classes'],
queryFn: async () => {
try {
return (
validateResponse<ClassOption[]>(
classOptionsSchema,
await api.get<ClassOption[]>('/classes'),
) ?? []
);
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载班级失败'));
return [];
}
},
});
const loadExams = useCallback(async () => {
const { data = [], isFetching } = useQuery<ExamItem[]>({
queryKey: [
'exams',
debouncedFilters.keyword,
debouncedFilters.examType,
debouncedFilters.classId,
debouncedFilters.showArchived,
],
queryFn: async () => {
try {
const params = new URLSearchParams();
if (debouncedFilters.keyword.trim())
params.set('keyword', debouncedFilters.keyword.trim());
if (debouncedFilters.examType) params.set('examType', debouncedFilters.examType);
if (debouncedFilters.classId) params.set('classId', String(debouncedFilters.classId));
params.set('isArchived', String(debouncedFilters.showArchived));
return (
validateResponse<ExamItem[]>(
examsSchema,
await api.get<ExamItem[]>(`/exams?${params.toString()}`),
) ?? []
);
} catch (error) {
message.error(getErrorMessage(error, '加载考试失败'));
return [];
}
},
});
const loading = isFetching;
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) => api.post('/exams', payload),
{ invalidate: [['exams']] },
);
const archiveMutation = useApiMutation(
async ({ id, archive }: { id: number; archive: boolean }) =>
api.put(`/exams/${id}/${archive ? 'archive' : 'restore'}`),
{ invalidate: [['exams']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/exams/${id}/permanent`),
{ invalidate: [['exams']] },
);
const batchPurgeMutation = useApiMutation(
async (ids: number[]) => api.post<{ deleted: number; skipped: number }>('/exams/batch-permanent-delete', { ids }),
{ invalidate: [['exams']] },
);
const batchArchiveMutation = useApiMutation(
async (ids: number[]) => api.put<{ archived: number; skipped: number }>('/exams/batch-archive', { ids }),
{ invalidate: [['exams']] },
);
const batchRestoreMutation = useApiMutation(
async (ids: number[]) => api.put<{ restored: number; skipped: number }>('/exams/batch-restore', { ids }),
{ invalidate: [['exams']] },
);
const updateKeyword = (value: string) => {
setKeyword(value);
setSelectedExamIds([]);
setLoading(true);
try {
const params = new URLSearchParams();
if (keyword.trim()) params.set('keyword', keyword.trim());
if (examType) params.set('examType', examType);
if (classId) params.set('classId', String(classId));
params.set('isArchived', String(showArchived));
const result = await api.get<ExamItem[]>(`/exams?${params.toString()}`);
setData(result ?? []);
} catch (error) {
message.error((error as { message?: string })?.message || '加载考试失败');
} finally {
setLoading(false);
}
}, [classId, examType, keyword, showArchived]);
useEffect(() => {
void loadClasses().catch((error: { message?: string }) => message.error(error?.message || '加载班级失败'));
}, [loadClasses]);
useEffect(() => {
const timer = window.setTimeout(() => void loadExams(), 200);
return () => window.clearTimeout(timer);
}, [loadExams]);
};
const updateExamType = (value: string | undefined) => {
setExamType(value);
setSelectedExamIds([]);
};
const updateClassId = (value: number | undefined) => {
setClassId(value);
setSelectedExamIds([]);
};
const classOptions = useMemo(
() => classes.map((item) => ({ value: item.id, label: item.name })),
@@ -74,13 +161,11 @@ const ExamsPage: React.FC = () => {
const values = await form.validateFields();
setSaving(true);
const payload = { ...values, examDate: values.examDate.format('YYYY-MM-DD') };
await api.post('/exams', payload);
await saveMutation.mutateAsync(payload);
message.success('考试已创建');
setModalOpen(false);
await loadExams();
} catch (error) {
if ((error as { errorFields?: unknown[] }).errorFields) return;
message.error((error as { message?: string })?.message || '保存失败');
} catch {
// 校验错误静默,接口错误由 useApiMutation 统一提示
} finally {
setSaving(false);
}
@@ -88,11 +173,44 @@ const ExamsPage: React.FC = () => {
const changeArchiveStatus = async (exam: ExamItem, archive: boolean) => {
try {
await api.put(`/exams/${exam.id}/${archive ? 'archive' : 'restore'}`);
await archiveMutation.mutateAsync({ id: exam.id, archive });
message.success(archive ? '考试已归档' : '考试已恢复');
await loadExams();
} catch (error) {
message.error((error as { message?: string })?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (exam: ExamItem) => {
modal.confirm({
title: `永久删除考试「${exam.examName}」?`,
content: '删除后不可恢复,该考试及其成绩记录将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(exam.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const batchPurge = async () => {
if (selectedExamIds.length === 0 || batchLoading) return;
setBatchLoading(true);
try {
const result = await batchPurgeMutation.mutateAsync(selectedExamIds);
message.success(
`已永久删除 ${result.deleted} 场考试${result.skipped ? `,跳过 ${result.skipped}` : ''}`,
);
setSelectedExamIds([]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
};
@@ -100,7 +218,12 @@ const ExamsPage: React.FC = () => {
const partiallySelected = selectedExamIds.length > 0 && !allCurrentSelected;
const toggleSelectAll = (checked: boolean) => {
setSelectedExamIds(selectAllExamIds(data.map((exam) => exam.id), checked));
setSelectedExamIds(
selectAllExamIds(
data.map((exam) => exam.id),
checked,
),
);
};
const changeArchiveView = (checked: boolean) => {
@@ -113,24 +236,19 @@ const ExamsPage: React.FC = () => {
setBatchLoading(true);
try {
if (archive) {
const result = await api.put<{ archived: number; skipped: number }>('/exams/batch-archive', {
ids: selectedExamIds,
});
const result = await batchArchiveMutation.mutateAsync(selectedExamIds);
message.success(
`已归档 ${result.archived} 场考试${result.skipped ? `,跳过 ${result.skipped}` : ''}`,
);
} else {
const result = await api.put<{ restored: number; skipped: number }>('/exams/batch-restore', {
ids: selectedExamIds,
});
const result = await batchRestoreMutation.mutateAsync(selectedExamIds);
message.success(
`已恢复 ${result.restored} 场考试${result.skipped ? `,跳过 ${result.skipped}` : ''}`,
);
}
setSelectedExamIds([]);
await loadExams();
} catch (error) {
message.error((error as { message?: string })?.message || '批量操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setBatchLoading(false);
}
@@ -140,9 +258,31 @@ const ExamsPage: React.FC = () => {
<div className="exam-page">
<div className="exam-toolbar">
<Space wrap>
<Input value={keyword} onChange={(event) => setKeyword(event.target.value)} prefix={<SearchOutlined />} placeholder="搜索考试名称" allowClear />
<Select value={examType} onChange={setExamType} options={EXAM_TYPE_OPTIONS} placeholder="考试类型" allowClear style={{ width: 140 }} />
<Select value={classId} onChange={setClassId} options={classOptions} placeholder="考试班级" allowClear showSearch optionFilterProp="label" style={{ width: 180 }} />
<Input
value={keyword}
onChange={(event) => updateKeyword(event.target.value)}
prefix={<SearchOutlined />}
placeholder="搜索考试名称"
allowClear
/>
<Select
value={examType}
onChange={updateExamType}
options={EXAM_TYPE_OPTIONS}
placeholder="考试类型"
allowClear
style={{ width: 140 }}
/>
<Select
value={classId}
onChange={updateClassId}
options={classOptions}
placeholder="考试班级"
allowClear
showSearch
optionFilterProp="label"
style={{ width: 180 }}
/>
</Space>
<Space wrap>
<Checkbox
@@ -171,29 +311,55 @@ const ExamsPage: React.FC = () => {
{showArchived ? '批量恢复' : '批量归档'}
</Button>
</Popconfirm>
{showArchived && canPurgeExam ? (
<Popconfirm
title={`确认永久删除选中的 ${selectedExamIds.length} 场考试?`}
description="删除后不可恢复,相关成绩将一并清除。"
disabled={selectedExamIds.length === 0 || batchLoading}
onConfirm={() => void batchPurge()}
okText="永久删除"
okButtonProps={{ danger: true }}
>
<Button
danger
icon={<DeleteOutlined />}
loading={batchLoading}
disabled={selectedExamIds.length === 0}
>
</Button>
</Popconfirm>
) : null}
<span className="exam-archive-toggle">
<InboxOutlined />
<Switch size="small" checked={showArchived} onChange={changeArchiveView} />
</span>
{!showArchived ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
) : null}
</Space>
</div>
{data.length === 0 && !loading ? (
<div className="exam-empty"><Empty description="暂无考试" /></div>
<div className="exam-empty">
<Empty description="暂无考试" />
</div>
) : (
<Row gutter={[16, 16]}>
{data.map((exam) => {
const percent = exam.totalStudents === 0 ? 0 : Math.round((exam.enteredScores / exam.totalStudents) * 100);
const percent =
exam.totalStudents === 0
? 0
: Math.round((exam.enteredScores / exam.totalStudents) * 100);
return (
<Col key={exam.id} xs={24} sm={12} xl={8} xxl={6}>
<Card
className={`exam-card${selectedExamIds.includes(exam.id) ? ' exam-card-selected' : ''}`}
loading={loading}
title={(
title={
<Space>
<Checkbox
aria-label={`选择考试 ${exam.examName}`}
@@ -208,18 +374,38 @@ const ExamsPage: React.FC = () => {
<Tag color="blue">{exam.examType}</Tag>
<span>{exam.examName}</span>
</Space>
)}
extra={<Tag color={exam.status === 'archived' ? 'default' : 'green'}>{exam.status === 'archived' ? '已归档' : '成绩录入'}</Tag>}
}
extra={
<Tag color={exam.status === 'archived' ? 'default' : 'green'}>
{exam.status === 'archived' ? '已归档' : '成绩录入'}
</Tag>
}
actions={[
<span key="detail" onClick={() => navigate(`/exams/${exam.id}`)}></span>,
<span key="detail" onClick={() => navigate(`/exams/${exam.id}`)}>
</span>,
exam.status === 'archived' ? (
<Popconfirm
key="restore"
title="确认恢复该考试?"
onConfirm={() => changeArchiveStatus(exam, false)}
>
<span></span>
</Popconfirm>
<>
<Popconfirm
key="restore"
title="确认恢复该考试?"
onConfirm={() => changeArchiveStatus(exam, false)}
>
<span></span>
</Popconfirm>
{canPurgeExam ? (
<Popconfirm
key="purge"
title="确认永久删除该考试?"
description="删除后不可恢复,成绩记录将一并清除。"
onConfirm={() => handlePurge(exam)}
okText="永久删除"
okButtonProps={{ danger: true }}
>
<span className="exam-purge-action"></span>
</Popconfirm>
) : null}
</>
) : (
<Popconfirm
key="archive"
@@ -232,10 +418,31 @@ const ExamsPage: React.FC = () => {
),
]}
>
<div className="exam-meta"><span></span><strong>{exam.subject}</strong></div>
<div className="exam-meta"><span><TeamOutlined /> </span><strong>{exam.className}</strong></div>
<div className="exam-meta"><span><CalendarOutlined /> </span><strong>{exam.examDate}</strong></div>
<div className="exam-progress"><div><span></span><strong>{exam.enteredScores}/{exam.totalStudents}</strong></div><Progress percent={percent} size="small" /></div>
<div className="exam-meta">
<span></span>
<strong>{exam.subject}</strong>
</div>
<div className="exam-meta">
<span>
<TeamOutlined />
</span>
<strong>{exam.className}</strong>
</div>
<div className="exam-meta">
<span>
<CalendarOutlined />
</span>
<strong>{exam.examDate}</strong>
</div>
<div className="exam-progress">
<div>
<span></span>
<strong>
{exam.enteredScores}/{exam.totalStudents}
</strong>
</div>
<Progress percent={percent} size="small" />
</div>
</Card>
</Col>
);
@@ -243,7 +450,15 @@ const ExamsPage: React.FC = () => {
</Row>
)}
<ExamFormModal open={modalOpen} editing={false} saving={saving} form={form} classes={classes} onCancel={() => setModalOpen(false)} onSubmit={() => void submit()} />
<ExamFormModal
open={modalOpen}
editing={false}
saving={saving}
form={form}
classes={classes}
onCancel={() => setModalOpen(false)}
onSubmit={() => void submit()}
/>
</div>
);
};

View File

@@ -80,6 +80,10 @@
white-space: nowrap;
}
.exam-purge-action {
color: #ff4d4f;
}
@media (max-width: 575px) {
.exam-toolbar > .ant-space,
.exam-toolbar .ant-input-affix-wrapper,

View File

@@ -0,0 +1,166 @@
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React from 'react';
import {
DatePicker,
Form,
Input,
InputNumber,
Modal,
Select,
} from 'antd';
const { RangePicker } = DatePicker;
export const RoomExpenseModal: React.FC<{
open: boolean;
editing: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
rooms: any[];
typeOptions: Array<{ value: string; label: string }>;
onOk: () => void;
onCancel: () => void;
}> = ({ open, editing, saving, form, rooms, typeOptions, onOk, onCancel }) => {
return (
<Modal
title={editing ? '编辑宿舍费用' : '录入宿舍费用'}
open={open}
onOk={onOk}
onCancel={onCancel}
okText={editing ? '保存' : '确认录入'}
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={rooms.map((r: any) => ({
value: r.id,
label: `${r.roomNumber} (${r.building || ''})`,
}))}
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={typeOptions} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}>
<RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
};
export const UtilityModal: React.FC<{
open: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
students: any[];
onOk: () => void;
onCancel: () => void;
}> = ({ open, saving, form, students, onOk, onCancel }) => {
return (
<Modal
title="添加学生水电费并立即出账"
open={open}
onOk={onOk}
onCancel={onCancel}
okText="生成账单并扣余额"
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={students.map((student: any) => ({
value: student.id,
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
}))}
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select
options={[
{ value: 'water', label: '水费' },
{ value: 'electricity', label: '电费' },
]}
/>
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}>
<RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} maxLength={300} />
</Form.Item>
</Form>
</Modal>
);
};
export const PersonalExpenseModal: React.FC<{
open: boolean;
editing: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
students: any[];
rooms: any[];
personalTypeOptions: Array<{ value: string; label: string }>;
onOk: () => void;
onCancel: () => void;
}> = ({ open, editing, saving, form, students, rooms, personalTypeOptions, onOk, onCancel }) => {
return (
<Modal
title={editing ? '编辑个人费用' : '录入个人附加费'}
open={open}
onOk={onOk}
onCancel={onCancel}
okText={editing ? '保存' : '确认录入'}
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={students.map((s: any) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item name="roomId" label="关联宿舍">
<Select
allowClear
showSearch
optionFilterProp="label"
options={rooms.map((r: any) => ({ value: r.id, label: r.roomNumber }))}
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={personalTypeOptions} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="expenseDate" label="费用日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
};

View File

@@ -0,0 +1,524 @@
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React from 'react';
import {
Button,
Empty,
Input,
Popconfirm,
Select,
Space,
Table,
Tag,
Upload,
} from 'antd';
import {
DeleteOutlined,
DownloadOutlined,
EditOutlined,
ExportOutlined,
InboxOutlined,
UndoOutlined,
UploadOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
export const EXPENSE_FIELDS = {
roomId: 'roomId',
expenseType: 'expenseType',
amount: 'amount',
description: 'description',
studentId: 'studentId',
expenseDate: 'expenseDate',
} as const;
export interface ExpenseTablePanelProps {
kind: 'room' | 'personal';
searchText: string;
onSearchChange: (value: string) => void;
typeFilter: string | undefined;
onTypeFilterChange: (value?: string) => void;
typeOptions: Array<{ value: string; label: string }>;
typeMap: Record<string, string>;
data: any[];
loading: boolean;
selectedKeys: number[];
onSelect: (keys: number[]) => void;
rooms: any[];
students: any[];
readonly: boolean;
showArchived: boolean;
canPurgeExpense: boolean;
batchLoading: boolean;
canImport: boolean;
onBatchRestore: () => void;
onBatchPurge: () => void;
onBatchDelete: () => void;
onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;
onPeriodSave: (id: number, periodStart: string, periodEnd: string) => Promise<void> | void;
onEdit: (record: any) => void;
onArchive: (id: number) => Promise<unknown> | unknown;
onPurge: (id: number) => void;
onImport: (formData: FormData) => Promise<any>;
onTemplateDownload: () => void;
onExport?: () => void;
onAddUtility?: () => void;
}
export const ExpenseTablePanel: React.FC<ExpenseTablePanelProps> = ({
kind,
searchText,
onSearchChange,
typeFilter,
onTypeFilterChange,
typeOptions,
typeMap,
data,
loading,
selectedKeys,
onSelect,
rooms,
students,
readonly,
showArchived,
canPurgeExpense,
batchLoading,
canImport,
onBatchRestore,
onBatchPurge,
onBatchDelete,
onSaveCell,
onPeriodSave,
onEdit,
onArchive,
onPurge,
onImport,
onTemplateDownload,
onExport,
onAddUtility,
}) => {
const isRoom = kind === 'room';
const noun = isRoom ? '费用' : '个人费用';
const EditableExpenseCell = ({
value,
editor,
min,
max,
required,
options,
onSave,
children,
}: {
value: unknown;
editor?: React.ComponentProps<typeof EditableCell>['editor'];
min?: number;
max?: number;
required?: boolean;
options?: Array<{ value: string | number; label: string }>;
onSave: (value: unknown) => Promise<void> | void;
children?: React.ReactNode;
}) => (
<EditableCell
value={value}
editor={editor}
min={min}
max={max}
required={required}
options={options}
permission="expense:edit"
disabled={readonly}
onSave={async (next) => {
await onSave(next);
}}
>
{children ?? String(value ?? '-')}
</EditableCell>
);
const renderExpenseActions = (record: any) => {
if (showArchived) {
return (
<Space>
<Tag color="#999"></Tag>
{canPurgeExpense ? (
<Button size="small" danger type="link" onClick={() => onPurge(record.id)}>
</Button>
) : null}
</Space>
);
}
return (
<Space>
<PermissionButton
permission="expense:edit"
size="small"
icon={<EditOutlined />}
onClick={() => onEdit(record)}
>
</PermissionButton>
<Popconfirm
title="确定归档?"
onConfirm={async () => {
try {
await onArchive(record.id);
message.success('归档成功');
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
>
<PermissionButton permission="expense:delete" size="small" danger icon={<InboxOutlined />}>
</PermissionButton>
</Popconfirm>
</Space>
);
};
const columns = isRoom
? [
{
title: '宿舍',
width: 120,
render: (_: any, r: any) => (
<EditableExpenseCell
value={r.roomId}
editor="select"
options={rooms.map((item) => ({ value: item.id, label: item.roomNumber }))}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.roomId, next)}
>
{r.room?.roomNumber || '-'}
</EditableExpenseCell>
),
},
{
title: '费用类型',
width: 100,
dataIndex: 'expenseType',
render: (v: string, r: any) => (
<EditableExpenseCell
value={v}
editor="select"
options={typeOptions}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.expenseType, next)}
>
<Tag>{typeMap[v] || v}</Tag>
</EditableExpenseCell>
),
},
{
title: '金额',
dataIndex: 'amount',
width: 100,
render: (v: number, r: any) => (
<EditableExpenseCell
value={v}
editor="money"
min={0.01}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.amount, next)}
>
{`¥${v.toFixed(2)}`}
</EditableExpenseCell>
),
},
{
title: '账单周期',
width: 200,
render: (_: any, r: any) => (
<EditableCell
value={[r.periodStart, r.periodEnd]}
editor="date-range"
permission="expense:edit"
disabled={readonly}
required
onSave={async (next) => {
const [periodStart, periodEnd] = next as [string, string];
await onPeriodSave(r.id, periodStart, periodEnd);
}}
>{`${r.periodStart} ~ ${r.periodEnd}`}</EditableCell>
),
},
{
title: '说明',
dataIndex: 'description',
width: 150,
render: (v: string, r: any) => (
<EditableExpenseCell
value={v}
editor="textarea"
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.description, next)}
>
{v || '-'}
</EditableExpenseCell>
),
},
{
title: '录入时间',
width: 160,
dataIndex: 'createdAt',
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
width: 120,
render: (_: any, record: any) => renderExpenseActions(record),
},
]
: [
{
title: '学生',
width: 120,
render: (_: any, r: any) => (
<EditableExpenseCell
value={r.studentId}
editor="select"
options={students.map((item) => ({ value: item.id, label: item.name }))}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.studentId, next)}
>
{r.student?.name || '-'}
</EditableExpenseCell>
),
},
{
title: '费用类型',
width: 100,
dataIndex: 'expenseType',
render: (v: string, r: any) => (
<EditableExpenseCell
value={v}
editor="select"
options={typeOptions}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.expenseType, next)}
>
<Tag color="orange">{typeMap[v] || v}</Tag>
</EditableExpenseCell>
),
},
{
title: '金额',
dataIndex: 'amount',
render: (v: number, r: any) => (
<EditableExpenseCell
value={v}
editor="money"
min={0.01}
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.amount, next)}
>
{`¥${v.toFixed(2)}`}
</EditableExpenseCell>
),
},
{
title: '日期',
dataIndex: 'expenseDate',
width: 110,
render: (v: string, r: any) => (
<EditableExpenseCell
value={v}
editor="date"
required
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.expenseDate, next)}
>
{v}
</EditableExpenseCell>
),
},
{
title: '说明',
dataIndex: 'description',
width: 150,
render: (v: string, r: any) => (
<EditableExpenseCell
value={v}
editor="textarea"
onSave={(next) => onSaveCell(r, EXPENSE_FIELDS.description, next)}
>
{v || '-'}
</EditableExpenseCell>
),
},
{
title: '操作',
width: 120,
render: (_: any, record: any) => renderExpenseActions(record),
},
];
return (
<>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder={isRoom ? '搜索宿舍号' : '搜索学生姓名'}
allowClear
style={{ width: 160 }}
value={searchText}
onSearch={(v) => onSearchChange(v)}
onChange={(e) => {
if (!e.target.value) onSearchChange('');
}}
/>
<Select
placeholder="费用类型"
allowClear
style={{ width: 120 }}
value={typeFilter}
onChange={onTypeFilterChange}
options={typeOptions}
/>
{canImport && !showArchived && (
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
try {
const formData = new FormData();
formData.append('file', file);
const res: any = await onImport(formData);
if (isRoom && res.errors?.length > 0) {
message.warning(res.message || '导入完成');
res.errors.forEach((e: string) => message.warning(e));
} else {
message.success(res.message || '导入完成');
if (res.errors?.length) res.errors.forEach((e: string) => message.warning(e));
}
onSuccess?.(res);
} catch (e) {
onError?.(e as Error);
}
}}
>
<Button icon={<UploadOutlined />}>
{isRoom ? '导入水电费Excel' : '导入个人附加费'}
</Button>
</Upload>
)}
{!showArchived && (
<PermissionButton
permission="expense:view"
icon={<DownloadOutlined />}
onClick={onTemplateDownload}
>
{isRoom ? '下载水电费模板' : '下载模板'}
</PermissionButton>
)}
{onExport && !showArchived ? (
<PermissionButton
permission="expense:view"
icon={<ExportOutlined />}
onClick={onExport}
>
</PermissionButton>
) : null}
{isRoom && onAddUtility && !showArchived ? (
<PermissionButton
permission="expense:create"
icon={<ThunderboltOutlined />}
onClick={onAddUtility}
>
</PermissionButton>
) : null}
</Space>
<Space>
{showArchived ? (
<>
<Popconfirm
title={`确定恢复选中的 ${selectedKeys.length}${noun}`}
onConfirm={onBatchRestore}
okText="恢复"
cancelText="取消"
disabled={selectedKeys.length === 0}
>
<PermissionButton
permission="expense:edit"
type="primary"
icon={<UndoOutlined />}
loading={batchLoading}
disabled={selectedKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
{canPurgeExpense ? (
<Popconfirm
title={`确定永久删除选中的 ${selectedKeys.length}${noun}?删除后不可恢复!`}
onConfirm={onBatchPurge}
okText="永久删除"
okButtonProps={{ danger: true }}
cancelText="取消"
disabled={selectedKeys.length === 0}
>
<Button
danger
icon={<DeleteOutlined />}
loading={batchLoading}
disabled={selectedKeys.length === 0}
>
</Button>
</Popconfirm>
) : null}
</>
) : (
<Popconfirm
title={`确定归档选中的 ${selectedKeys.length}${noun}`}
onConfirm={onBatchDelete}
okText="归档"
cancelText="取消"
disabled={selectedKeys.length === 0}
>
<PermissionButton
permission="expense:delete"
danger
icon={<InboxOutlined />}
disabled={selectedKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: (keys) => onSelect(keys as number[]),
}}
/>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,503 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Alert,
Button,
Card,
Col,
DatePicker,
Drawer,
Form,
Input,
List,
Modal,
Row,
Select,
Space,
Tag,
Tree,
TreeSelect,
} from 'antd';
import type { DataNode } from 'antd/es/tree';
import type { TreeSelectProps } from 'antd/es/tree-select';
import {
BankOutlined,
StopOutlined,
SyncOutlined,
UserOutlined,
} from '@ant-design/icons';
import api from '../../api';
import { message } from '../../ui/app-message';
import PermissionButton from '../../components/PermissionButton';
import { getErrorMessage } from '../../utils/error';
interface DingOrgTreeNodeExt {
id: number;
name: string;
parentId: number;
children: DingOrgTreeNodeExt[];
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
}
interface OrgTreeNodeRaw {
id: number;
name: string;
children?: OrgTreeNodeRaw[];
}
interface OrgTreeResponse {
success: boolean;
data: OrgTreeNodeRaw[];
}
interface OrgTreeWithUsersResponse {
success: boolean;
data: DingOrgTreeNodeExt[];
}
type DeptPickerTreeNode = NonNullable<TreeSelectProps<number>['treeData']>[number];
interface ClassItem {
id: number;
name: string;
code: string;
classType?: string;
startDate?: string;
endDate?: string;
notes?: string;
}
interface ImportResult {
imported: number;
skipped: number;
conflicts: number;
}
interface DingTalkAttendanceGroup {
group_id: number;
group_name: string;
type: string;
member_count: number;
}
interface AttendanceGroupResponse {
success: boolean;
data: DingTalkAttendanceGroup[];
}
interface DeleteAttendanceGroupsResponse {
success: boolean;
data: {
total: number;
deleted: Array<{ groupId: number; groupName: string }>;
failed: Array<{ groupId: number; groupName: string; error: string }>;
};
}
interface IntegrationOrgSyncPanelProps {
canCreateClass: boolean;
}
export const IntegrationOrgSyncPanel: React.FC<IntegrationOrgSyncPanelProps> = ({
canCreateClass,
}) => {
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchingTree, setFetchingTree] = useState(false);
const [importing, setImporting] = useState(false);
const [deptPickerTree, setDeptPickerTree] = useState<DeptPickerTreeNode[]>([]);
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
const [classes, setClasses] = useState<ClassItem[]>([]);
const [classForm] = Form.useForm();
const [classModalOpen, setClassModalOpen] = useState(false);
const [attendanceGroups, setAttendanceGroups] = useState<DingTalkAttendanceGroup[]>([]);
const [deleteGroupsOpen, setDeleteGroupsOpen] = useState(false);
const [loadingGroups, setLoadingGroups] = useState(false);
const [deletingGroups, setDeletingGroups] = useState(false);
const loadDeptTree = async () => {
try {
const res = await api.get<OrgTreeResponse>('/sync/dingtalk/org-tree');
if (res.success && res.data) {
const toTreeNode = (nodes: OrgTreeNodeRaw[]): DeptPickerTreeNode[] =>
nodes.map((n) => ({
title: n.name,
value: n.id,
children: n.children ? toTreeNode(n.children) : undefined,
}));
setDeptPickerTree(toTreeNode(res.data));
}
} catch (e) {
console.error('获取部门架构失败', e);
message.error('获取部门架构失败');
}
};
const fetchClasses = async () => {
try {
const res = await api.get<ClassItem[] | { data: ClassItem[] }>('/classes');
if (Array.isArray(res)) {
setClasses(res);
} else {
setClasses(res.data ?? []);
}
} catch {
/* ignore */
}
};
const handleFetchOrgTree = async () => {
setFetchingTree(true);
try {
const params: Record<string, string> = {};
if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId);
const res = await api.get<OrgTreeWithUsersResponse>('/sync/dingtalk/org-tree-with-users', {
params,
});
if (res.success && res.data) {
setOrgTree(res.data);
setCheckedKeys([]);
setSelectedClassId(null);
setDrawerOpen(true);
fetchClasses();
} else {
message.error('获取组织架构失败');
}
} catch (e: unknown) {
message.error(getErrorMessage(e, '获取组织架构失败'));
} finally {
setFetchingTree(false);
}
};
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
return nodes.map((node) => {
const users = node.users ?? [];
const children: DataNode[] = [
...buildTreeData(node.children ?? []),
...users.map((u) => ({
title: (
<Space>
<UserOutlined />
<span>{u.name}</span>
{u.mobile ? <Tag>{u.mobile}</Tag> : null}
</Space>
),
key: `user-${u.userid}`,
isLeaf: true,
})),
];
return {
title: (
<Space size="small">
<BankOutlined />
<span>{node.name}</span>
<Tag>{users.length}</Tag>
</Space>
),
key: `dept-${node.id}`,
// Only attach children when there are any, so empty/leaf departments
// don't render a phantom expand arrow that opens to nothing.
...(children.length > 0 ? { children } : {}),
};
});
}, []);
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]);
const extractCheckedUsers = useCallback((): Array<{
dingUserId: string;
name: string;
mobile?: string;
}> => {
const result: Array<{ dingUserId: string; name: string; mobile?: string }> = [];
const walk = (nodes: DingOrgTreeNodeExt[]) => {
for (const node of nodes) {
for (const u of node.users ?? []) {
if (checkedKeys.includes(`user-${u.userid}`)) {
result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });
}
}
walk(node.children ?? []);
}
};
walk(orgTree);
return result;
}, [checkedKeys, orgTree]);
const handleJoinClass = async () => {
if (selectedClassId === null) return message.warning('请先选择一个班级');
const users = extractCheckedUsers();
if (users.length === 0) return message.warning('请勾选要导入的用户');
setImporting(true);
try {
const res = await api.post<ImportResult>(`/classes/${selectedClassId}/students/import`, {
users,
});
if (res.conflicts > 0) {
message.warning(
`导入 ${res.imported} 人,跳过 ${res.skipped} 人,${res.conflicts} 人需人工绑定`,
);
} else {
message.success(`导入 ${res.imported} 人,跳过 ${res.skipped}`);
}
setCheckedKeys([]);
setSelectedClassId(null);
} catch (e: unknown) {
message.error(getErrorMessage(e, '导入失败'));
} finally {
setImporting(false);
}
};
const handleCreateClass = async () => {
try {
const values = await classForm.validateFields();
const users = extractCheckedUsers();
await api.post('/classes', { ...values, users });
message.success('班级创建成功');
setClassModalOpen(false);
classForm.resetFields();
setCheckedKeys([]);
fetchClasses();
} catch (e: unknown) {
message.error(getErrorMessage(e, '创建失败'));
} finally {
setImporting(false);
}
};
const openDeleteAllGroups = async () => {
setLoadingGroups(true);
try {
const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');
setAttendanceGroups(response.data);
setDeleteGroupsOpen(true);
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '获取钉钉考勤组失败');
} finally {
setLoadingGroups(false);
}
};
const deleteAllGroups = async () => {
setDeletingGroups(true);
try {
const response = await api.post<DeleteAttendanceGroupsResponse>(
'/sync/dingtalk/attendance-groups/delete-all',
);
setDeleteGroupsOpen(false);
setAttendanceGroups([]);
if (response.data.failed.length > 0) {
message.warning(
`已清空 ${response.data.deleted.length} 个,失败 ${response.data.failed.length}`,
);
} else {
message.success(`已清空钉钉全部 ${response.data.deleted.length} 个考勤组`);
}
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '清空钉钉考勤组失败');
} finally {
setDeletingGroups(false);
}
};
return (
<div>
<Alert
type="info"
message="从钉钉获取组织架构,勾选用户后批量导入到班级。"
style={{ marginBottom: 16 }}
showIcon
/>
<Space>
<TreeSelect
treeData={deptPickerTree}
value={syncRootDeptId}
onChange={(v) => setSyncRootDeptId(v)}
placeholder="选择起始部门(不选=全部)"
allowClear
treeDefaultExpandAll
style={{ minWidth: 240 }}
onDropdownVisibleChange={(open) => {
if (open) loadDeptTree();
}}
/>
<Button
type="primary"
icon={<SyncOutlined />}
loading={fetchingTree}
onClick={handleFetchOrgTree}
>
</Button>
<PermissionButton
permission="sync:trigger"
danger
icon={<StopOutlined />}
loading={loadingGroups}
onClick={openDeleteAllGroups}
>
</PermissionButton>
</Space>
{drawerOpen && (
<Drawer
title="钉钉组织架构 — 批量导入"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
size="min(900px, 100vw)"
footer={
<Space>
<Button onClick={() => setDrawerOpen(false)}></Button>
<Button
type="primary"
loading={importing}
disabled={
checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 ||
selectedClassId === null
}
onClick={handleJoinClass}
>
</Button>
{canCreateClass ? (
<Button
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0}
onClick={() => setClassModalOpen(true)}
>
</Button>
) : null}
</Space>
}
>
<Row gutter={[16, 16]}>
<Col xs={24} md={14}>
<div style={{ maxHeight: '60vh', overflow: 'auto' }}>
<Tree
checkable
treeData={treeData}
defaultExpandAll
showLine={{ showLeafIcon: false }}
checkedKeys={checkedKeys}
onCheck={(checked) => setCheckedKeys(checked as React.Key[])}
/>
</div>
</Col>
<Col xs={24} md={10}>
<Card
title="班级列表"
size="small"
extra={
canCreateClass ? (
<Button size="small" onClick={() => setClassModalOpen(true)}>
+
</Button>
) : null
}
>
<List
dataSource={classes}
renderItem={(cls: ClassItem) => (
<List.Item
onClick={() => setSelectedClassId(cls.id)}
style={{
cursor: 'pointer',
background: selectedClassId === cls.id ? '#e6f4ff' : undefined,
borderRadius: 4,
padding: '8px 12px',
}}
>
<List.Item.Meta
title={cls.name}
description={`${cls.code} ${cls.classType || ''}`}
/>
</List.Item>
)}
/>
</Card>
</Col>
</Row>
{canCreateClass ? (
<Modal
title="创建班级"
open={classModalOpen}
onOk={handleCreateClass}
onCancel={() => {
setClassModalOpen(false);
classForm.resetFields();
}}
confirmLoading={importing}
destroyOnClose
>
<Form form={classForm} layout="vertical">
<Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
<Input placeholder="如 CS2024-01" />
</Form.Item>
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
<Select
options={[
{ value: 'culture', label: '文化课' },
{ value: 'professional', label: '专业课' },
{ value: 'bootcamp', label: '集训营' },
{ value: 'sprint', label: '冲刺班' },
]}
/>
</Form.Item>
<Form.Item name="startDate" label="开班日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
) : null}
</Drawer>
)}
<Modal
title="确认清空钉钉全部考勤组"
open={deleteGroupsOpen}
okText="确认全部清空"
okButtonProps={{ danger: true, disabled: attendanceGroups.length === 0 }}
cancelText="取消"
confirmLoading={deletingGroups}
onOk={deleteAllGroups}
onCancel={() => setDeleteGroupsOpen(false)}
>
<Alert
type="error"
showIcon
title={`将永久清空钉钉上的 ${attendanceGroups.length} 个考勤组`}
description="本地班级和排课不会清空。清空后需在排课管理中重新同步,才能重建考勤组。"
style={{ marginBottom: 12 }}
/>
<List
size="small"
bordered
dataSource={attendanceGroups}
style={{ maxHeight: 280, overflow: 'auto' }}
renderItem={(group) => (
<List.Item>
<List.Item.Meta
title={group.group_name}
description={`ID ${group.group_id} · ${group.member_count}`}
/>
</List.Item>
)}
/>
</Modal>
</div>
);
};

View File

@@ -1,37 +1,26 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { integrationConfigSchema } from '../../api/schemas';
import {
Alert,
Button,
Card,
Descriptions,
Divider,
Form,
Input,
Button,
Space,
Spin,
Alert,
Descriptions,
Tag,
Divider,
Drawer,
Tree,
Select,
TreeSelect,
Modal,
DatePicker,
Row,
Col,
List,
} from 'antd';
import {
SaveOutlined,
ApiOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
BankOutlined,
UserOutlined,
StopOutlined,
SaveOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import type { TreeSelectProps } from 'antd/es/tree-select';
import api from '../../api';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
@@ -47,146 +36,78 @@ import {
commitDingTalkConfig,
readDingTalkConfigCache,
} from './integration-config-cache';
import { IntegrationOrgSyncPanel } from './IntegrationOrgSyncPanel';
interface DingTalkConfig {
agentId: string;
corpId: string;
}
interface DingOrgTreeNodeExt {
id: number;
name: string;
parentId: number;
children: DingOrgTreeNodeExt[];
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
}
interface OrgTreeNodeRaw {
id: number;
name: string;
children?: OrgTreeNodeRaw[];
}
interface OrgTreeResponse {
success: boolean;
data: OrgTreeNodeRaw[];
}
interface OrgTreeWithUsersResponse {
success: boolean;
data: DingOrgTreeNodeExt[];
}
type DeptPickerTreeNode = NonNullable<TreeSelectProps<number>['treeData']>[number];
interface ClassItem {
id: number;
name: string;
code: string;
classType?: string;
startDate?: string;
endDate?: string;
notes?: string;
}
interface ImportResult {
imported: number;
skipped: number;
conflicts: number;
}
interface DingTalkAttendanceGroup {
group_id: number;
group_name: string;
type: string;
member_count: number;
}
interface AttendanceGroupResponse {
success: boolean;
data: DingTalkAttendanceGroup[];
}
interface DeleteAttendanceGroupsResponse {
success: boolean;
data: {
total: number;
deleted: Array<{ groupId: number; groupName: string }>;
failed: Array<{ groupId: number; groupName: string; error: string }>;
};
}
const IntegrationConfigPage: React.FC = () => {
const initialCache = useMemo(() => readDingTalkConfigCache(), []);
const { hasPermission, hasAllPermissions } = usePermission();
const canCreateClass = hasPermission('class:create');
const [loading, setLoading] = useState(!initialCache.loaded);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [config, setConfig] = useState<DingTalkConfig | null>(initialCache.config);
const [verified, setVerified] = useState<boolean | null>(initialCache.verified);
const [form] = Form.useForm<DingTalkConfigFormValues>();
const queryClient = useQueryClient();
// ── Manual organization sync ──
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchingTree, setFetchingTree] = useState(false);
const [importing, setImporting] = useState(false);
const [deptPickerTree, setDeptPickerTree] = useState<DeptPickerTreeNode[]>([]);
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
const [classes, setClasses] = useState<ClassItem[]>([]);
const [classForm] = Form.useForm();
const [classModalOpen, setClassModalOpen] = useState(false);
const [attendanceGroups, setAttendanceGroups] = useState<DingTalkAttendanceGroup[]>([]);
const [deleteGroupsOpen, setDeleteGroupsOpen] = useState(false);
const [loadingGroups, setLoadingGroups] = useState(false);
const [deletingGroups, setDeletingGroups] = useState(false);
const fetchConfig = useCallback(async (showLoading = false) => {
if (showLoading) setLoading(true);
try {
const res = await api.get<{
success: boolean;
data: Array<{ type: string; verify: boolean; config: DingTalkConfig }>;
}>('/integration/config');
const dt = res.data?.find((c) => c.type === 'DINGTALK');
if (dt) {
setConfig(dt.config);
setVerified(dt.verify);
cacheDingTalkServerSnapshot(dt.config, dt.verify);
form.setFieldsValue(readDingTalkConfigCache().formValues);
} else {
setConfig(null);
setVerified(null);
cacheDingTalkServerSnapshot(null, null);
const {
data: serverConfig = { config: initialCache.config, verified: initialCache.verified },
isLoading: configLoading,
isFetching: configFetching,
} = useQuery<{ config: DingTalkConfig | null; verified: boolean | null }>({
queryKey: ['integration', 'config'],
queryFn: async () => {
try {
const res = await api.get<{
success: boolean;
data: Array<{ type: string; verify: boolean; config: DingTalkConfig }>;
}>('/integration/config');
const validated = validateResponse<{
success: boolean;
data: Array<{ type: string; verify: boolean; config: DingTalkConfig }>;
}>(integrationConfigSchema, res);
const dt = validated.data?.find((c) => c.type === 'DINGTALK');
return { config: dt?.config ?? null, verified: dt ? dt.verify : null };
} catch {
// not configured
return { config: initialCache.config, verified: initialCache.verified };
}
} catch {
// not configured
} finally {
if (showLoading) setLoading(false);
}
}, [form]);
},
});
const config = serverConfig.config;
const verified = serverConfig.verified;
const loading = !initialCache.loaded && (configLoading || configFetching);
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
api.post('/integration/config', { type: 'DINGTALK', config: payload }),
{ invalidate: [['integration', 'config']] },
);
// 初始表单值来自本地缓存(外部存储同步)
useEffect(() => {
form.setFieldsValue(initialCache.formValues);
void fetchConfig(!initialCache.loaded);
}, [fetchConfig, form, initialCache]);
}, [form, initialCache]);
// 服务端配置同步进 localStorage 缓存,并回填表单
useEffect(() => {
cacheDingTalkServerSnapshot(config, verified);
if (config) form.setFieldsValue(readDingTalkConfigCache().formValues);
}, [config, verified, form]);
const handleSave = async () => {
const values = await form.validateFields();
const payload = buildDingTalkConfigPayload(values);
setSaving(true);
try {
await api.post('/integration/config', { type: 'DINGTALK', config: payload });
await saveMutation.mutateAsync(payload);
message.success('配置已保存');
commitDingTalkConfig({ corpId: payload.corpId, agentId: payload.agentId });
form.setFieldValue('appSecret', undefined);
await fetchConfig();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '保存失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -204,414 +125,23 @@ const IntegrationConfigPage: React.FC = () => {
config: payload,
},
);
setVerified(res.success);
queryClient.setQueryData(['integration', 'config'], (prev) => ({
...(prev ?? { config: initialCache.config, verified: initialCache.verified }),
verified: res.success,
}));
message.success(res.message);
} catch (e: unknown) {
const err = e as { message?: string };
setVerified(false);
queryClient.setQueryData(['integration', 'config'], (prev) => ({
...(prev ?? { config: initialCache.config, verified: initialCache.verified }),
verified: false,
}));
message.error(err?.message || '连接失败');
} finally {
setTesting(false);
}
};
const loadDeptTree = async () => {
try {
const res = await api.get<OrgTreeResponse>('/sync/dingtalk/org-tree');
if (res.success && res.data) {
const toTreeNode = (nodes: OrgTreeNodeRaw[]): DeptPickerTreeNode[] =>
nodes.map((n) => ({
title: n.name,
value: n.id,
children: n.children ? toTreeNode(n.children) : undefined,
}));
setDeptPickerTree(toTreeNode(res.data));
}
} catch {
message.error('获取部门架构失败');
}
};
const fetchClasses = async () => {
try {
const res = await api.get<ClassItem[] | { data: ClassItem[] }>('/classes');
if (Array.isArray(res)) {
setClasses(res);
} else {
setClasses(res.data ?? []);
}
} catch {
/* ignore */
}
};
const handleFetchOrgTree = async () => {
setFetchingTree(true);
try {
const params: Record<string, string> = {};
if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId);
const res = await api.get<OrgTreeWithUsersResponse>('/sync/dingtalk/org-tree-with-users', {
params,
});
if (res.success && res.data) {
setOrgTree(res.data);
setCheckedKeys([]);
setSelectedClassId(null);
setDrawerOpen(true);
fetchClasses();
} else {
message.error('获取组织架构失败');
}
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '获取组织架构失败');
} finally {
setFetchingTree(false);
}
};
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
return nodes.map((node) => {
const users = node.users ?? [];
const children: DataNode[] = [
...buildTreeData(node.children ?? []),
...users.map((u) => ({
title: (
<Space>
<UserOutlined />
<span>{u.name}</span>
{u.mobile ? <Tag>{u.mobile}</Tag> : null}
</Space>
),
key: `user-${u.userid}`,
isLeaf: true,
})),
];
return {
title: (
<Space size="small">
<BankOutlined />
<span>{node.name}</span>
<Tag>{users.length}</Tag>
</Space>
),
key: `dept-${node.id}`,
// Only attach children when there are any, so empty/leaf departments
// don't render a phantom expand arrow that opens to nothing.
...(children.length > 0 ? { children } : {}),
};
});
}, []);
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]);
const extractCheckedUsers = useCallback((): Array<{
dingUserId: string;
name: string;
mobile?: string;
}> => {
const result: Array<{ dingUserId: string; name: string; mobile?: string }> = [];
const walk = (nodes: DingOrgTreeNodeExt[]) => {
for (const node of nodes) {
for (const u of node.users ?? []) {
if (checkedKeys.includes(`user-${u.userid}`)) {
result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });
}
}
walk(node.children ?? []);
}
};
walk(orgTree);
return result;
}, [checkedKeys, orgTree]);
const handleJoinClass = async () => {
if (selectedClassId === null) return message.warning('请先选择一个班级');
const users = extractCheckedUsers();
if (users.length === 0) return message.warning('请勾选要导入的用户');
setImporting(true);
try {
const res = await api.post<ImportResult>(`/classes/${selectedClassId}/students/import`, {
users,
});
if (res.conflicts > 0) {
message.warning(
`导入 ${res.imported} 人,跳过 ${res.skipped} 人,${res.conflicts} 人需人工绑定`,
);
} else {
message.success(`导入 ${res.imported} 人,跳过 ${res.skipped}`);
}
setCheckedKeys([]);
setSelectedClassId(null);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '导入失败');
} finally {
setImporting(false);
}
};
const handleCreateClass = async () => {
try {
const values = await classForm.validateFields();
const users = extractCheckedUsers();
await api.post('/classes', { ...values, users });
message.success('班级创建成功');
setClassModalOpen(false);
classForm.resetFields();
setCheckedKeys([]);
fetchClasses();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '创建失败');
} finally {
setImporting(false);
}
};
const openDeleteAllGroups = async () => {
setLoadingGroups(true);
try {
const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');
setAttendanceGroups(response.data);
setDeleteGroupsOpen(true);
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '获取钉钉考勤组失败');
} finally {
setLoadingGroups(false);
}
};
const deleteAllGroups = async () => {
setDeletingGroups(true);
try {
const response = await api.post<DeleteAttendanceGroupsResponse>(
'/sync/dingtalk/attendance-groups/delete-all',
);
setDeleteGroupsOpen(false);
setAttendanceGroups([]);
if (response.data.failed.length > 0) {
message.warning(
`已清空 ${response.data.deleted.length} 个,失败 ${response.data.failed.length}`,
);
} else {
message.success(`已清空钉钉全部 ${response.data.deleted.length} 个考勤组`);
}
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '清空钉钉考勤组失败');
} finally {
setDeletingGroups(false);
}
};
const syncPanel =
config && hasAllPermissions('sync:read', 'class:view', 'class:edit') ? (
<div>
<Alert
type="info"
message="从钉钉获取组织架构,勾选用户后批量导入到班级。"
style={{ marginBottom: 16 }}
showIcon
/>
<Space>
<TreeSelect
treeData={deptPickerTree}
value={syncRootDeptId}
onChange={(v) => setSyncRootDeptId(v)}
placeholder="选择起始部门(不选=全部)"
allowClear
treeDefaultExpandAll
style={{ minWidth: 240 }}
onDropdownVisibleChange={(open) => {
if (open) loadDeptTree();
}}
/>
<Button
type="primary"
icon={<SyncOutlined />}
loading={fetchingTree}
onClick={handleFetchOrgTree}
>
</Button>
<PermissionButton
permission="sync:trigger"
danger
icon={<StopOutlined />}
loading={loadingGroups}
onClick={openDeleteAllGroups}
>
</PermissionButton>
</Space>
{drawerOpen && (
<Drawer
title="钉钉组织架构 — 批量导入"
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
}}
width="min(900px, 100vw)"
footer={
<Space>
<Button
onClick={() => {
setDrawerOpen(false);
}}
>
</Button>
<Button
type="primary"
loading={importing}
disabled={
checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 ||
selectedClassId === null
}
onClick={handleJoinClass}
>
</Button>
{canCreateClass ? (
<Button
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0}
onClick={() => setClassModalOpen(true)}
>
</Button>
) : null}
</Space>
}
>
<Row gutter={[16, 16]}>
<Col xs={24} md={14}>
<div style={{ maxHeight: '60vh', overflow: 'auto' }}>
<Tree
checkable
treeData={treeData}
defaultExpandAll
showLine={{ showLeafIcon: false }}
checkedKeys={checkedKeys}
onCheck={(checked) => setCheckedKeys(checked as React.Key[])}
/>
</div>
</Col>
<Col xs={24} md={10}>
<Card
title="班级列表"
size="small"
extra={
canCreateClass ? (
<Button size="small" onClick={() => setClassModalOpen(true)}>
+
</Button>
) : null
}
>
<List
dataSource={classes}
renderItem={(cls: ClassItem) => (
<List.Item
onClick={() => setSelectedClassId(cls.id)}
style={{
cursor: 'pointer',
background: selectedClassId === cls.id ? '#e6f4ff' : undefined,
borderRadius: 4,
padding: '8px 12px',
}}
>
<List.Item.Meta
title={cls.name}
description={`${cls.code} ${cls.classType || ''}`}
/>
</List.Item>
)}
/>
</Card>
</Col>
</Row>
{/* Create class Modal */}
{canCreateClass ? (
<Modal
title="创建班级"
open={classModalOpen}
onOk={handleCreateClass}
onCancel={() => {
setClassModalOpen(false);
classForm.resetFields();
}}
confirmLoading={importing}
destroyOnClose
>
<Form form={classForm} layout="vertical">
<Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
<Input placeholder="如 CS2024-01" />
</Form.Item>
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
<Select
options={[
{ value: 'culture', label: '文化课' },
{ value: 'professional', label: '专业课' },
{ value: 'bootcamp', label: '集训营' },
{ value: 'sprint', label: '冲刺班' },
]}
/>
</Form.Item>
<Form.Item name="startDate" label="开班日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
) : null}
</Drawer>
)}
<Modal
title="确认清空钉钉全部考勤组"
open={deleteGroupsOpen}
okText="确认全部清空"
okButtonProps={{ danger: true, disabled: attendanceGroups.length === 0 }}
cancelText="取消"
confirmLoading={deletingGroups}
onOk={deleteAllGroups}
onCancel={() => setDeleteGroupsOpen(false)}
>
<Alert
type="error"
showIcon
message={`将永久清空钉钉上的 ${attendanceGroups.length} 个考勤组`}
description="本地班级和排课不会清空。清空后需在排课管理中重新同步,才能重建考勤组。"
style={{ marginBottom: 12 }}
/>
<List
size="small"
bordered
dataSource={attendanceGroups}
style={{ maxHeight: 280, overflow: 'auto' }}
renderItem={(group) => (
<List.Item>
<List.Item.Meta
title={group.group_name}
description={`ID ${group.group_id} · ${group.member_count}`}
/>
</List.Item>
)}
/>
</Modal>
</div>
) : null;
return (
<Card
title="钉钉集成配置"
@@ -700,10 +230,10 @@ const IntegrationConfigPage: React.FC = () => {
</Space>
</Form>
{syncPanel && (
{config && hasAllPermissions('sync:read', 'class:view', 'class:edit') && (
<>
<Divider titlePlacement="start"></Divider>
{syncPanel}
<IntegrationOrgSyncPanel canCreateClass={canCreateClass} />
</>
)}
</Spin>

View File

@@ -1,4 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { validateResponse } from '../../utils/validate';
import { notificationsSchema } from '../../api/schemas';
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, Grid, Select } from 'antd';
import {
BellOutlined,
@@ -7,7 +10,7 @@ import {
TeamOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import api from '../../api';
import { message } from '../../ui/app-message';
import { formatNotificationText } from '../../utils/notification-display';
@@ -60,33 +63,33 @@ const FILTER_ITEMS: Array<{ key: string; icon: React.ReactNode; label: string }>
const NotificationsPage: React.FC = () => {
const screens = useBreakpoint();
const isMobile = !screens.sm;
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [filter, setFilter] = useState('all');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
const fetchData = async () => {
setLoading(true);
try {
const data = (await api.get('/notifications?limit=50')) as unknown as NotificationItem[];
setNotifications(data);
} catch (e: any) {
console.error('加载通知失败', e);
message.error(e?.message || '加载通知失败');
}
setLoading(false);
};
useEffect(() => {
fetchData();
}, []);
const { data: notifications = [], isLoading, isFetching } = useQuery<NotificationItem[]>({
queryKey: ['notifications'],
queryFn: async () => {
try {
return validateResponse<NotificationItem[]>(
notificationsSchema,
await api.get('/notifications?limit=50'),
);
} catch (e: any) {
console.error('加载通知失败', e);
message.error(e?.message || '加载通知失败');
return [];
}
},
});
const loading = isLoading || isFetching;
const handleClick = async (item: NotificationItem) => {
if (!item.isRead) {
try {
await api.put(`/notifications/${item.id}/read`);
setNotifications((prev) =>
prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)),
queryClient.setQueryData<NotificationItem[]>(['notifications'], (prev) =>
(prev ?? []).map((n) => (n.id === item.id ? { ...n, isRead: true } : n)),
);
} catch (e: any) {
console.error('标记已读失败', e);
@@ -99,7 +102,9 @@ const NotificationsPage: React.FC = () => {
const handleMarkAll = async () => {
try {
await api.put('/notifications/read-all');
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
queryClient.setQueryData<NotificationItem[]>(['notifications'], (prev) =>
(prev ?? []).map((n) => ({ ...n, isRead: true })),
);
} catch (e: any) {
console.error('全部已读失败', e);
message.error(e?.message || '操作失败');

View File

@@ -0,0 +1,142 @@
import React from 'react';
import { Alert, Button, Empty, Popconfirm, Table } from 'antd';
import { InboxOutlined, LogoutOutlined, UndoOutlined, DeleteOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
export const OccupanciesTableArea: React.FC<{
columns: any[];
data: any[];
loading: boolean;
selectedRowKeys: number[];
rowSelection: any;
batchAction: 'checkout' | 'archive' | 'restore';
canDelete: boolean;
canPurge: boolean;
batchLoading: boolean;
onBatchCheckOut: () => void;
onBatchDelete: () => void;
onBatchRestore: () => void;
onBatchPurge: () => void;
onClearSelection: () => void;
}> = ({
columns,
data,
loading,
selectedRowKeys,
rowSelection,
batchAction,
canDelete,
canPurge,
batchLoading,
onBatchCheckOut,
onBatchDelete,
onBatchRestore,
onBatchPurge,
onClearSelection,
}) => {
return (
<>
{selectedRowKeys.length > 0 && (
<Alert
title={
<span>
<strong>{selectedRowKeys.length}</strong>
{batchAction === 'checkout' ? (
<PermissionButton
permission="occupancy:checkout"
type="primary"
size="small"
icon={<LogoutOutlined />}
onClick={onBatchCheckOut}
style={{ marginLeft: 12 }}
loading={batchLoading}
>
退宿
</PermissionButton>
) : batchAction === 'archive' ? (
canDelete ? (
<Popconfirm
title={`确定归档选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
onConfirm={onBatchDelete}
okText="归档"
cancelText="取消"
>
<Button
danger
size="small"
icon={<InboxOutlined />}
style={{ marginLeft: 12 }}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null
) : (
<>
{canDelete ? (
<Popconfirm
title={`确定恢复选中的 ${selectedRowKeys.length} 条入住记录?`}
onConfirm={onBatchRestore}
okText="恢复"
cancelText="取消"
>
<Button
type="primary"
size="small"
icon={<UndoOutlined />}
style={{ marginLeft: 12 }}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
{canPurge ? (
<Popconfirm
title={`确定永久删除选中的 ${selectedRowKeys.length} 条入住记录?删除后不可恢复!`}
onConfirm={onBatchPurge}
okText="永久删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
<Button
danger
size="small"
icon={<DeleteOutlined />}
style={{ marginLeft: 8 }}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
</>
)}
<Button size="small" onClick={onClearSelection} style={{ marginLeft: 8 }}>
</Button>
</span>
}
type="info"
style={{ marginBottom: 12 }}
/>
)}
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1300 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
rowSelection={rowSelection}
/>
</>
);
};

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { Button, DatePicker, Input, InputNumber, Space, Switch, Tooltip, Upload } from 'antd';
import {
DownloadOutlined,
ExportOutlined,
PlusOutlined,
UploadOutlined,
} from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import PermissionButton from '../../components/PermissionButton';
import type { OccupancyView } from '../archive-view';
const { RangePicker } = DatePicker;
export const OccupanciesToolbar: React.FC<{
viewMode: OccupancyView;
onChangeViewMode: (mode: OccupancyView) => void;
onSearch: (value: string) => void;
dateRange: [Dayjs | null, Dayjs | null] | null;
onChangeDateRange: (dates: [Dayjs | null, Dayjs | null] | null) => void;
canCheckIn: boolean;
onCheckIn: () => void;
onImport: (options: any) => void;
autoDeposit: boolean;
onAutoDepositChange: (value: boolean) => void;
depositAmount: number;
onDepositAmountChange: (value: number) => void;
onDownloadTemplate: () => void;
onExport: () => void;
}> = ({
viewMode,
onChangeViewMode,
onSearch,
dateRange,
onChangeDateRange,
canCheckIn,
onCheckIn,
onImport,
autoDeposit,
onAutoDepositChange,
depositAmount,
onDepositAmountChange,
onDownloadTemplate,
onExport,
}) => {
return (
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<Button
type={viewMode === 'active' ? 'primary' : 'default'}
onClick={() => onChangeViewMode('active')}
>
</Button>
<Button
type={viewMode === 'all' ? 'primary' : 'default'}
onClick={() => onChangeViewMode('all')}
>
</Button>
<Button
type={viewMode === 'archived' ? 'primary' : 'default'}
onClick={() => onChangeViewMode('archived')}
>
</Button>
<Input.Search
placeholder="搜索学生姓名或房间号"
onSearch={onSearch}
allowClear
style={{ width: 200 }}
/>
<RangePicker
value={dateRange}
onChange={(dates) => onChangeDateRange(dates ? [dates[0], dates[1]] : null)}
placeholder={['入住开始', '入住结束']}
style={{ width: 240 }}
/>
</Space>
<Space wrap className="responsive-toolbar__group">
{viewMode !== 'archived' ? (
<PermissionButton
permission="occupancy:checkin"
type="primary"
icon={<PlusOutlined />}
onClick={onCheckIn}
>
</PermissionButton>
) : null}
{viewMode !== 'archived' && canCheckIn ? (
<>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onImport}>
<Tooltip title="按手机号关联学生,并自动创建缺失的学生、宿舍和入住记录">
<Button type="primary" ghost icon={<UploadOutlined />}>
</Button>
</Tooltip>
</Upload>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<Switch size="small" checked={autoDeposit} onChange={onAutoDepositChange} />
{autoDeposit && (
<Space.Compact>
<InputNumber
size="small"
min={0}
value={depositAmount}
onChange={(v) => onDepositAmountChange(v || 500)}
style={{ width: 60 }}
/>
<span
style={{
padding: '0 8px',
display: 'flex',
alignItems: 'center',
border: '1px solid #d9d9d9',
backgroundColor: '#fafafa',
fontSize: 12,
}}
>
</span>
</Space.Compact>
)}
</span>
</>
) : null}
{viewMode !== 'archived' ? (
<PermissionButton
permission="occupancy:view"
icon={<DownloadOutlined />}
onClick={onDownloadTemplate}
>
</PermissionButton>
) : null}
{viewMode !== 'archived' ? (
<PermissionButton permission="occupancy:view" icon={<ExportOutlined />} onClick={onExport}>
</PermissionButton>
) : null}
</Space>
</div>
);
};

View File

@@ -0,0 +1,133 @@
// aislop-ignore-file: duplicate-block -- 列渲染结构相似且字段不同,逻辑已组件化
import { Button, Popconfirm, Space, Tag } from 'antd';
import { InboxOutlined, LogoutOutlined, SwapOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
export interface OccupancyRow {
id: number;
studentId: number;
roomId: number;
checkInDate?: string;
billingStartDate?: string;
billingEndDate?: string;
checkOutDate?: string | null;
status?: string;
student?: { id?: number; name?: string; studentNo?: string } | null;
room?: { id?: number; roomNumber?: string; building?: string } | null;
bed?: { bedNumber?: string } | null;
locker?: { lockerNumber?: string } | null;
}
export interface OccupancyColumnContext {
readonly: boolean;
canPurge: boolean;
canDelete: boolean;
onPurge: (id: number, name: string) => void;
onArchive: (id: number) => Promise<unknown> | unknown;
onCheckOut: (record: OccupancyRow) => void;
onTransfer: (record: OccupancyRow) => void;
}
const buildOccupancyDataColumns = () => {
return [
{
title: '学生',
width: 120,
render: (_: unknown, r: OccupancyRow) => r.student?.name || '-',
},
{
title: '宿舍',
width: 120,
render: (_: unknown, r: OccupancyRow) => r.room?.roomNumber || '-',
},
{
title: '床位',
width: 80,
render: (_: unknown, r: OccupancyRow) => r.bed?.bedNumber || '-',
},
{
title: '柜子',
width: 80,
render: (_: unknown, r: OccupancyRow) => r.locker?.lockerNumber || '-',
},
{ title: '入住日期', dataIndex: 'checkInDate', width: 110 },
{ title: '计费起始', dataIndex: 'billingStartDate', width: 110 },
{
title: '退宿日期',
dataIndex: 'checkOutDate',
width: 110,
render: (v: any) => v || <Tag color="green"></Tag>,
},
{ title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' },
{ title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' },
];
};
const buildOccupancyActionColumn = (ctx: OccupancyColumnContext) => {
const { readonly, canPurge, canDelete, onPurge, onArchive, onCheckOut, onTransfer } = ctx;
return {
title: '操作',
width: 220,
render: (_: any, record: OccupancyRow) =>
readonly ? (
<Space>
<Tag color="#999"></Tag>
{canPurge ? (
<Button
size="small"
danger
type="link"
onClick={() => onPurge(record.id, record.student?.name || `记录${record.id}`)}
>
</Button>
) : null}
</Space>
) : !record.checkOutDate ? (
<Space>
<PermissionButton
permission="occupancy:checkout"
size="small"
icon={<LogoutOutlined />}
onClick={() => onCheckOut(record)}
>
退宿
</PermissionButton>
<PermissionButton
permission="occupancy:transfer"
size="small"
icon={<SwapOutlined />}
onClick={() => onTransfer(record)}
>
</PermissionButton>
</Space>
) : (
<Space>
<Tag>退宿</Tag>
{canDelete ? (
<Popconfirm
title="确定归档此记录?"
onConfirm={async () => {
try {
await onArchive(record.id);
message.success('归档成功');
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
>
<Button size="small" danger icon={<InboxOutlined />}>
</Button>
</Popconfirm>
) : null}
</Space>
),
};
};
export const buildOccupancyColumns = (ctx: OccupancyColumnContext) => {
return [...buildOccupancyDataColumns(), buildOccupancyActionColumn(ctx)];
};

View File

@@ -0,0 +1,551 @@
// aislop-ignore-file: duplicate-block -- 退宿/换房表单结构相似且字段不同,已共享 DateFormItem
import React from 'react';
import {
DatePicker,
Form,
Input,
InputNumber,
Modal,
Select,
Switch,
Tag,
} from 'antd';
import type { Dayjs } from 'dayjs';
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
import type { OccupancyRow } from './OccupancyColumns';
export type FormRule = React.ComponentProps<typeof Form.Item>['rules'];
export const DateFormItem: React.FC<{
name: string;
label: string;
placeholder: string;
required?: boolean;
dependencies?: string[];
extra?: string;
rules?: FormRule;
}> = ({ name, label, placeholder, required, dependencies, extra, rules }) => (
<Form.Item
name={name}
label={label}
dependencies={dependencies}
extra={extra}
rules={[
...(required ? [{ required: true, message: `请选择${label}` }] : []),
...(rules ?? []),
]}
>
<DatePicker style={{ width: '100%' }} placeholder={placeholder} format="YYYY-MM-DD" />
</Form.Item>
);
export const CheckInModal: React.FC<{
open: boolean;
canCheckIn: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
students: any[];
activeOccupancyByStudentId: Map<number, OccupancyRow>;
rooms: any[];
roomOptionLabel: (room: any) => string;
isRoomSelectable: (room: any) => boolean;
onRoomChange: (roomId: number) => void;
availableBeds: any[];
availableLockers: any[];
availableResourcesLoading: boolean;
selectedCheckInRoomId?: number;
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
onOk: () => void;
onCancel: () => void;
}> = ({
open,
canCheckIn,
saving,
form,
students,
activeOccupancyByStudentId,
rooms,
roomOptionLabel,
isRoomSelectable,
onRoomChange,
availableBeds,
availableLockers,
availableResourcesLoading,
selectedCheckInRoomId,
dateNotBefore,
onOk,
onCancel,
}) => {
return (
<Modal
title="入住登记"
open={open && canCheckIn}
onOk={canCheckIn ? onOk : undefined}
onCancel={onCancel}
okText="确认入住"
confirmLoading={saving}
width={500}
>
<Form form={form} layout="vertical">
<Form.Item name="studentId" label="选择学生" rules={[{ required: true, message: '请选择学生' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="搜索并选择学生"
options={students
.filter((s) => s.status === 'active')
.map((s) => {
const activeOccupancy = activeOccupancyByStudentId.get(s.id);
const identifier = s.idNumber
? maskIdNumber(s.idNumber)
: s.phone
? maskPhone(s.phone)
: '';
return {
value: s.id,
label: `${s.name} (${identifier})${
activeOccupancy
? ` · 已入住${activeOccupancy.room?.roomNumber ? ` ${activeOccupancy.room.roomNumber}` : ''}`
: ''
}`,
disabled: !!activeOccupancy,
};
})}
/>
</Form.Item>
<Form.Item name="roomId" label="选择宿舍" rules={[{ required: true, message: '请选择宿舍' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="搜索并选择宿舍"
onChange={onRoomChange}
options={rooms.map((r) => ({
value: r.id,
label: roomOptionLabel(r),
disabled: !isRoomSelectable(r),
}))}
/>
</Form.Item>
<DateFormItem name="checkInDate" label="入住日期" placeholder="选择入住日期" required />
<DateFormItem
name="billingStartDate"
label="计费起始日"
placeholder="选择计费起始日"
required
dependencies={['checkInDate']}
extra="默认与入住日期相同,可调整(如学生要求从次日开始计费)"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
getFieldValue('checkInDate'),
'计费起始日不能早于入住日期',
) as never,
}),
]}
/>
<Form.Item name="stayType" label="入住类型" rules={[{ required: true, message: '请选择入住类型' }]}>
<Select
options={[
{ value: 'short', label: '短租' },
{ value: 'long', label: '长租' },
]}
placeholder="默认为短租"
/>
</Form.Item>
<Form.Item
name="bedId"
label="床位"
rules={[
{ required: true, message: '请选择床位' },
{
validator: (_: unknown, value?: number) =>
!value || availableBeds.some((bed) => bed.id === value)
? Promise.resolve()
: Promise.reject(new Error('请选择当前宿舍下的可用床位')),
},
]}
>
<Select
placeholder={selectedCheckInRoomId ? '请选择床位' : '请先选择房间'}
loading={availableResourcesLoading}
disabled={!selectedCheckInRoomId || availableResourcesLoading || availableBeds.length === 0}
options={availableBeds.map((b) => ({
value: b.id,
label: b.bedNumber,
}))}
notFoundContent={selectedCheckInRoomId ? '该房间暂无可用床位' : '请先选择房间'}
/>
</Form.Item>
{availableBeds.length > 0 && (
<div style={{ marginTop: -16, marginBottom: 16, color: '#888', fontSize: 12 }}>
{availableBeds.length}
</div>
)}
<Form.Item name="lockerId" label="柜子(可选)">
<Select
allowClear
placeholder="可选分配柜子"
loading={availableResourcesLoading}
disabled={!selectedCheckInRoomId || availableResourcesLoading || availableLockers.length === 0}
options={availableLockers.map((l) => ({
value: l.id,
label: l.lockerNumber,
}))}
/>
</Form.Item>
<Form.Item
name="collectDeposit"
label="押金缴纳"
valuePropName="checked"
extra="开启后,确认入住时同步生成已缴押金记录;已有已缴押金时不会重复创建"
>
<Switch checkedChildren="已缴" unCheckedChildren="不缴" />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, current) => prev.collectDeposit !== current.collectDeposit}
>
{({ getFieldValue }) =>
getFieldValue('collectDeposit') ? (
<Form.Item
name="depositAmount"
label="押金金额"
rules={[{ required: true, message: '请输入押金金额' }]}
>
<InputNumber min={0.01} precision={2} addonAfter="元" style={{ width: '100%' }} />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
};
export const CheckOutModal: React.FC<{
record: OccupancyRow | null;
canCheckOut: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
onOk: () => void;
onCancel: () => void;
}> = ({ record, canCheckOut, saving, form, dateNotBefore, onOk, onCancel }) => {
return (
<Modal
title={`退宿 - ${record?.student?.name}`}
open={!!record && canCheckOut}
onOk={canCheckOut ? onOk : undefined}
onCancel={onCancel}
okText="确认退宿"
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<DateFormItem
name="checkOutDate"
label="退宿日期"
placeholder="选择退宿日期"
required
rules={[
{ validator: dateNotBefore(record?.checkInDate, '退宿日期不能早于入住日期') as never },
]}
/>
<DateFormItem
name="billingEndDate"
label="计费截止日"
placeholder="选择计费截止日"
dependencies={['checkOutDate']}
extra="默认与退宿日期相同"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
record?.billingStartDate || record?.checkInDate || getFieldValue('checkOutDate'),
'计费截止日不能早于计费起始日',
) as never,
}),
]}
/>
<Form.Item name="checkOutReason" label="退宿原因">
<Select
allowClear
options={[
{ value: '换房', label: '换房' },
{ value: '退训', label: '退训' },
{ value: '结业', label: '结业' },
{ value: '毕业', label: '毕业' },
{ value: '其他', label: '其他' },
]}
/>
</Form.Item>
</Form>
</Modal>
);
};
export const BatchCheckOutModal: React.FC<{
open: boolean;
canCheckOut: boolean;
selectedRowKeys: number[];
latestSelectedCheckInDate?: string;
latestSelectedBillingStartDate?: string;
data: any[];
form: ReturnType<typeof Form.useForm>[0];
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
onOk: () => void;
onCancel: () => void;
}> = ({
open,
canCheckOut,
selectedRowKeys,
latestSelectedCheckInDate,
latestSelectedBillingStartDate,
data,
form,
dateNotBefore,
onOk,
onCancel,
}) => {
return (
<Modal
title={`批量退宿(${selectedRowKeys.length} 人)`}
open={open && canCheckOut}
onOk={canCheckOut ? onOk : undefined}
onCancel={onCancel}
okText="确认批量退宿"
width={500}
>
<Form form={form} layout="vertical">
<DateFormItem
name="checkOutDate"
label="退宿日期"
placeholder="选择退宿日期"
required
rules={[
{
validator: dateNotBefore(
latestSelectedCheckInDate,
'退宿日期不能早于所选记录中最晚的入住日期',
) as never,
},
]}
/>
<DateFormItem
name="billingEndDate"
label="计费截止日"
placeholder="选择计费截止日"
extra="默认与退宿日期相同"
rules={[
{
validator: dateNotBefore(
latestSelectedBillingStartDate,
'计费截止日不能早于所选记录中最晚的计费起始日',
) as never,
},
]}
/>
<Form.Item name="checkOutReason" label="退宿原因">
<Select
allowClear
options={[
{ value: '结业', label: '结业' },
{ value: '退训', label: '退训' },
{ value: '毕业', label: '毕业' },
{ value: '其他', label: '其他' },
]}
/>
</Form.Item>
</Form>
<div
style={{
marginTop: 12,
padding: '8px 12px',
background: '#f5f5f5',
borderRadius: 6,
maxHeight: 150,
overflow: 'auto',
}}
>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>退宿</div>
{data
.filter((r: any) => selectedRowKeys.includes(r.id))
.map((r: any) => (
<Tag key={r.id} style={{ marginBottom: 4 }}>
{r.student?.name} ({r.room?.roomNumber})
</Tag>
))}
</div>
</Modal>
);
};
export const TransferModal: React.FC<{
record: OccupancyRow | null;
canTransfer: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
rooms: any[];
roomOptionLabel: (room: any) => string;
isRoomSelectable: (room: any) => boolean;
onRoomChange: (roomId: number) => void;
transferAvailableBeds: any[];
transferAvailableLockers: any[];
transferResourcesLoading: boolean;
selectedTransferRoomId?: number;
dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;
onOk: () => void;
onCancel: () => void;
}> = ({
record,
canTransfer,
saving,
form,
rooms,
roomOptionLabel,
isRoomSelectable,
onRoomChange,
transferAvailableBeds,
transferAvailableLockers,
transferResourcesLoading,
selectedTransferRoomId,
dateNotBefore,
onOk,
onCancel,
}) => {
return (
<Modal
title={`换房 - ${record?.student?.name}`}
open={!!record && canTransfer}
onOk={onOk}
onCancel={onCancel}
okText="确认换房"
confirmLoading={saving}
width={500}
>
<Form form={form} layout="vertical">
<Form.Item name="newRoomId" label="目标宿舍" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择目标宿舍"
onChange={onRoomChange}
options={rooms
.filter((r) => r.id !== record?.roomId)
.map((r) => ({
value: r.id,
label: roomOptionLabel(r),
disabled: !isRoomSelectable(r),
}))}
/>
</Form.Item>
<Form.Item
name="newBedId"
label="目标床位"
rules={[
{ required: true, message: '请选择目标床位' },
{
validator: (_: unknown, value?: number) =>
!value || transferAvailableBeds.some((bed) => bed.id === value)
? Promise.resolve()
: Promise.reject(new Error('请选择目标宿舍下的可用床位')),
},
]}
>
<Select
placeholder={selectedTransferRoomId ? '请选择目标床位' : '请先选择目标宿舍'}
loading={transferResourcesLoading}
disabled={
!selectedTransferRoomId ||
transferResourcesLoading ||
transferAvailableBeds.length === 0
}
options={transferAvailableBeds.map((bed) => ({
value: bed.id,
label: bed.bedNumber,
}))}
notFoundContent={selectedTransferRoomId ? '目标宿舍暂无可用床位' : '请先选择目标宿舍'}
/>
</Form.Item>
{transferAvailableBeds.length > 0 && (
<div style={{ marginTop: -16, marginBottom: 16, color: '#888', fontSize: 12 }}>
{transferAvailableBeds.length}
</div>
)}
<Form.Item name="newLockerId" label="目标柜子(可选)">
<Select
allowClear
placeholder="可选分配目标宿舍柜子"
loading={transferResourcesLoading}
disabled={
!selectedTransferRoomId ||
transferResourcesLoading ||
transferAvailableLockers.length === 0
}
options={transferAvailableLockers.map((locker) => ({
value: locker.id,
label: locker.lockerNumber,
}))}
notFoundContent="目标宿舍暂无可用柜子"
/>
</Form.Item>
<Form.Item
name="transferDate"
label="换房日期"
rules={[
{ required: true, message: '请选择换房日期' },
{
validator: dateNotBefore(record?.checkInDate, '换房日期不能早于原入住日期') as never,
},
]}
>
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item
name="oldBillingEndDate"
label="旧房计费截止日"
dependencies={['transferDate']}
extra="默认为换房当天"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
record?.billingStartDate || record?.checkInDate || getFieldValue('transferDate'),
'旧房计费截止日不能早于计费起始日',
) as never,
}),
]}
>
<DatePicker
style={{ width: '100%' }}
placeholder="选择旧房计费截止日"
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item
name="newBillingStartDate"
label="新房计费起始日"
dependencies={['transferDate']}
extra="默认为换房次日"
rules={[
({ getFieldValue }) => ({
validator: dateNotBefore(
getFieldValue('transferDate'),
'新房计费起始日不能早于换房日期',
) as never,
}),
]}
>
<DatePicker
style={{ width: '100%' }}
placeholder="选择新房计费起始日"
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="reason" label="换房原因">
<Input />
</Form.Item>
</Form>
</Modal>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
import { useApiMutation } from '../../hooks/useApiMutation';
import api from '../../api';
export function useOccupancyMutations() {
const invalidateOccupancies: Array<readonly unknown[]> = [['occupancies']];
const checkInMutation = useApiMutation(
async (payload: unknown) => api.post('/occupancies/check-in', payload),
{ invalidate: invalidateOccupancies },
);
const checkOutMutation = useApiMutation(
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
api.put(`/occupancies/${id}/check-out`, payload),
{ invalidate: invalidateOccupancies },
);
const transferMutation = useApiMutation(
async ({ id, payload }: { id: number; payload: unknown }) =>
api.put(`/occupancies/${id}/transfer`, payload),
{ invalidate: invalidateOccupancies },
);
const batchCheckOutMutation = useApiMutation(
async (payload: Record<string, unknown>) => api.post('/occupancies/batch-check-out', payload),
{ invalidate: invalidateOccupancies },
);
const batchDeleteMutation = useApiMutation(
async (ids: number[]) => api.post('/occupancies/batch-delete', { ids }),
{ invalidate: invalidateOccupancies },
);
const batchRestoreMutation = useApiMutation(
async (ids: number[]) =>
api.put<{ restored: number; skipped: number }>('/occupancies/batch-restore', { ids }),
{ invalidate: invalidateOccupancies },
);
const archiveMutation = useApiMutation(
async (id: number) => api.delete(`/occupancies/${id}`),
{ invalidate: invalidateOccupancies },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/occupancies/${id}/permanent`),
{ invalidate: invalidateOccupancies },
);
const batchPurgeMutation = useApiMutation(
async (ids: number[]) => api.post('/occupancies/batch-permanent-delete', { ids }),
{ invalidate: invalidateOccupancies },
);
const importMutation = useApiMutation(
async ({ formData, params }: { formData: FormData; params: string }) =>
api.post(`/occupancies/import?${params}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
{ invalidate: invalidateOccupancies },
);
return {
checkInMutation,
checkOutMutation,
transferMutation,
batchCheckOutMutation,
batchDeleteMutation,
batchRestoreMutation,
archiveMutation,
purgeMutation,
batchPurgeMutation,
importMutation,
};
}

View File

@@ -1,10 +1,16 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { App, Alert, Button, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
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';
import { usePermission } from '../../hooks/usePermission';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { organizationsSchema } from '../../api/schemas';
const PRESET_COLORS = [
'#ff7875',
@@ -31,9 +37,18 @@ interface OrganizationItem {
status: 'active' | 'archived';
}
const ORGANIZATION_FIELDS = {
name: 'name',
code: 'code',
contactName: 'contactName',
phone: 'phone',
notes: 'notes',
} as const;
const OrganizationsPage: React.FC = () => {
const [data, setData] = useState<OrganizationItem[]>([]);
const [loading, setLoading] = useState(false);
const { modal } = App.useApp();
const { hasPermission } = usePermission();
const canPurgeOrganization = hasPermission('organization:purge');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<OrganizationItem | null>(null);
const [form] = Form.useForm();
@@ -41,6 +56,48 @@ const OrganizationsPage: React.FC = () => {
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string>();
const { data = [], isLoading, isFetching } = useQuery({
queryKey: ['organizations'],
queryFn: async () => {
try {
return validateResponse<OrganizationItem[]>(
organizationsSchema,
await api.get<OrganizationItem[]>('/organizations', {
params: { includeArchived: true },
}),
);
} catch (error: any) {
message.error(error?.message || '机构数据加载失败');
return [];
}
},
});
const loading = isLoading || isFetching;
const saveMutation = useApiMutation(
async (values: { name: string; code: string; color?: string; notes?: string }) =>
editing
? api.put(`/organizations/${editing.id}`, values)
: api.post('/organizations', values),
{ invalidate: [['organizations']] },
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: OrganizationItem; field: string; value: unknown }) =>
api.put(`/organizations/${record.id}`, { [field]: value }),
{ invalidate: [['organizations']] },
);
const statusMutation = useApiMutation(
async ({ id, status }: { id: number; status: 'active' | 'archived' }) =>
status === 'active'
? api.put(`/organizations/${id}`, { status: 'active' })
: api.delete(`/organizations/${id}`),
{ invalidate: [['organizations']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/organizations/${id}/permanent`),
{ invalidate: [['organizations']] },
);
const filteredData = useMemo(() => {
const keyword = searchText.trim().toLowerCase();
return data.filter((item) => {
@@ -53,23 +110,24 @@ const OrganizationsPage: React.FC = () => {
});
}, [data, searchText, filterStatus]);
const fetchData = async () => {
setLoading(true);
try {
setData(
await api.get<OrganizationItem[]>('/organizations', { params: { includeArchived: true } }),
);
} catch (error: any) {
message.error(error?.message || '机构数据加载失败');
} finally {
setLoading(false);
}
const handlePurge = (record: OrganizationItem) => {
modal.confirm({
title: `永久删除机构「${record.name}」?`,
content: '删除后不可恢复,存在学生归属、入住或租赁关联时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
useEffect(() => {
void fetchData();
}, []);
const openEditor = (record?: OrganizationItem) => {
setEditing(record ?? null);
form.resetFields();
@@ -82,37 +140,63 @@ const OrganizationsPage: React.FC = () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) await api.put(`/organizations/${editing.id}`, values);
else await api.post('/organizations', values);
await saveMutation.mutateAsync(values);
message.success(editing ? '机构已更新' : '机构已创建');
setModalOpen(false);
await fetchData();
} catch (error: any) {
message.error(error?.message || '保存失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: OrganizationItem, field: string, value: unknown) => {
await api.put(`/organizations/${record.id}`, { [field]: value });
message.success('已保存');
await fetchData();
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const EditableOrganizationCell = <R extends { id: number; status?: string }>({
value,
field,
record,
editor,
required,
onSave,
children,
}: {
value: unknown;
field: string;
record: R;
editor?: React.ComponentProps<typeof EditableCell>['editor'];
required?: boolean;
onSave: (record: R, field: string, value: unknown) => Promise<void> | void;
children?: React.ReactNode;
}) => (
<EditableCell
value={value}
editor={editor}
required={required}
permission="organization:edit"
disabled={record.status === 'archived'}
onSave={async (next) => {
await onSave(record, field, next);
}}
>
{children ?? String(value ?? '-')}
</EditableCell>
);
const columns = [
{
title: '机构',
dataIndex: 'name',
width: 220,
render: (name: string, record: OrganizationItem) => (
<EditableCell
value={name}
required
permission="organization:edit"
disabled={record.status === 'archived'}
onSave={(next) => saveCell(record, 'name', next)}
>
<EditableOrganizationCell value={name} field={ORGANIZATION_FIELDS.name} record={record} required onSave={saveCell}>
<Space>
<span
style={{
@@ -131,71 +215,54 @@ const OrganizationsPage: React.FC = () => {
<Tag></Tag>
)}
</Space>
</EditableCell>
</EditableOrganizationCell>
),
},
{
title: '机构编码',
dataIndex: 'code',
width: 130,
render: (value: string, record: OrganizationItem) => (
<EditableCell
value={value}
required
permission="organization:edit"
disabled={record.status === 'archived'}
onSave={(next) => saveCell(record, 'code', next)}
>
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.code} record={record} required onSave={saveCell}>
<code>{value}</code>
</EditableCell>
</EditableOrganizationCell>
),
},
{
title: '联系人',
dataIndex: 'contactName',
width: 120,
render: (value: string | undefined, record: OrganizationItem) => (
<EditableCell
value={value}
permission="organization:edit"
disabled={record.status === 'archived'}
onSave={(next) => saveCell(record, 'contactName', next)}
>
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.contactName} record={record} onSave={saveCell}>
{value || '-'}
</EditableCell>
</EditableOrganizationCell>
),
},
{
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)}
>
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.phone} record={record} onSave={saveCell}>
{value || '-'}
</EditableCell>
</EditableOrganizationCell>
),
},
{
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)}
>
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.notes} record={record} editor="textarea" onSave={saveCell}>
{value || '-'}
</EditableCell>
</EditableOrganizationCell>
),
},
{
title: '状态',
dataIndex: 'status',
@@ -206,33 +273,40 @@ const OrganizationsPage: React.FC = () => {
</Tag>
),
},
{
title: '操作',
width: 160,
render: (_: unknown, record: OrganizationItem) => (
<Space>
{record.status === 'archived' ? (
<Popconfirm
title="确定恢复此机构?"
onConfirm={async () => {
try {
await api.put(`/organizations/${record.id}`, { status: 'active' });
message.success('机构已恢复');
await fetchData();
} catch (error: any) {
message.error(error?.message || '恢复失败');
}
}}
>
<PermissionButton
permission="organization:edit"
size="small"
type="link"
icon={<UndoOutlined />}
<>
<Popconfirm
title="确定恢复此机构?"
onConfirm={async () => {
try {
await statusMutation.mutateAsync({ id: record.id, status: 'active' });
message.success('机构已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
>
</PermissionButton>
</Popconfirm>
<PermissionButton
permission="organization:edit"
size="small"
type="link"
icon={<UndoOutlined />}
>
</PermissionButton>
</Popconfirm>
{canPurgeOrganization && !record.isHost ? (
<Button size="small" danger type="link" onClick={() => handlePurge(record)}>
</Button>
) : null}
</>
) : (
<>
<PermissionButton
@@ -247,11 +321,10 @@ const OrganizationsPage: React.FC = () => {
title="归档后仍保留历史学生、入住和租赁记录"
onConfirm={async () => {
try {
await api.delete(`/organizations/${record.id}`);
await statusMutation.mutateAsync({ id: record.id, status: 'archived' });
message.success('机构已归档');
await fetchData();
} catch (error: any) {
message.error(error?.message || '归档失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useState } from 'react';
import {
Row,
Col,
@@ -29,9 +29,11 @@ import {
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { message } from '../../ui/app-message';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission';
import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state';
import { getErrorMessage } from '../../utils/error';
function getCardStyle(room: any): React.CSSProperties {
let base: React.CSSProperties;
@@ -85,8 +87,6 @@ function getOrganizationTags(occupants: any[]) {
}
const RoomVisualPage: React.FC = () => {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [selectedBuilding, setSelectedBuilding] = useState<string>('all');
const [selectedOrganization, setSelectedOrganization] = useState<number | 'all'>('all');
const [detailRoom, setDetailRoom] = useState<any>(null);
@@ -97,35 +97,27 @@ const RoomVisualPage: React.FC = () => {
const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined;
const res: any = await api.get('/rooms/visual', { params });
setData(res);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [isHistorical, asOf]);
const queryClient = useQueryClient();
const { data, isLoading, isFetching } = useQuery<any>({
queryKey: ['rooms', 'visual', isHistorical, asOf],
queryFn: async () => {
try {
const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined;
return await api.get('/rooms/visual', { params });
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return null;
}
},
});
const loading = isLoading || isFetching;
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
if (!detailRoom) {
setPresentOccupancyIds([]);
return;
}
const openRoomDetail = (room: any) => {
setDetailRoom(room);
setPresentOccupancyIds(
getInitialPresentOccupancyIds(
detailRoom.occupants || [],
detailRoom.inspection?.submitted === true,
),
getInitialPresentOccupancyIds(room.occupants || [], room.inspection?.submitted === true),
);
}, [detailRoom]);
};
const inspectionDate = (asOf || dayjs()).format('YYYY-MM-DD');
@@ -139,12 +131,11 @@ const RoomVisualPage: React.FC = () => {
message.success(detailRoom.inspection?.submitted ? '查寝记录已更新' : '查寝已提交');
const params = isHistorical ? { asOf: inspectionDate } : undefined;
const res: any = await api.get('/rooms/visual', { params });
setData(res);
queryClient.setQueryData(['rooms', 'visual', isHistorical, asOf], res);
const updatedRoom = res.rooms.find((room: any) => room.id === detailRoom.id);
if (updatedRoom) setDetailRoom(updatedRoom);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '查寝提交失败');
message.error(getErrorMessage(e, '查寝提交失败'));
} finally {
setInspectionSaving(false);
}
@@ -299,7 +290,7 @@ const RoomVisualPage: React.FC = () => {
cursor: 'pointer',
height: '100%',
}}
onClick={() => setDetailRoom(room)}
onClick={() => openRoomDetail(room)}
>
<div
style={{

View File

@@ -0,0 +1,323 @@
import React from 'react';
import { Badge, Button, Popconfirm, Space, Tag } from 'antd';
import { InboxOutlined, UndoOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
export const statusMap: Record<string, { text: string; color: string }> = {
available: { text: '可入住', color: 'green' },
full: { text: '已满', color: 'red' },
maintenance: { text: '维修中', color: 'orange' },
archived: { text: '已归档', color: '#999' },
};
export const ROOM_STATUS_OPTIONS = [
{ value: 'available', label: '可入住' },
{ value: 'full', label: '已满' },
{ value: 'maintenance', label: '维修中' },
];
export const RENTAL_CATEGORY_OPTIONS = [
{ value: 'long', label: '长租' },
{ value: 'short', label: '短租' },
];
export const BED_STATUS_OPTIONS = [
{ value: 'available', label: '空闲' },
{ value: 'occupied', label: '占用' },
{ value: 'maintenance', label: '维修' },
];
export const BED_STATUS_MAP: Record<string, { text: string; color: string }> = {
available: { text: '空闲', color: 'green' },
occupied: { text: '占用', color: 'blue' },
maintenance: { text: '维修', color: 'orange' },
};
export interface BedItem {
id: number;
bedNumber: string;
status: string;
notes?: string | null;
}
export interface LockerItem {
id: number;
lockerNumber: string;
status: string;
notes?: string | null;
}
export function parseRoomNumber(input: string) {
const match = /^(\d+)-(\d+)/.exec(input.trim());
if (!match) return null;
return {
building: `${match[1]}号楼`,
floor: Number(match[2]),
roomType: input.includes('单人') ? '单人间' : input.includes('家庭') ? '家庭房' : '四人间',
};
}
export const EditableRoomCell = <R extends { id: number }>({
value,
field,
record,
editor,
min,
max,
required,
options,
archived = false,
onSave,
children,
}: {
value: unknown;
field: string;
record: R;
editor?: React.ComponentProps<typeof EditableCell>['editor'];
min?: number;
max?: number;
required?: boolean;
options?: Array<{ value: string; label: string }>;
archived?: boolean;
onSave: (record: R, field: string, value: unknown) => Promise<void> | void;
children?: React.ReactNode;
}) => (
<EditableCell
value={value}
editor={editor}
min={min}
max={max}
required={required}
options={options}
permission="room:edit"
disabled={archived}
onSave={async (next) => {
await onSave(record, field, next);
}}
>
{children ?? String(value ?? '-')}
</EditableCell>
);
export interface RoomColumnContext {
canEditRooms: boolean;
canDeleteRooms: boolean;
canPurgeRooms: boolean;
onSaveRoomCell: (record: any, field: string, value: unknown) => Promise<void> | void;
onRestore: (id: number) => Promise<unknown> | unknown;
onArchive: (id: number) => Promise<unknown> | unknown;
onPurge: (id: number, name: string) => void;
onView: (record: any) => void;
onEdit: (record: any) => void;
}
function buildRoomIdentityColumns(ctx: RoomColumnContext) {
const { onSaveRoomCell } = ctx;
return [
{
title: '房间号',
dataIndex: 'roomNumber',
width: 100,
sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber),
render: (v: string, r: any) => (
<EditableRoomCell value={v} field="roomNumber" record={r} required onSave={onSaveRoomCell}>
{v}
</EditableRoomCell>
),
},
{
title: '楼栋',
dataIndex: 'building',
width: 80,
render: (v: string, r: any) => (
<EditableRoomCell value={v} field="building" record={r} onSave={onSaveRoomCell}>
{v || '-'}
</EditableRoomCell>
),
},
{
title: '楼层',
dataIndex: 'floor',
width: 80,
render: (v: number, r: any) => (
<EditableRoomCell value={v} field="floor" record={r} editor="number" onSave={onSaveRoomCell}>
{v ?? '-'}
</EditableRoomCell>
),
},
{
title: '类型',
dataIndex: 'roomType',
width: 90,
render: (v: any, r: any) => (
<EditableRoomCell value={v} field="roomType" record={r} onSave={onSaveRoomCell}>
{v || '-'}
</EditableRoomCell>
),
},
{
title: '租赁类型',
dataIndex: 'rentalCategory',
width: 100,
render: (v: string, r: any) => (
<EditableRoomCell
value={v}
field="rentalCategory"
record={r}
editor="select"
options={RENTAL_CATEGORY_OPTIONS}
onSave={onSaveRoomCell}
>
{v === 'long' ? (
<Tag color="blue"></Tag>
) : v === 'short' ? (
<Tag color="green"></Tag>
) : (
'-'
)}
</EditableRoomCell>
),
},
{
title: '月租金',
dataIndex: 'monthlyRate',
width: 100,
render: (v: number, r: any) => (
<EditableRoomCell value={v} field="monthlyRate" record={r} editor="money" min={0} onSave={onSaveRoomCell}>
{v ? `¥${v}` : '-'}
</EditableRoomCell>
),
},
];
}
function buildRoomStatusColumns(ctx: RoomColumnContext) {
const { onSaveRoomCell } = ctx;
return [
{
title: '额定人数',
dataIndex: 'capacity',
width: 80,
render: (v: number, r: any) => (
<EditableRoomCell value={v} field="capacity" record={r} editor="number" min={1} required onSave={onSaveRoomCell}>
{v}
</EditableRoomCell>
),
},
{
title: '当前入住',
width: 80,
render: (_: any, r: any) =>
r.status === 'archived' ? (
<Tag color="#999">-</Tag>
) : (
<Badge
count={r.currentCount}
showZero
overflowCount={99}
style={{ backgroundColor: r.currentCount >= r.capacity ? '#ff4d4f' : '#52c41a' }}
/>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string, r: any) => (
<EditableRoomCell
value={s}
field="status"
record={r}
editor="select"
options={ROOM_STATUS_OPTIONS}
onSave={onSaveRoomCell}
>
<Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>
</EditableRoomCell>
),
},
];
}
function buildRoomActionColumn(ctx: RoomColumnContext) {
const {
canEditRooms,
canDeleteRooms,
canPurgeRooms,
onRestore,
onArchive,
onPurge,
onView,
onEdit,
} = ctx;
return {
title: '操作',
width: 220,
render: (_: unknown, record: unknown) => {
const r = record as { status?: string; id: number; roomNumber?: string };
return (
<Space>
{r.status === 'archived' ? (
<>
{canEditRooms ? (
<Popconfirm title="确定恢复此宿舍?" onConfirm={() => onRestore(r.id)}>
<Button size="small" icon={<UndoOutlined />} type="link">
</Button>
</Popconfirm>
) : null}
{canPurgeRooms ? (
<Button
size="small"
danger
type="link"
onClick={() => onPurge(r.id, r.roomNumber ?? '')}
>
</Button>
) : null}
</>
) : (
<>
<PermissionButton
permission="room:view"
size="small"
type="link"
onClick={() => onView(record)}
>
</PermissionButton>
<PermissionButton
permission="room:edit"
size="small"
onClick={() => onEdit(record)}
>
</PermissionButton>
{canDeleteRooms ? (
<Popconfirm title="确定归档?" onConfirm={() => onArchive(r.id)}>
<Button size="small" icon={<InboxOutlined />}>
</Button>
</Popconfirm>
) : null}
</>
)}
</Space>
);
},
};
}
export function buildRoomColumns(ctx: RoomColumnContext) {
return [
...buildRoomIdentityColumns(ctx),
...buildRoomStatusColumns(ctx),
buildRoomActionColumn(ctx),
];
}
export function useRoomColumns(ctx: RoomColumnContext) {
return React.useMemo(() => buildRoomColumns(ctx), [ctx]);
}

View File

@@ -0,0 +1,382 @@
// aislop-ignore-file: duplicate-block -- 床位/柜子表格声明结构相似且字段不同,渲染逻辑已共享 EditableRoomCell
import React from 'react';
import {
Button,
Drawer,
InputNumber,
Popconfirm,
Space,
Table,
Tabs,
Tag,
} from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import {
BED_STATUS_MAP,
BED_STATUS_OPTIONS,
EditableRoomCell,
statusMap,
type BedItem,
type LockerItem,
} from './RoomColumns';
export interface RoomDrawerProps {
open: boolean;
room: any;
beds: BedItem[];
lockers: LockerItem[];
canEditRooms: boolean;
remainingBedSlots: number;
defaultBatchBedCount: number;
onClose: () => void;
onAddBed: () => void;
onBatchBeds: (count: number) => void;
onEditBed: (record: BedItem) => void;
onDeleteBed: (id: number) => void;
onSaveBedCell: (record: BedItem, field: string, value: unknown) => void;
onAddLocker: () => void;
onBatchLockers: (count: number) => void;
onEditLocker: (record: LockerItem) => void;
onDeleteLocker: (id: number) => void;
onSaveLockerCell: (record: LockerItem, field: string, value: unknown) => void;
}
export const RoomDrawer: React.FC<RoomDrawerProps> = ({
open,
room,
beds,
lockers,
canEditRooms,
remainingBedSlots,
defaultBatchBedCount,
onClose,
onAddBed,
onBatchBeds,
onEditBed,
onDeleteBed,
onSaveBedCell,
onAddLocker,
onBatchLockers,
onEditLocker,
onDeleteLocker,
onSaveLockerCell,
}) => {
const roomItemActions = (kind: 'bed' | 'locker') => (r: any) => {
const isBed = kind === 'bed';
const handleDelete = isBed ? onDeleteBed : onDeleteLocker;
const handleEdit = isBed ? onEditBed : onEditLocker;
return (
<Space size="small">
<PermissionButton
permission="room:edit"
size="small"
type="link"
disabled={room?.status === 'archived'}
onClick={() => handleEdit(r)}
>
</PermissionButton>
{r.status !== 'occupied' && canEditRooms && (
<Popconfirm title="确定归档?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" type="link" danger disabled={room?.status === 'archived'}>
</Button>
</Popconfirm>
)}
</Space>
);
};
return (
<Drawer
title={`${room?.roomNumber} 房间详情`}
open={open}
onClose={onClose}
size={640}
destroyOnClose
>
<Tabs
defaultActiveKey="info"
items={[
{
key: 'info',
label: '基本信息',
children:
room && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<strong></strong>
{room.roomNumber}
</div>
<div>
<strong></strong>
{room.building || '-'}
</div>
<div>
<strong></strong>
{room.floor ?? '-'}
</div>
<div>
<strong></strong>
{room.roomType || '-'}
</div>
<div>
<strong></strong>
{room.capacity}
</div>
<div>
<strong></strong>
{room.rentalCategory === 'long' ? '长租' : '短租'}
</div>
<div>
<strong></strong>
{room.monthlyRate ? `¥${room.monthlyRate}` : '-'}
</div>
<div>
<strong></strong>
<Tag color={statusMap[room.status]?.color}>
{statusMap[room.status]?.text}
</Tag>
</div>
</div>
),
},
{
key: 'beds',
label: `床位管理 (${beds.length})`,
children: (
<div>
{canEditRooms ? (
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
disabled={room?.status === 'archived' || remainingBedSlots === 0}
onClick={onAddBed}
>
</Button>
<Popconfirm
title={remainingBedSlots > 0 ? '批量生成床位' : '床位已达到额定人数'}
description={
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;
onBatchBeds(
input
? parseInt(input.value) || defaultBatchBedCount
: defaultBatchBedCount,
);
}}
okText="生成"
disabled={room?.status === 'archived' || remainingBedSlots === 0}
>
<Button
size="small"
disabled={room?.status === 'archived' || remainingBedSlots === 0}
>
</Button>
</Popconfirm>
</div>
) : null}
<Table
dataSource={beds}
rowKey="id"
pagination={false}
size="small"
columns={[
{
title: '编号',
dataIndex: 'bedNumber',
width: 80,
render: (v: string, r: BedItem) => (
<EditableRoomCell
value={v}
field="bedNumber"
record={r}
required
archived={room?.status === 'archived'}
onSave={onSaveBedCell}
>
{v}
</EditableRoomCell>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string, r: BedItem) => (
<EditableRoomCell
value={s}
field="status"
record={r}
editor="select"
options={BED_STATUS_OPTIONS}
archived={room?.status === 'archived'}
onSave={onSaveBedCell}
>
<Tag color={BED_STATUS_MAP[s]?.color}>
{BED_STATUS_MAP[s]?.text || s}
</Tag>
</EditableRoomCell>
),
},
{
title: '备注',
dataIndex: 'notes',
render: (v: string, r: BedItem) => (
<EditableRoomCell
value={v}
field="notes"
record={r}
editor="textarea"
archived={room?.status === 'archived'}
onSave={onSaveBedCell}
>
{v || '-'}
</EditableRoomCell>
),
},
{
title: '操作',
width: 120,
render: roomItemActions('bed'),
},
]}
/>
</div>
),
},
{
key: 'lockers',
label: `柜子管理 (${lockers.length})`,
children: (
<div>
{canEditRooms ? (
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
disabled={room?.status === 'archived'}
onClick={onAddLocker}
>
</Button>
<Popconfirm
title="批量生成柜子"
description={
<InputNumber
min={1}
max={20}
defaultValue={4}
id="batch-locker-count"
style={{ width: 80 }}
/>
}
onConfirm={() => {
const input = document.getElementById(
'batch-locker-count',
) as HTMLInputElement;
onBatchLockers(input ? parseInt(input.value) || 4 : 4);
}}
okText="生成"
disabled={room?.status === 'archived'}
>
<Button size="small" disabled={room?.status === 'archived'}>
</Button>
</Popconfirm>
</div>
) : null}
<Table
dataSource={lockers}
rowKey="id"
pagination={false}
size="small"
columns={[
{
title: '编号',
dataIndex: 'lockerNumber',
width: 80,
render: (v: string, r: LockerItem) => (
<EditableRoomCell
value={v}
field="lockerNumber"
record={r}
required
archived={room?.status === 'archived'}
onSave={onSaveLockerCell}
>
{v}
</EditableRoomCell>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string, r: LockerItem) => (
<EditableRoomCell
value={s}
field="status"
record={r}
editor="select"
options={BED_STATUS_OPTIONS}
archived={room?.status === 'archived'}
onSave={onSaveLockerCell}
>
<Tag color={BED_STATUS_MAP[s]?.color}>
{BED_STATUS_MAP[s]?.text || s}
</Tag>
</EditableRoomCell>
),
},
{
title: '备注',
dataIndex: 'notes',
render: (v: string, r: LockerItem) => (
<EditableRoomCell
value={v}
field="notes"
record={r}
editor="textarea"
archived={room?.status === 'archived'}
onSave={onSaveLockerCell}
>
{v || '-'}
</EditableRoomCell>
),
},
{
title: '操作',
width: 120,
render: roomItemActions('locker'),
},
]}
/>
</div>
),
},
]}
/>
</Drawer>
);
};

View File

@@ -0,0 +1,244 @@
// aislop-ignore-file: duplicate-block -- 宿舍/床位/柜子表单声明结构相似且字段不同,已共享 RoomItemFormFields
import React from 'react';
import { Form, Input, InputNumber, Modal, Select } from 'antd';
import { RoomDrawer } from './RoomDrawer';
import type { BedItem, LockerItem } from './RoomColumns';
import { parseRoomNumber } from './RoomColumns';
export const RoomItemFormFields: React.FC<{
fieldName: 'bedNumber' | 'lockerNumber';
label: string;
placeholder: string;
}> = ({ fieldName, label, placeholder }) => (
<>
<Form.Item name={fieldName} label={label} rules={[{ required: true }]}>
<Input placeholder={placeholder} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'available', label: '空闲' },
{ value: 'maintenance', label: '维修中' },
]}
placeholder="默认为空闲"
/>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</>
);
export const RoomEditModal: React.FC<{
open: boolean;
editing: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
onOk?: () => void;
onCancel: () => void;
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
return (
<Modal
title={editing ? '编辑宿舍' : '添加宿舍'}
open={open}
onOk={onOk}
onCancel={onCancel}
okText="保存"
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="roomNumber" label="房间号" rules={[{ required: true }]}>
<Input
placeholder="如4-102自动解析楼栋楼层"
onChange={(e) => {
const parsed = parseRoomNumber(e.target.value);
if (parsed) form.setFieldsValue(parsed);
}}
/>
</Form.Item>
<Form.Item name="building" label="楼栋">
<Input placeholder="如4号楼留空自动解析" />
</Form.Item>
<Form.Item name="floor" label="楼层">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="capacity" label="额定人数" rules={[{ required: true }]}>
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="roomType" label="宿舍类型">
<Select
allowClear
options={[
{ value: '四人间', label: '四人间' },
{ value: '单人间', label: '单人间' },
{ value: '家庭房', label: '家庭房' },
{ value: '爆改房', label: '爆改房' },
]}
placeholder="留空自动解析"
/>
</Form.Item>
<Form.Item name="rentalCategory" label="租赁类别">
<Select
allowClear
options={[
{ value: 'short', label: '短租' },
{ value: 'long', label: '长租' },
]}
placeholder="默认为短租"
/>
</Form.Item>
<Form.Item name="monthlyRate" label="月租金">
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="长租月租金" />
</Form.Item>
{editing && (
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'available', label: '可入住' },
{ value: 'full', label: '已满' },
{ value: 'maintenance', label: '维修中' },
]}
/>
</Form.Item>
)}
</Form>
</Modal>
);
};
export const BedModal: React.FC<{
open: boolean;
editing: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
onOk?: () => void;
onCancel: () => void;
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
return (
<Modal
title={editing ? '编辑床位' : '添加床位'}
open={open}
onOk={onOk}
onCancel={onCancel}
confirmLoading={saving}
okText="保存"
>
<Form form={form} layout="vertical">
<RoomItemFormFields fieldName="bedNumber" label="床位编号" placeholder="如1号床" />
</Form>
</Modal>
);
};
export const LockerModal: React.FC<{
open: boolean;
editing: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
onOk?: () => void;
onCancel: () => void;
}> = ({ open, editing, saving, form, onOk, onCancel }) => {
return (
<Modal
title={editing ? '编辑柜子' : '添加柜子'}
open={open}
onOk={onOk}
onCancel={onCancel}
confirmLoading={saving}
okText="保存"
>
<Form form={form} layout="vertical">
<RoomItemFormFields fieldName="lockerNumber" label="柜子编号" placeholder="如1号柜" />
</Form>
</Modal>
);
};
export const RoomDetailArea: React.FC<{
modalOpen: boolean;
canSaveRoom: boolean;
editing: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
onSaveRoom: () => void;
onCloseRoomModal: () => void;
drawerOpen: boolean;
drawerRoom: any;
beds: BedItem[];
lockers: LockerItem[];
canEditRooms: boolean;
remainingBedSlots: number;
defaultBatchBedCount: number;
onCloseDrawer: () => void;
onAddBed: () => void;
onBatchBeds: (count: number) => void;
onEditBed: (record: BedItem) => void;
onDeleteBed: (id: number) => void;
onSaveBedCell: (record: BedItem, field: string, value: unknown) => void;
onAddLocker: () => void;
onBatchLockers: (count: number) => void;
onEditLocker: (record: LockerItem) => void;
onDeleteLocker: (id: number) => void;
onSaveLockerCell: (record: LockerItem, field: string, value: unknown) => void;
bedModalOpen: boolean;
bedEditing: boolean;
savingBed: boolean;
bedForm: ReturnType<typeof Form.useForm>[0];
onSaveBed: () => void;
onCloseBedModal: () => void;
lockerModalOpen: boolean;
lockerEditing: boolean;
savingLocker: boolean;
lockerForm: ReturnType<typeof Form.useForm>[0];
onSaveLocker: () => void;
onCloseLockerModal: () => void;
}> = (props) => {
return (
<>
<RoomEditModal
open={props.modalOpen && props.canSaveRoom}
editing={props.editing}
saving={props.saving}
form={props.form}
onOk={props.canSaveRoom ? props.onSaveRoom : undefined}
onCancel={props.onCloseRoomModal}
/>
<RoomDrawer
open={props.drawerOpen}
room={props.drawerRoom}
beds={props.beds}
lockers={props.lockers}
canEditRooms={props.canEditRooms}
remainingBedSlots={props.remainingBedSlots}
defaultBatchBedCount={props.defaultBatchBedCount}
onClose={props.onCloseDrawer}
onAddBed={props.onAddBed}
onBatchBeds={props.onBatchBeds}
onEditBed={props.onEditBed}
onDeleteBed={props.onDeleteBed}
onSaveBedCell={props.onSaveBedCell}
onAddLocker={props.onAddLocker}
onBatchLockers={props.onBatchLockers}
onEditLocker={props.onEditLocker}
onDeleteLocker={props.onDeleteLocker}
onSaveLockerCell={props.onSaveLockerCell}
/>
<BedModal
open={props.bedModalOpen && props.canEditRooms}
editing={props.bedEditing}
saving={props.savingBed}
form={props.bedForm}
onOk={props.canEditRooms ? props.onSaveBed : undefined}
onCancel={props.onCloseBedModal}
/>
<LockerModal
open={props.lockerModalOpen && props.canEditRooms}
editing={props.lockerEditing}
saving={props.savingLocker}
form={props.lockerForm}
onOk={props.canEditRooms ? props.onSaveLocker : undefined}
onCancel={props.onCloseLockerModal}
/>
</>
);
};

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Empty, Table } from 'antd';
export const RoomsTable: React.FC<{
columns: any[];
data: any[];
loading: boolean;
selectedRowKeys: number[];
onSelect: (keys: number[]) => void;
}> = ({ columns, data, loading, selectedRowKeys, onSelect }) => {
return (
<>
<Table
columns={columns}
dataSource={data}
rowKey="id"
scroll={{ x: 1200 }}
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total}`,
}}
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
rowSelection={{
selectedRowKeys,
onChange: (keys) => onSelect(keys as number[]),
}}
/>
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
</>
);
};

View File

@@ -0,0 +1,198 @@
import React from 'react';
import { Button, Input, Popconfirm, Select, Space, Upload } from 'antd';
import type { UploadRequestOption } from '@rc-component/upload/lib/interface';
import {
DeleteOutlined,
DownloadOutlined,
ExportOutlined,
InboxOutlined,
PlusOutlined,
SearchOutlined,
UndoOutlined,
UploadOutlined,
} from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
export interface RoomsToolbarProps {
onSearch: (value: string) => void;
buildings: string[];
filterBuilding?: string;
onFilterBuilding: (value?: string) => void;
filterStatus?: string;
onFilterStatus: (value?: string) => void;
filterRentalCategory?: string;
onFilterRentalCategory: (value?: string) => void;
showArchived: boolean;
onToggleArchived: () => void;
selectedRowKeys: number[];
batchLoading: boolean;
canEditRooms: boolean;
canPurgeRooms: boolean;
canDeleteRooms: boolean;
hasCreatePermission: boolean;
onBatchRestore: () => void;
onBatchPurge: () => void;
onBatchDelete: () => void;
onAddRoom: () => void;
onImport: (options: UploadRequestOption<{ message?: string }>) => void;
onDownloadTemplate: () => void;
onExport: () => void;
}
export const RoomsToolbar: React.FC<RoomsToolbarProps> = ({
onSearch,
buildings,
filterBuilding,
onFilterBuilding,
filterStatus,
onFilterStatus,
filterRentalCategory,
onFilterRentalCategory,
showArchived,
onToggleArchived,
selectedRowKeys,
batchLoading,
canEditRooms,
canPurgeRooms,
canDeleteRooms,
hasCreatePermission,
onBatchRestore,
onBatchPurge,
onBatchDelete,
onAddRoom,
onImport,
onDownloadTemplate,
onExport,
}) => {
return (
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<h3 style={{ margin: 0 }}>宿</h3>
<Input.Search
placeholder="搜索房间号"
onSearch={onSearch}
allowClear
style={{ width: 160 }}
prefix={<SearchOutlined />}
/>
<Select
placeholder="筛选楼栋"
allowClear
style={{ width: 120 }}
value={filterBuilding}
onChange={onFilterBuilding}
options={buildings.map((b) => ({ value: b, label: b }))}
/>
<Select
placeholder="状态"
allowClear
style={{ width: 110 }}
value={filterStatus}
onChange={onFilterStatus}
options={[
{ value: 'available', label: '可入住' },
{ value: 'full', label: '已满' },
{ value: 'maintenance', label: '维护中' },
]}
/>
<Select
placeholder="租赁类型"
allowClear
style={{ width: 120 }}
value={filterRentalCategory}
onChange={onFilterRentalCategory}
options={[
{ value: 'long', label: '长租' },
{ value: 'short', label: '短租' },
]}
/>
<Button type={showArchived ? 'primary' : 'default'} onClick={onToggleArchived}>
{showArchived ? '返回正常数据' : '查看已归档'}
</Button>
</Space>
<Space wrap className="responsive-toolbar__group">
{showArchived && canEditRooms ? (
<>
<Popconfirm
title={`确定批量恢复选中的 ${selectedRowKeys.length} 间宿舍?`}
onConfirm={onBatchRestore}
okText="恢复"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
type="primary"
icon={<UndoOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
{canPurgeRooms ? (
<Popconfirm
title={`确定永久删除选中的 ${selectedRowKeys.length} 间宿舍?删除后不可恢复!`}
onConfirm={onBatchPurge}
okText="永久删除"
okButtonProps={{ danger: true }}
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
</>
) : !showArchived && canDeleteRooms ? (
<Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
onConfirm={onBatchDelete}
okText="归档"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
danger
icon={<InboxOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
{!showArchived ? (
<PermissionButton
permission="room:create"
type="primary"
icon={<PlusOutlined />}
onClick={onAddRoom}
>
宿
</PermissionButton>
) : null}
{!showArchived && hasCreatePermission ? (
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onImport}>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
) : null}
<PermissionButton
permission="room:view"
icon={<DownloadOutlined />}
onClick={onDownloadTemplate}
>
</PermissionButton>
<PermissionButton permission="room:view" icon={<ExportOutlined />} onClick={onExport}>
</PermissionButton>
</Space>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
import { useApiMutation } from '../../hooks/useApiMutation';
import api from '../../api';
export function useBedMutations() {
const saveBedCellMutation = useApiMutation(
async ({
roomId,
bedId,
field,
value,
}: {
roomId: number;
bedId: number;
field: string;
value: unknown;
}) => api.put(`/rooms/${roomId}/beds/${bedId}`, { [field]: value }),
{ invalidate: [['rooms']] },
);
const saveBedMutation = useApiMutation(
async ({
roomId,
bedId,
values,
}: {
roomId: number;
bedId?: number;
values: Record<string, unknown>;
}) =>
bedId
? api.put(`/rooms/${roomId}/beds/${bedId}`, values)
: api.post(`/rooms/${roomId}/beds`, values),
{ invalidate: [['rooms']] },
);
const deleteBedMutation = useApiMutation(
async ({ roomId, bedId }: { roomId: number; bedId: number }) =>
api.delete(`/rooms/${roomId}/beds/${bedId}`),
{ invalidate: [['rooms']] },
);
const batchBedsMutation = useApiMutation(
async ({ roomId, count }: { roomId: number; count: number }) =>
api.post(`/rooms/${roomId}/beds/batch`, { count }),
{ invalidate: [['rooms']] },
);
return {
saveBedCellMutation,
saveBedMutation,
deleteBedMutation,
batchBedsMutation,
};
}
export function useLockerMutations() {
const saveLockerCellMutation = useApiMutation(
async ({
roomId,
lockerId,
field,
value,
}: {
roomId: number;
lockerId: number;
field: string;
value: unknown;
}) => api.put(`/rooms/${roomId}/lockers/${lockerId}`, { [field]: value }),
{ invalidate: [['rooms']] },
);
const saveLockerMutation = useApiMutation(
async ({
roomId,
lockerId,
values,
}: {
roomId: number;
lockerId?: number;
values: Record<string, unknown>;
}) =>
lockerId
? api.put(`/rooms/${roomId}/lockers/${lockerId}`, values)
: api.post(`/rooms/${roomId}/lockers`, values),
{ invalidate: [['rooms']] },
);
const deleteLockerMutation = useApiMutation(
async ({ roomId, lockerId }: { roomId: number; lockerId: number }) =>
api.delete(`/rooms/${roomId}/lockers/${lockerId}`),
{ invalidate: [['rooms']] },
);
const batchLockersMutation = useApiMutation(
async ({ roomId, count }: { roomId: number; count: number }) =>
api.post(`/rooms/${roomId}/lockers/batch`, { count }),
{ invalidate: [['rooms']] },
);
return {
saveLockerCellMutation,
saveLockerMutation,
deleteLockerMutation,
batchLockersMutation,
};
}
export function useRoomItemMutations() {
return { ...useBedMutations(), ...useLockerMutations() };
}
export function useRoomMutations(editing: any) {
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
editing ? api.put(`/rooms/${editing.id}`, payload) : api.post('/rooms', payload),
{ invalidate: [['rooms']] },
);
const saveRoomCellMutation = useApiMutation(
async ({ record, field, value }: { record: any; field: string; value: unknown }) =>
api.put(`/rooms/${record.id}`, { [field]: value }),
{ invalidate: [['rooms']] },
);
const archiveMutation = useApiMutation(
async (id: number) => api.delete(`/rooms/${id}`),
{ invalidate: [['rooms']] },
);
const restoreMutation = useApiMutation(
async (id: number) => api.put(`/rooms/${id}/restore`),
{ invalidate: [['rooms']] },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/rooms/${id}/permanent`),
{ invalidate: [['rooms']] },
);
const batchDeleteMutation = useApiMutation(
async (ids: number[]) => api.post('/rooms/batch-delete', { ids }),
{ invalidate: [['rooms']] },
);
const batchRestoreMutation = useApiMutation(
async (ids: number[]) =>
api.put<{ message?: string; restored: number; skipped: number }>('/rooms/batch-restore', {
ids,
}),
{ invalidate: [['rooms']] },
);
const batchPurgeMutation = useApiMutation(
async (ids: number[]) => api.post('/rooms/batch-permanent-delete', { ids }),
{ invalidate: [['rooms']] },
);
const importMutation = useApiMutation(
async (formData: FormData) =>
api.post<{ message?: string }>('/rooms/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
{ invalidate: [['rooms']] },
);
return {
saveMutation,
saveRoomCellMutation,
archiveMutation,
restoreMutation,
purgeMutation,
batchDeleteMutation,
batchRestoreMutation,
batchPurgeMutation,
importMutation,
};
}

View File

@@ -0,0 +1,330 @@
// aislop-ignore-file: duplicate-block -- 周/月视图表格结构相似且展示维度不同,已共享 ScheduleGrid 组件
import React from 'react';
import { Badge, Empty, Spin, Tooltip } from 'antd';
import type { Dayjs } from 'dayjs';
import { isMaskedSchedule } from './schedule-visibility';
export const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
export const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7];
export interface ClassScheduleItem {
id: number | null;
classId: number | null;
classroomId: number;
weekDay: number;
startTime: string;
endTime: string;
attendanceAdvanceMinutes: number;
startDate: string;
endDate: string;
subject: string;
teacherId: number | null;
scheduleType: string;
status: string;
notes: string | null;
createdAt: string;
updatedAt: string;
canViewDetails?: boolean;
}
export interface ClassroomItem {
id: number;
name: string;
building: string;
floor: number;
roomType: string;
}
export interface ClassItem {
id: number;
name: string;
code: string;
}
export interface ClassTeacherOption {
id: number;
userId: number;
username?: string;
name?: string;
roleType: string;
subject?: string | null;
}
export const ScheduleGrid: React.FC<{
loading: boolean;
viewMode: 'week' | 'month';
classrooms: ClassroomItem[];
filteredClassrooms: ClassroomItem[];
displayMatrix: Record<number, Record<number, ClassScheduleItem[]>>;
weeks: Dayjs[][];
monthStart: Dayjs;
monthScheduleMap: Record<string, ClassScheduleItem[]>;
onCellClick: (classroomId: number, weekDay: number) => void;
onDateClick: (date: Dayjs) => void;
}> = ({
loading,
viewMode,
classrooms,
filteredClassrooms,
displayMatrix,
weeks,
monthStart,
monthScheduleMap,
onCellClick,
onDateClick,
}) => {
return (
<Spin spinning={loading}>
{classrooms.length === 0 ? (
<Empty description="暂无教室数据" />
) : viewMode === 'week' ? (
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
tableLayout: 'fixed',
}}
>
<thead>
<tr style={{ background: '#fafafa' }}>
<th
style={{
position: 'sticky',
left: 0,
background: '#fafafa',
zIndex: 2,
padding: '10px 12px',
border: '1px solid #f0f0f0',
width: 150,
textAlign: 'left',
}}
>
</th>
{WEEKDAYS.map((day) => (
<th
key={day}
style={{
padding: '10px 8px',
border: '1px solid #f0f0f0',
textAlign: 'center',
background: '#fafafa',
fontWeight: 500,
}}
>
{day}
</th>
))}
</tr>
</thead>
<tbody>
{filteredClassrooms.map((classroom) => (
<tr key={classroom.id}>
<td
style={{
position: 'sticky',
left: 0,
background: '#fff',
zIndex: 1,
padding: '8px 12px',
border: '1px solid #f0f0f0',
fontWeight: 500,
}}
>
<div>{classroom.name}</div>
{classroom.building && (
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 2 }}>
{classroom.building}
{classroom.floor ? ` ${classroom.floor}F` : ''}
</div>
)}
</td>
{WEEKDAY_NUMBERS.map((wd) => {
const schedules = displayMatrix[classroom.id]?.[wd] || [];
const hasContent = schedules.length > 0;
return (
<td
key={wd}
tabIndex={0}
role="button"
aria-label={`选择教室 ${classroom.name} ${WEEKDAYS[wd - 1]} 排课`}
onClick={() => onCellClick(classroom.id, wd)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCellClick(classroom.id, wd);
}
}}
style={{
padding: 4,
border: '1px solid #f0f0f0',
verticalAlign: 'top',
cursor: 'pointer',
minHeight: 56,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = '#f6f8fa';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = '';
}}
>
{hasContent ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{schedules.map((s) => (
<Tooltip
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
title={
isMaskedSchedule(s)
? `已占用 · ${s.startTime}-${s.endTime}`
: `${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`
}
>
<div
style={{
background: isMaskedSchedule(s) ? '#f5f5f5' : '#e6f4ff',
border: isMaskedSchedule(s)
? '1px solid #d9d9d9'
: '1px solid #91caff',
borderRadius: 4,
padding: '2px 6px',
fontSize: 12,
lineHeight: '18px',
}}
>
<div
style={{
fontWeight: 600,
color: isMaskedSchedule(s) ? '#595959' : '#1677ff',
}}
>
{s.subject}
</div>
<div style={{ color: '#595959' }}>
{s.startTime}-{s.endTime}
</div>
</div>
</Tooltip>
))}
</div>
) : (
<div
style={{
color: '#d9d9d9',
fontSize: 20,
textAlign: 'center',
lineHeight: '44px',
}}
>
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
tableLayout: 'fixed',
}}
>
<thead>
<tr style={{ background: '#fafafa' }}>
{WEEKDAYS.map((d) => (
<th
key={d}
style={{
padding: '10px 8px',
border: '1px solid #f0f0f0',
textAlign: 'center',
fontWeight: 500,
}}
>
{d}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week, wi) => (
<tr key={wi}>
{week.map((day, di) => {
const isCurrentMonth = day.month() === monthStart.month();
const dateKey = day.format('YYYY-MM-DD');
const daySchedules = monthScheduleMap[dateKey] || [];
const count = daySchedules.length;
return (
<td
key={di}
tabIndex={0}
role="button"
aria-label={`${day.format('YYYY-MM-DD')} 排课详情`}
onClick={() => onDateClick(day)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onDateClick(day);
}
}}
style={{
padding: '6px 8px',
border: '1px solid #f0f0f0',
verticalAlign: 'top',
cursor: 'pointer',
height: 90,
background: isCurrentMonth ? '#fff' : '#fafafa',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
? '#f0f5ff'
: '#f0f0f0';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
? ''
: '#fafafa';
}}
>
<div
style={{
fontWeight: isCurrentMonth ? 600 : 400,
color: isCurrentMonth ? '#262626' : '#bfbfbf',
fontSize: 14,
marginBottom: 4,
}}
>
{day.date()}
</div>
{count > 0 && (
<Badge
count={count}
size="small"
overflowCount={99}
style={{ backgroundColor: '#1677ff' }}
/>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</Spin>
);
};

View File

@@ -0,0 +1,561 @@
import React from 'react';
import {
Alert,
Button,
Card,
Col,
DatePicker,
Empty,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Spin,
Statistic,
Switch,
Tag,
TimePicker,
} from 'antd';
import { CloudSyncOutlined, EditOutlined, PlusOutlined, StopOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import PermissionButton from '../../components/PermissionButton';
import { isMaskedSchedule } from './schedule-visibility';
import type { ScheduleFormValues } from './schedule-form';
import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids';
import { WEEKDAYS } from './ScheduleGrids';
export interface ScheduleModalProps {
open: boolean;
mode: 'create' | 'edit' | 'detail';
submitting: boolean;
form: ReturnType<typeof Form.useForm<ScheduleFormValues>>[0];
selectedCell: { classroomId: number; weekDay: number } | null;
selectedDate: Dayjs | null;
selectedSchedules: ClassScheduleItem[];
editingSchedule: ClassScheduleItem | null;
selectedClassroom?: ClassroomItem;
classOptions: Array<{ value: number; label: string }>;
classroomOptions: Array<{ value: number; label: string }>;
classTeachers: ClassTeacherOption[];
classes: ClassItem[];
onCancel: () => void;
onSubmit: () => void;
onStartCreate: () => void;
onEdit: (schedule: ClassScheduleItem) => void;
onDisable: (id: number | null) => void;
onClassChange: (classId: number) => void;
onSubjectBlur: (value: string) => void;
}
export const ScheduleModal: React.FC<ScheduleModalProps> = ({
open,
mode,
submitting,
form,
selectedCell,
selectedDate,
selectedSchedules,
editingSchedule,
selectedClassroom,
classOptions,
classroomOptions,
classTeachers,
classes,
onCancel,
onSubmit,
onStartCreate,
onEdit,
onDisable,
onClassChange,
onSubjectBlur,
}) => {
const title =
mode === 'create'
? `新增排课 — ${selectedClassroom?.name || ''} · ${
selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''
}`
: mode === 'edit'
? `编辑排课 — ${editingSchedule?.subject || ''}`
: selectedDate
? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${
WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]
}`
: `排课详情 — ${selectedClassroom?.name || ''} · ${
selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''
}`;
return (
<Modal
title={title}
open={open}
onCancel={onCancel}
onOk={mode !== 'detail' ? onSubmit : undefined}
confirmLoading={submitting}
okText={mode === 'edit' ? '保存' : mode === 'create' ? '创建' : undefined}
footer={mode === 'detail' ? null : undefined}
width={600}
destroyOnHidden
>
{mode !== 'detail' ? (
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="classId" label="班级" rules={[{ required: true, message: '请选择班级' }]}>
<Select
placeholder="选择班级"
showSearch
optionFilterProp="label"
options={classOptions}
onChange={onClassChange}
/>
</Form.Item>
<Form.Item
name="classroomId"
label="教室"
rules={[{ required: true, message: '请选择教室' }]}
>
<Select
placeholder="选择教室"
showSearch
optionFilterProp="label"
options={classroomOptions}
/>
</Form.Item>
<Form.Item
name="weekDay"
label="星期"
rules={[{ required: true, message: '请选择星期' }]}
>
<Select
options={WEEKDAYS.map((label, index) => ({ value: index + 1, label }))}
/>
</Form.Item>
<Form.Item
name="subject"
label="科目"
rules={[{ required: true, message: '请输入科目' }]}
>
<Input
placeholder="如:数学、语文"
onBlur={(event) => onSubjectBlur(event.target.value)}
/>
</Form.Item>
<Form.Item name="teacherId" label="任课老师">
<Select
showSearch
allowClear
placeholder="先选班级;系统会按科目自动带出任课老师"
optionFilterProp="label"
options={classTeachers.map((teacher) => ({
value: teacher.userId,
label: `${teacher.name || teacher.username || `#${teacher.userId}`}${
teacher.subject ? ` · ${teacher.subject}` : ''
}`,
}))}
notFoundContent="该班级暂无可选教师,请先在班级详情配置教师"
/>
</Form.Item>
<Form.Item name="notes" label="备注" rules={[{ max: 500, message: '备注不能超过500字' }]}>
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="可填写排课说明、设备需求或临时调整原因"
/>
</Form.Item>
<Form.Item
name="attendanceAdvanceMinutes"
label="课前签到时间"
tooltip="从上课前指定分钟开始,到下课时间结束;期间任意上班或下班打卡都计为出勤"
initialValue={30}
rules={[{ required: true, message: '请设置课前签到时间' }]}
>
<InputNumber
min={0}
max={1440}
step={5}
addonAfter="分钟"
style={{ width: '100%' }}
placeholder="例如 30"
/>
</Form.Item>
<Form.Item
name="timeRange"
label="上课时段"
tooltip="同一教室的前后两节排课必须至少间隔10分钟"
extra="系统按10分钟选择时间并为相邻排课强制预留至少10分钟。"
rules={[{ required: true, message: '请选择时段' }]}
>
<TimePicker.RangePicker
format="HH:mm"
minuteStep={10}
style={{ width: '100%' }}
placeholder={['开始时间', '结束时间']}
/>
</Form.Item>
<Form.Item
name="dateRange"
label="日期范围"
rules={[{ required: true, message: '请选择日期范围' }]}
>
<DatePicker.RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
</Form>
) : (
<div style={{ lineHeight: 2 }}>
<div
style={{
marginBottom: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 500 }}></span>
<PermissionButton
permission="schedule:create"
type="primary"
icon={<PlusOutlined />}
onClick={onStartCreate}
>
</PermissionButton>
</div>
{selectedSchedules.length === 0 ? (
<Empty description="该时段暂无排课" />
) : (
selectedSchedules.map((s) => (
<Card
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
size="small"
style={{ marginBottom: 8 }}
styles={{ body: { padding: 12 } }}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<div>
<div>
<strong>{isMaskedSchedule(s) ? '状态:' : '科目:'}</strong>
<Tag color={isMaskedSchedule(s) ? 'default' : 'blue'}>{s.subject}</Tag>
</div>
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
</div>
)}
{!isMaskedSchedule(s) && s.teacherId != null && (
<div>
<strong></strong>
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
classTeachers.find((u) => u.userId === s.teacherId)?.username ||
`#${s.teacherId}`}
</div>
)}
<div>
<strong></strong>
{s.startTime} ~ {s.endTime}
</div>
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{s.attendanceAdvanceMinutes ?? 30}
</div>
)}
<div>
<strong></strong>
{s.startDate} ~ {s.endDate}
</div>
{!isMaskedSchedule(s) && s.notes && (
<div>
<strong></strong>
{s.notes}
</div>
)}
{!isMaskedSchedule(s) && (
<div>
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
</Tag>
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
</div>
)}
</div>
{!isMaskedSchedule(s) && (
<Space>
{s.scheduleType !== 'RENTAL' && (
<PermissionButton
permission="schedule:edit"
size="small"
icon={<EditOutlined />}
onClick={() => onEdit(s)}
>
</PermissionButton>
)}
{s.scheduleType !== 'RENTAL' && s.status === 'active' && (
<Popconfirm
title="确认停用该排课?"
description="停用后历史考勤记录会保留,但该排课不会再显示或占用教室。"
onConfirm={() => onDisable(s.id)}
okText="停用"
cancelText="取消"
>
<PermissionButton
permission="schedule:edit"
size="small"
icon={<StopOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
)}
</div>
</Card>
))
)}
</div>
)}
</Modal>
);
};
export interface SyncModalProps {
open: boolean;
syncing: boolean;
syncStatus: {
activeSchedules: number;
mappedClasses: number;
totalClasses: number;
} | null;
syncResult: {
scheduleCount: number;
shiftCount: number;
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
failedBatchCount: number;
failedItems: number;
errors: string[];
groups: Array<{ className: string; groupId: number; itemCount: number }>;
} | null;
syncDateFrom: Dayjs;
syncDays: number;
attendanceMachineOnly: boolean;
onClose: () => void;
onSync: () => void;
onDateChange: (date: Dayjs) => void;
onDaysChange: (days: number) => void;
onMachineOnlyChange: (value: boolean) => void;
}
export const SyncModal: React.FC<SyncModalProps> = ({
open,
syncing,
syncStatus,
syncResult,
syncDateFrom,
syncDays,
attendanceMachineOnly,
onClose,
onSync,
onDateChange,
onDaysChange,
onMachineOnlyChange,
}) => {
return (
<Modal
title="同步排课到钉钉考勤排班"
open={open}
onCancel={onClose}
footer={
syncResult
? [
<Button key="close" onClick={onClose}>
</Button>,
]
: [
<Button key="cancel" onClick={onClose}>
</Button>,
<Button
key="sync"
type="primary"
icon={<CloudSyncOutlined />}
loading={syncing}
onClick={onSync}
disabled={!syncStatus || syncStatus.activeSchedules === 0}
>
</Button>,
]
}
width={560}
>
{syncResult ? (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Statistic title="排课" value={syncResult.scheduleCount} suffix="条" />
</Col>
<Col xs={12} sm={6}>
<Statistic title="班次" value={syncResult.shiftCount} suffix="个" />
</Col>
<Col xs={12} sm={6}>
<Statistic title="考勤组" value={syncResult.groupCount} suffix="个" />
</Col>
<Col xs={12} sm={6}>
<Statistic
title="排班数"
value={syncResult.syncedItems}
suffix="条"
valueStyle={{ color: '#3f8600' }}
/>
</Col>
</Row>
{syncResult.skippedNoMapping > 0 && (
<Alert
type="warning"
title={`${syncResult.skippedNoMapping} 条排课因班级无钉钉绑定学生而跳过`}
style={{ marginBottom: 16 }}
showIcon
/>
)}
{syncResult.failedBatchCount > 0 && (
<>
<Alert
type="error"
title={`${syncResult.failedBatchCount} 批写入失败,共 ${syncResult.failedItems}`}
description={
syncResult.errors.length > 0
? syncResult.errors.slice(0, 5).map((err, i) => (
<div key={i} style={{ wordBreak: 'break-all' }}>
{err}
</div>
))
: undefined
}
style={{ marginBottom: 16 }}
showIcon
/>
{syncResult.errors.length > 5 && (
<div style={{ fontSize: 12, color: '#999', marginBottom: 16, marginTop: -12 }}>
... {syncResult.errors.length - 5}
</div>
)}
</>
)}
{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.className}{g.itemCount}
</Tag>
))}
</div>
)}
</div>
) : syncStatus ? (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Statistic title="活跃排课" value={syncStatus.activeSchedules} suffix="条" />
</Col>
<Col xs={24} sm={8}>
<Statistic
title="已就绪班级"
value={syncStatus.mappedClasses}
suffix={`/ ${syncStatus.totalClasses}`}
valueStyle={{
color:
syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
}}
/>
</Col>
<Col xs={24} sm={8}>
<Statistic
title="无绑定学生班级"
value={syncStatus.totalClasses - syncStatus.mappedClasses}
suffix="个"
/>
</Col>
</Row>
{syncStatus.mappedClasses < syncStatus.totalClasses && (
<Alert
type="warning"
title={`${syncStatus.totalClasses - syncStatus.mappedClasses} 个班级没有已绑定钉钉的学生,其排课将被跳过。请先在钉钉集成页导入并绑定学生。`}
style={{ marginBottom: 16 }}
showIcon
/>
)}
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Space wrap>
<span></span>
<DatePicker
value={syncDateFrom}
onChange={(d) => d && onDateChange(d)}
allowClear={false}
/>
<span></span>
<Select
value={syncDays}
onChange={onDaysChange}
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 style={{ marginTop: 16 }}>
<Space align="start">
<Switch checked={attendanceMachineOnly} onChange={onMachineOnlyChange} />
<div>
<div style={{ fontWeight: 500 }}></div>
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 2 }}>
Wi-Fi
</div>
</div>
</Space>
</div>
{attendanceMachineOnly && (
<Alert
type="info"
showIcon
message="已存在的同名考勤组也会在本次同步中更新为仅考勤机打卡。"
style={{ marginTop: 12 }}
/>
)}
</div>
{syncStatus.activeSchedules === 0 && (
<Alert
type="info"
message="当前没有活跃排课。请先在排课页面创建排课记录。"
showIcon
/>
)}
</div>
) : (
<Spin description="查询同步状态..." />
)}
</Modal>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,388 @@
// aislop-ignore-file: duplicate-block -- 单元格渲染结构相似且字段不同,逻辑已通过 EditableStudentCell 共享
import React from 'react';
import { Button, Popconfirm, Space, Tag } from 'antd';
import { EyeOutlined, InboxOutlined, UndoOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
export const statusMap: Record<string, { text: string; color: string }> = {
active: { text: '在读', color: 'green' },
graduated: { text: '已毕业', color: 'blue' },
withdrawn: { text: '已退训', color: 'red' },
archived: { text: '已归档', color: '#999' },
};
export const STUDENT_FIELDS = {
name: 'name',
studentNo: 'studentNo',
ethnicity: 'ethnicity',
emergencyContact: 'emergencyContact',
supervisor: 'supervisor',
status: 'status',
organizationId: 'organizationId',
} as const;
export const SENSITIVE_LABELS = {
phone: '电话',
idNumber: '身份证号',
emergencyPhone: '紧急联系人电话',
} as const;
export const STUDENT_STATUS_OPTIONS = [
{ value: 'active', label: '在读' },
{ value: 'graduated', label: '已毕业' },
{ value: 'withdrawn', label: '已退训' },
];
export interface StudentColumnContext {
pageInfo: { current: number; pageSize: number };
organizations: Array<{ id: number; name: string; isHost?: boolean }>;
canChooseOrganization: boolean;
canEditStudent: boolean;
canDeleteStudent: boolean;
canPurgeStudent: boolean;
canViewSensitive: boolean;
onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;
onViewSensitive: (recordId: number, field: string, value: string) => void;
onOpenDrawer: (recordId: number) => void;
onEdit: (record: any) => void;
onRestore: (id: number) => Promise<unknown> | unknown;
onPurge: (id: number, name: string) => void;
onArchive: (id: number) => Promise<unknown> | unknown;
}
export const EditableStudentCell = <R extends { id: number; status?: string }>({
value,
field,
record,
editor,
min,
max,
required,
options,
onSave,
children,
}: {
value: unknown;
field: string;
record: R;
editor?: React.ComponentProps<typeof EditableCell>['editor'];
min?: number;
max?: number;
required?: boolean;
options?: Array<{ value: string | number; label: string }>;
onSave: (record: R, field: string, value: unknown) => Promise<void> | void;
children?: React.ReactNode;
}) => (
<EditableCell
value={value}
editor={editor}
min={min}
max={max}
required={required}
options={options}
permission="student:edit"
disabled={record.status === 'archived'}
onSave={async (next) => {
await onSave(record, field, next);
}}
>
{children ?? String(value ?? '-')}
</EditableCell>
);
export const SensitiveValue: React.FC<{
value: string;
masked: string;
label: string;
recordId: number;
canViewSensitive: boolean;
onViewSensitive: (recordId: number, field: string, value: string) => void;
}> = ({ value, masked, label, recordId, canViewSensitive, onViewSensitive }) => {
if (!value) return <>-</>;
return (
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
<span style={{ marginRight: 4 }}>{masked}</span>
{canViewSensitive ? (
<Button
type="link"
size="small"
style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => onViewSensitive(recordId, label, value)}
title="点击查看完整号码"
>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</Button>
) : null}
</span>
);
};
function buildIdentityColumns(ctx: StudentColumnContext) {
const {
pageInfo,
canViewSensitive,
onSaveCell,
onViewSensitive,
} = ctx;
return [
{
title: '序号',
key: 'index',
width: 70,
render: (_: unknown, __: unknown, index: number) =>
(pageInfo.current - 1) * pageInfo.pageSize + index + 1,
},
{
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v: string, record: any) => (
<EditableStudentCell value={v} field={STUDENT_FIELDS.name} record={record} required onSave={onSaveCell}>
{v}
</EditableStudentCell>
),
},
{
title: '电话',
dataIndex: 'phone',
width: 140,
render: (v: string, record: any) => (
<SensitiveValue
value={v}
masked={maskPhone(v)}
label={SENSITIVE_LABELS.phone}
recordId={record.id}
canViewSensitive={canViewSensitive}
onViewSensitive={onViewSensitive}
/>
),
},
{
title: '学号',
dataIndex: 'studentNo',
width: 120,
render: (v: string, record: any) => (
<EditableStudentCell value={v} field={STUDENT_FIELDS.studentNo} record={record} onSave={onSaveCell}>
{v || '-'}
</EditableStudentCell>
),
},
{
title: '身份证',
dataIndex: 'idNumber',
width: 180,
render: (v: string, record: any) => (
<SensitiveValue
value={v}
masked={maskIdNumber(v)}
label={SENSITIVE_LABELS.idNumber}
recordId={record.id}
canViewSensitive={canViewSensitive}
onViewSensitive={onViewSensitive}
/>
),
},
];
}
function buildContactColumns(ctx: StudentColumnContext) {
const { organizations, canChooseOrganization, canViewSensitive, onSaveCell, onViewSensitive } =
ctx;
return [
{
title: '民族',
dataIndex: 'ethnicity',
width: 90,
render: (v: string, record: any) => (
<EditableStudentCell value={v} field={STUDENT_FIELDS.ethnicity} record={record} onSave={onSaveCell}>
{v || '-'}
</EditableStudentCell>
),
},
{
title: '紧急联系人',
dataIndex: 'emergencyContact',
width: 100,
render: (v: string, record: any) => (
<EditableStudentCell
value={v}
field={STUDENT_FIELDS.emergencyContact}
record={record}
onSave={onSaveCell}
>
{v || '-'}
</EditableStudentCell>
),
},
{
title: '紧急联系人电话',
dataIndex: 'emergencyPhone',
width: 150,
render: (v: string, record: any) => (
<SensitiveValue
value={v}
masked={maskPhone(v)}
label={SENSITIVE_LABELS.emergencyPhone}
recordId={record.id}
canViewSensitive={canViewSensitive}
onViewSensitive={onViewSensitive}
/>
),
},
{
title: '所属机构',
dataIndex: 'organization',
width: 100,
render: (organization: { name?: string } | null, record: any) =>
canChooseOrganization ? (
<EditableStudentCell
value={record.organizationId}
field={STUDENT_FIELDS.organizationId}
record={record}
editor="select"
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
required
onSave={onSaveCell}
>
{organization?.name ? (
<Tag
color="purple"
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{organization.name}
</Tag>
) : (
'-'
)}
</EditableStudentCell>
) : organization?.name ? (
<Tag color="purple">{organization.name}</Tag>
) : (
'-'
),
},
];
}
function buildProfileColumns(ctx: StudentColumnContext) {
const { onSaveCell } = ctx;
return [
{
title: '负责人',
dataIndex: 'supervisor',
width: 100,
render: (v: string, record: any) => (
<EditableStudentCell value={v} field={STUDENT_FIELDS.supervisor} record={record} onSave={onSaveCell}>
{v || '-'}
</EditableStudentCell>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string, record: any) => (
<EditableStudentCell
value={s}
field={STUDENT_FIELDS.status}
record={record}
editor="select"
options={STUDENT_STATUS_OPTIONS}
onSave={onSaveCell}
>
<Tag
color={statusMap[s]?.color}
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{statusMap[s]?.text || s}
</Tag>
</EditableStudentCell>
),
},
];
}
function buildActionColumn(ctx: StudentColumnContext) {
const {
canEditStudent,
canDeleteStudent,
canPurgeStudent,
onOpenDrawer,
onEdit,
onRestore,
onPurge,
onArchive,
} = ctx;
return {
title: '操作',
width: 180,
render: (_: any, record: any) => (
<Space>
{record.status === 'archived' ? (
<>
{canEditStudent ? (
<Popconfirm
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
onConfirm={() => onRestore(record.id)}
okText="恢复"
cancelText="取消"
>
<Button size="small" icon={<UndoOutlined />} type="link">
</Button>
</Popconfirm>
) : null}
{canPurgeStudent ? (
<Button
size="small"
danger
type="link"
onClick={() => onPurge(record.id, record.name)}
>
</Button>
) : null}
</>
) : (
<>
<PermissionButton
permission="student:view"
size="small"
type="link"
onClick={() => onOpenDrawer(record.id)}
>
</PermissionButton>
<PermissionButton permission="student:edit" size="small" onClick={() => onEdit(record)}>
</PermissionButton>
{canDeleteStudent ? (
<Popconfirm
title="归档后不会删除数据,可随时恢复。确定归档?"
onConfirm={() => onArchive(record.id)}
okText="归档"
cancelText="取消"
>
<Button size="small" icon={<InboxOutlined />}>
</Button>
</Popconfirm>
) : null}
</>
)}
</Space>
),
};
}
export function buildStudentColumns(ctx: StudentColumnContext) {
return [
...buildIdentityColumns(ctx),
...buildContactColumns(ctx),
...buildProfileColumns(ctx),
buildActionColumn(ctx),
];
}

View File

@@ -0,0 +1,179 @@
import React from 'react';
import { App, Descriptions, Drawer, Form, Input, Modal, Select } from 'antd';
import JinshujuMatchModal from '../../components/JinshujuMatchModal';
import StudentProfileContent from '../../components/StudentProfileContent';
import { SENSITIVE_LABELS } from './StudentColumns';
type AppModal = ReturnType<typeof App.useApp>['modal'];
export const showCreateImportResult = (
modal: AppModal,
result: { message?: string; imported?: number; skipped?: number },
) => {
const imported = result.imported ?? 0;
const skipped = result.skipped ?? 0;
modal.success({
title: '导入完成',
okText: '知道了',
content: (
<div>
<Descriptions column={1} size="small">
<Descriptions.Item label="成功新增">{imported} </Descriptions.Item>
<Descriptions.Item label="跳过">{skipped} </Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 12, fontWeight: 600 }}></div>
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
<li></li>
<li></li>
</ul>
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
</div>
</div>
),
});
};
export const showUpdateImportResult = (
modal: AppModal,
result: { message?: string; matched?: number; skipped?: number },
) => {
const matched = result.matched ?? 0;
const skipped = result.skipped ?? 0;
modal.success({
title: '更新完成',
okText: '知道了',
content: (
<div>
<Descriptions column={1} size="small">
<Descriptions.Item label="成功更新">{matched} </Descriptions.Item>
<Descriptions.Item label="未匹配">{skipped} </Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 12, fontWeight: 600 }}></div>
<div></div>
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
</div>
</div>
),
});
};
export const StudentEditModal: React.FC<{
open: boolean;
editing: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
canChooseOrganization: boolean;
organizations: Array<{ id: number; name: string; isHost?: boolean }>;
onOk?: () => void;
onCancel: () => void;
}> = ({
open,
editing,
saving,
form,
canChooseOrganization,
organizations,
onOk,
onCancel,
}) => {
return (
<Modal
title={editing ? '编辑学生' : '添加学生'}
className="student-form-modal"
width={720}
open={open}
onOk={onOk}
onCancel={onCancel}
okText="保存"
confirmLoading={saving}
>
<Form form={form} layout="vertical" className="student-form-grid">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="studentNo" label="学号">
<Input placeholder="学生的学号" />
</Form.Item>
<Form.Item name="gender" label="性别">
<Select
allowClear
options={[
{ value: '男', label: '男' },
{ value: '女', label: '女' },
]}
/>
</Form.Item>
<Form.Item name="phone" label={SENSITIVE_LABELS.phone}>
<Input />
</Form.Item>
<Form.Item name="idNumber" label="身份证">
<Input />
</Form.Item>
<Form.Item name="ethnicity" label="民族">
<Input placeholder="如:汉族" />
</Form.Item>
<Form.Item name="emergencyContact" label="紧急联系人">
<Input />
</Form.Item>
<Form.Item name="emergencyPhone" label={SENSITIVE_LABELS.emergencyPhone}>
<Input />
</Form.Item>
{canChooseOrganization ? (
<Form.Item
name="organizationId"
label="所属机构"
rules={[{ required: true, message: '请选择所属机构' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择所属机构"
options={organizations.map((organization) => ({
value: organization.id,
label: organization.isHost
? `${organization.name}(本机构)`
: organization.name,
}))}
/>
</Form.Item>
) : null}
<Form.Item name="supervisor" label="负责人/班主任">
<Input />
</Form.Item>
{editing && (
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'active', label: '在读' },
{ value: 'graduated', label: '已毕业' },
{ value: 'withdrawn', label: '已退训' },
]}
/>
</Form.Item>
)}
</Form>
</Modal>
);
};
export const JinshujuModal: React.FC<{
open: boolean;
onClose: () => void;
onApplied: () => void;
}> = ({ open, onClose, onApplied }) => {
return <JinshujuMatchModal open={open} onClose={onClose} onApplied={onApplied} />;
};
export const StudentDrawer: React.FC<{
open: boolean;
studentId: number | null;
onClose: () => void;
}> = ({ open, studentId, onClose }) => {
return (
<Drawer title={null} open={open} onClose={onClose} size={720}>
{studentId && <StudentProfileContent studentId={studentId} inDrawer onClose={onClose} />}
</Drawer>
);
};

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { Alert, Button, Card, Col, Descriptions, Empty, Row, Table, Tag } from 'antd';
import api from '../../api';
export interface EnrollmentInfo {
classId: number;
className: string;
classType: string;
startDate: string;
endDate: string;
joinDate: string;
leaveDate: string;
status: string;
}
export const StudentsTable: React.FC<{
columns: any[];
data: any[];
loading: boolean;
pageInfo: { current: number; pageSize: number };
onPageChange: (current: number, pageSize: number) => void;
selectedRowKeys: number[];
onSelect: (keys: number[]) => void;
onClearSelection: () => void;
}> = ({
columns,
data,
loading,
pageInfo,
onPageChange,
selectedRowKeys,
onSelect,
onClearSelection,
}) => {
const [enrollmentData, setEnrollmentData] = React.useState<Record<number, EnrollmentInfo[]>>({});
return (
<>
{selectedRowKeys.length > 0 ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
title={
<span>
<strong style={{ color: '#1677ff' }}>{selectedRowKeys.length}</strong>{' '}
</span>
}
action={
<Button size="small" type="link" onClick={onClearSelection}>
</Button>
}
/>
) : null}
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1410 }}
pagination={{
defaultPageSize: 15,
current: pageInfo.current,
pageSize: pageInfo.pageSize,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
onChange: onPageChange,
}}
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
rowSelection={{
selectedRowKeys,
onChange: (keys) => onSelect(keys as number[]),
}}
expandable={{
rowExpandable: () => true,
expandedRowRender: (record) => {
const enrollments = enrollmentData[record.id];
if (!enrollments) return null;
if (enrollments.length < 2) {
return (
<div style={{ padding: 8, color: '#999', fontSize: 13 }}>
{enrollments.length}
</div>
);
}
return (
<Card title="多班型对比" size="small" style={{ margin: '8px 0' }}>
<Row gutter={[16, 16]}>
{enrollments.map((enr, idx) => (
<Col xs={24} md={12} key={enr.classId}>
<Card
size="small"
title={enr.classType || `班型 ${idx + 1}`}
style={{ background: idx === 0 ? '#f0f5ff' : '#f6ffed' }}
>
<Descriptions column={1} size="small">
<Descriptions.Item label="班级">{enr.className || '-'}</Descriptions.Item>
<Descriptions.Item label="开班日期">
{enr.startDate || enr.joinDate || '-'}
</Descriptions.Item>
<Descriptions.Item label="结课日期">
{enr.endDate || enr.leaveDate || '-'}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={enr.status === 'active' ? 'green' : 'default'}>
{enr.status || '-'}
</Tag>
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
))}
</Row>
</Card>
);
},
onExpand: async (expanded, record) => {
if (expanded && !enrollmentData[record.id]) {
try {
const res = await api.get<{ enrollments: EnrollmentInfo[] }>(
`/students/${record.id}/compare-classes`,
);
setEnrollmentData((prev) => ({ ...prev, [record.id]: res.enrollments }));
} catch {
setEnrollmentData((prev) => ({ ...prev, [record.id]: [] }));
}
}
},
}}
/>
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
</>
);
};

View File

@@ -0,0 +1,272 @@
import React from 'react';
import {
Button,
Input,
Popconfirm,
Select,
Space,
Upload,
} from 'antd';
import type { UploadProps } from 'antd';
import {
CloudUploadOutlined,
DeleteOutlined,
DownloadOutlined,
ExportOutlined,
InboxOutlined,
PlusOutlined,
SwapOutlined,
SyncOutlined,
UndoOutlined,
UploadOutlined,
} from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import { statusMap } from './StudentColumns';
export interface StudentsToolbarProps {
onSearchName: (value: string) => void;
filterStatus?: string;
onFilterStatus: (value?: string) => void;
effectiveFilterOrganizationId?: number;
onFilterOrganization: (value?: number) => void;
canViewOrganizations: boolean;
organizations: Array<{ id: number; name: string }>;
filterClassId?: number;
onFilterClass: (value?: number) => void;
classOptions: Array<{ id: number; name: string; code?: string }>;
filterTeacherId?: number;
onFilterTeacher: (value?: number) => void;
teacherOptions: Array<{ id: number; name: string; username: string }>;
showArchived: boolean;
onToggleArchived: () => void;
selectedRowKeys: number[];
batchLoading: boolean;
canEditStudent: boolean;
canPurgeStudent: boolean;
canDeleteStudent: boolean;
canSyncJinshuju: boolean;
canSyncDingTalk: boolean;
dingSyncLoading: boolean;
onBatchRestore: () => void;
onBatchPurge: () => void;
onBatchDelete: () => void;
onAddStudent: () => void;
onOpenJinshuju: () => void;
onDingTalkSync: () => void;
onCreateImport: UploadProps['customRequest'];
onUpdateImport: UploadProps['customRequest'];
onDownloadTemplate: () => void;
onExport: () => void;
}
export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
onSearchName,
filterStatus,
onFilterStatus,
effectiveFilterOrganizationId,
onFilterOrganization,
canViewOrganizations,
organizations,
filterClassId,
onFilterClass,
classOptions,
filterTeacherId,
onFilterTeacher,
teacherOptions,
showArchived,
onToggleArchived,
selectedRowKeys,
batchLoading,
canEditStudent,
canPurgeStudent,
canDeleteStudent,
canSyncJinshuju,
canSyncDingTalk,
dingSyncLoading,
onBatchRestore,
onBatchPurge,
onBatchDelete,
onAddStudent,
onOpenJinshuju,
onDingTalkSync,
onCreateImport,
onUpdateImport,
onDownloadTemplate,
onExport,
}) => {
return (
<>
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<Input.Search
placeholder="搜索学生姓名"
onSearch={onSearchName}
allowClear
style={{ width: 250 }}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={onFilterStatus}
>
{Object.entries(statusMap)
.filter(([k]) => k !== 'archived')
.map(([k, v]) => (
<Select.Option key={k} value={k}>
{v.text}
</Select.Option>
))}
</Select>
{canViewOrganizations ? (
<Select
placeholder="所属机构"
allowClear
style={{ width: 140 }}
value={effectiveFilterOrganizationId}
onChange={onFilterOrganization}
>
{organizations.map((t: { id: number; name: string }) => (
<Select.Option key={t.id} value={t.id}>
{t.name}
</Select.Option>
))}
</Select>
) : null}
<Select
placeholder="所属班级"
allowClear
showSearch
optionFilterProp="label"
style={{ width: 160 }}
value={filterClassId}
onChange={onFilterClass}
options={classOptions.map((item) => ({
value: item.id,
label: item.code ? `${item.name}${item.code}` : item.name,
}))}
/>
<Select
placeholder="所属老师"
allowClear
showSearch
optionFilterProp="label"
style={{ width: 160 }}
value={filterTeacherId}
onChange={onFilterTeacher}
options={teacherOptions.map((item) => ({
value: item.id,
label: item.name === item.username ? item.name : `${item.name}${item.username}`,
}))}
/>
<Button type={showArchived ? 'primary' : 'default'} onClick={onToggleArchived}>
{showArchived ? '返回正常数据' : '查看已归档'}
</Button>
</Space>
<Space wrap className="responsive-toolbar__group">
{showArchived && canEditStudent ? (
<>
<Popconfirm
title={`确定批量恢复选中的 ${selectedRowKeys.length} 名学生?`}
onConfirm={onBatchRestore}
okText="恢复"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
type="primary"
icon={<UndoOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
{canPurgeStudent ? (
<Popconfirm
title={`确定永久删除选中的 ${selectedRowKeys.length} 名学生?删除后不可恢复!`}
onConfirm={onBatchPurge}
okText="永久删除"
okButtonProps={{ danger: true }}
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
</>
) : !showArchived && canDeleteStudent ? (
<Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
onConfirm={onBatchDelete}
okText="归档"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
danger
icon={<InboxOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
{!showArchived ? (
<PermissionButton
permission="student:create"
type="primary"
icon={<PlusOutlined />}
onClick={onAddStudent}
>
</PermissionButton>
) : null}
{!showArchived && (
<>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onCreateImport}>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onUpdateImport}>
<Button icon={<SwapOutlined />}></Button>
</Upload>
</>
)}
{!showArchived && canSyncJinshuju ? (
<Button icon={<CloudUploadOutlined />} onClick={onOpenJinshuju}>
</Button>
) : null}
{!showArchived && canSyncDingTalk ? (
<Button icon={<SyncOutlined />} loading={dingSyncLoading} onClick={onDingTalkSync}>
</Button>
) : null}
<PermissionButton
permission="student:view"
icon={<DownloadOutlined />}
onClick={onDownloadTemplate}
>
</PermissionButton>
<PermissionButton
permission="student:export"
icon={<ExportOutlined />}
onClick={onExport}
>
</PermissionButton>
</Space>
</div>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,8 @@
import React, { useEffect, useState, useMemo } from 'react';
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { validateResponse } from '../../utils/validate';
import { teacherWorkspaceSchema } from '../../api/schemas';
import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import api from '../../api';
@@ -55,24 +59,22 @@ const WEEKDAY_LABELS: Record<string, string> = {
};
const TeacherWorkspacePage: React.FC = () => {
const [data, setData] = useState<WorkspaceData | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
const { data, isLoading, isFetching } = useQuery<WorkspaceData | null>({
queryKey: ['rbac', 'teacher-workspace'],
queryFn: async () => {
try {
const res = (await api.get('/rbac/teacher-workspace')) as WorkspaceData;
setData(res);
return validateResponse<WorkspaceData>(
teacherWorkspaceSchema,
await api.get('/rbac/teacher-workspace'),
);
} catch (e) {
console.error(e);
message.error('数据加载失败');
} finally {
setLoading(false);
return null;
}
};
fetchData();
}, []);
},
});
const loading = isLoading || isFetching;
const classColumns: ColumnsType<AssignedClass> = useMemo(
() => [
@@ -166,6 +168,7 @@ const TeacherWorkspacePage: React.FC = () => {
<Empty description="暂无分配的班级" />
),
},
{
key: 'schedule',
label: `今日课程 (${data?.todaySchedules.length || 0})`,
@@ -185,6 +188,7 @@ const TeacherWorkspacePage: React.FC = () => {
<Empty description="今日无排课" />
),
},
{
key: 'students',
label: `我的学生 (${data?.myStudents.length || 0})`,

View File

@@ -1,4 +1,8 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { teacherListSchema } from '../../api/schemas';
import { Table, Input, Modal, Form, Select, DatePicker, Tag, Space } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
@@ -53,9 +57,6 @@ const DEFAULT_PAGE_SIZE = 20;
const TeachersPage: React.FC = () => {
const { hasPermission } = usePermission();
const canEditTeachers = hasPermission('teacher:edit');
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('');
@@ -63,43 +64,62 @@ const TeachersPage: React.FC = () => {
const [form] = Form.useForm<ProfileFormValues>();
const [saving, setSaving] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<TeacherListResponse>('/rbac/teachers', {
params: { search: search || undefined, page, pageSize },
});
setData(res.list);
setTotal(res.total);
} catch {
// silent
}
setLoading(false);
}, [page, pageSize, search]);
const {
data: fetchResult = { list: [], total: 0 },
isLoading,
isFetching,
} = useQuery<TeacherListResponse>({
queryKey: ['rbac', 'teachers', page, pageSize, search],
queryFn: async () => {
try {
return validateResponse<TeacherListResponse>(
teacherListSchema,
await api.get<TeacherListResponse>('/rbac/teachers', {
params: { search: search || undefined, page, pageSize },
}),
);
} catch {
return { list: [], total: 0 };
}
},
});
const data = fetchResult.list;
const total = fetchResult.total;
const loading = isLoading || isFetching;
useEffect(() => {
fetchData();
}, [fetchData]);
const saveProfileMutation = useApiMutation(
async ({
id,
values,
}: {
id: number;
values: { subjects: string[]; joinedAt?: string; qualifications?: string };
}) => api.put(`/rbac/teachers/${id}/profile`, values),
{ invalidate: [['rbac', 'teachers']] },
);
const saveProfileCellMutation = useApiMutation(
async ({ record, field, value }: { record: TeacherRow; field: string; value: unknown }) =>
api.put(`/rbac/teachers/${record.id}/profile`, { [field]: value }),
{ invalidate: [['rbac', 'teachers']] },
);
const handleSaveProfile = async () => {
const values = await form.validateFields();
if (!profileModal) return;
setSaving(true);
try {
await api.put(`/rbac/teachers/${profileModal.id}/profile`, {
subjects: values.subjects || [],
joinedAt: values.joinedAt?.format('YYYY-MM-DD'),
qualifications: values.qualifications,
await saveProfileMutation.mutateAsync({
id: profileModal.id,
values: {
subjects: values.subjects || [],
joinedAt: values.joinedAt?.format('YYYY-MM-DD'),
qualifications: values.qualifications,
},
});
message.success('已更新');
setProfileModal(null);
fetchData();
} catch (e: unknown) {
let msg = '更新失败';
if (e !== null && typeof e === 'object' && 'message' in e) {
msg = String(e.message);
}
message.error(msg);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -107,11 +127,14 @@ 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();
try {
await saveProfileCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[fetchData],
[saveProfileCellMutation],
);
const columns = useMemo(

View File

@@ -1,4 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { roomTypesSchema, walletsSchema } from '../../api/schemas';
import {
Button,
Drawer,
@@ -30,6 +34,14 @@ interface WalletRow {
roomNumber?: string;
}
interface WalletTransaction {
id: number;
createdAt: string;
type: string;
amount: number;
balanceAfter: number;
}
const transactionNames: Record<string, string> = {
recharge: '充值',
adjustment: '调账',
@@ -38,14 +50,11 @@ const transactionNames: Record<string, string> = {
};
const WalletsPage: React.FC = () => {
const [rows, setRows] = useState<WalletRow[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [debtOnly, setDebtOnly] = useState(false);
const [roomType, setRoomType] = useState<string | undefined>();
const [roomTypes, setRoomTypes] = useState<string[]>([]);
const [selected, setSelected] = useState<WalletRow | null>(null);
const [transactions, setTransactions] = useState<any[]>([]);
const [transactions, setTransactions] = useState<WalletTransaction[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [form] = Form.useForm();
const [batchForm] = Form.useForm();
@@ -53,38 +62,66 @@ const WalletsPage: React.FC = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchModalOpen, setBatchModalOpen] = useState(false);
const fetchRows = useCallback(async () => {
setLoading(true);
try {
const data = await api.get('/wallets', {
params: { keyword: keyword || undefined, debtOnly, roomType },
});
setRows(data as WalletRow[]);
} catch (error: any) {
message.error(error?.message || '加载学生余额失败');
} finally {
setLoading(false);
}
}, [keyword, debtOnly, roomType]);
useEffect(() => {
void fetchRows();
}, [fetchRows]);
useEffect(() => {
const fetchRoomTypes = async () => {
const {
data: rows = [],
isLoading,
isFetching,
refetch,
} = useQuery<WalletRow[]>({
queryKey: ['wallets', keyword, debtOnly, roomType],
queryFn: async () => {
try {
setRoomTypes((await api.get('/wallets/room-types')) as string[]);
return validateResponse<WalletRow[]>(
walletsSchema,
await api.get('/wallets', {
params: { keyword: keyword || undefined, debtOnly, roomType },
}),
);
} catch (error: any) {
message.error(error?.message || '加载学生余额失败');
return [];
}
},
});
const { data: roomTypes = [] } = useQuery<string[]>({
queryKey: ['wallets', 'room-types'],
queryFn: async () => {
try {
return validateResponse<string[]>(
roomTypesSchema,
await api.get('/wallets/room-types'),
);
} catch (error: any) {
message.error(error?.message || '加载房型失败');
return [];
}
};
void fetchRoomTypes();
}, []);
},
});
const loading = isLoading || isFetching;
const fetchRows = useCallback(() => refetch(), [refetch]);
useEffect(() => {
const changeMutation = useApiMutation(
async (payload: Record<string, unknown>) => api.post('/wallets/change-balance', payload),
{ invalidate: [['wallets']] },
);
const batchChangeMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
api.post('/wallets/batch-change-balance', payload),
{ invalidate: [['wallets']] },
);
const updateKeyword = (value: string) => {
setKeyword(value);
setSelectedRowKeys([]);
}, [keyword, debtOnly, roomType]);
};
const updateRoomType = (value: string | undefined) => {
setRoomType(value);
setSelectedRowKeys([]);
};
const updateDebtOnly = (value: boolean) => {
setDebtOnly(value);
setSelectedRowKeys([]);
};
const openChange = (row: WalletRow) => {
setSelected(row);
@@ -101,7 +138,7 @@ const WalletsPage: React.FC = () => {
const values = await form.validateFields();
setSaving(true);
try {
const result: any = await api.post('/wallets/change-balance', {
const result: any = await changeMutation.mutateAsync({
operationId: newOperationId(),
studentId: selected.studentId,
...values,
@@ -112,9 +149,8 @@ const WalletsPage: React.FC = () => {
);
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
setSelected(null);
await fetchRows();
} catch (error: any) {
message.error(error?.message || '余额操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -124,7 +160,7 @@ const WalletsPage: React.FC = () => {
const values = await batchForm.validateFields();
setSaving(true);
try {
const result: any = await api.post('/wallets/batch-change-balance', {
const result: any = await batchChangeMutation.mutateAsync({
operationId: newOperationId(),
studentIds: selectedRowKeys,
...values,
@@ -146,9 +182,8 @@ const WalletsPage: React.FC = () => {
setBatchModalOpen(false);
setSelectedRowKeys([]);
batchForm.resetFields();
await fetchRows();
} catch (error: any) {
message.error(error?.message || '批量余额操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -159,10 +194,13 @@ const WalletsPage: React.FC = () => {
setDrawerOpen(true);
try {
setTransactions(
(await api.get('/wallets/transactions', { params: { studentId: row.studentId } })) as any[],
await api.get<WalletTransaction[]>('/wallets/transactions', {
params: { studentId: row.studentId },
}),
);
} catch (error: any) {
message.error(error?.message || '加载流水失败');
} catch (error: unknown) {
console.error('加载余额流水失败', error);
message.error('加载流水失败');
}
};
@@ -184,8 +222,8 @@ const WalletsPage: React.FC = () => {
title: '可用余额',
dataIndex: 'balance',
render: (value: number) => (
<strong style={{ color: Number(value) > 0 ? '#1677ff' : undefined }}>
¥{Number(value).toFixed(2)}
<strong style={{ color: value > 0 ? '#1677ff' : undefined }}>
¥{value.toFixed(2)}
</strong>
),
},
@@ -193,8 +231,8 @@ const WalletsPage: React.FC = () => {
title: '未付账单',
dataIndex: 'outstandingAmount',
render: (value: number) =>
Number(value) > 0 ? (
<Tag color="red">¥{Number(value).toFixed(2)}</Tag>
value > 0 ? (
<Tag color="red">¥{value.toFixed(2)}</Tag>
) : (
<Tag color="green"></Tag>
),
@@ -238,19 +276,19 @@ const WalletsPage: React.FC = () => {
allowClear
placeholder="搜索姓名或学号"
style={{ width: 240 }}
onSearch={setKeyword}
onChange={(event) => !event.target.value && setKeyword('')}
onSearch={updateKeyword}
onChange={(event) => !event.target.value && updateKeyword('')}
/>
<Select
allowClear
placeholder="按房型筛选"
style={{ width: 180 }}
value={roomType}
onChange={setRoomType}
onChange={updateRoomType}
options={roomTypes.map((type) => ({ label: type, value: type }))}
/>
<span></span>
<Switch checked={debtOnly} onChange={setDebtOnly} />
<Switch checked={debtOnly} onChange={updateDebtOnly} />
</Space>
<Space wrap>
<PermissionButton
@@ -297,6 +335,7 @@ const WalletsPage: React.FC = () => {
]}
/>
</Form.Item>
<Form.Item
name="amount"
label="变动金额"
@@ -305,6 +344,7 @@ const WalletsPage: React.FC = () => {
>
<InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea maxLength={300} />
</Form.Item>
@@ -331,6 +371,7 @@ const WalletsPage: React.FC = () => {
]}
/>
</Form.Item>
<Form.Item
name="amount"
label="变动金额(元/人)"
@@ -339,6 +380,7 @@ const WalletsPage: React.FC = () => {
>
<InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea maxLength={300} />
</Form.Item>
@@ -346,7 +388,7 @@ const WalletsPage: React.FC = () => {
</Modal>
<Drawer
title={`${selected?.studentName || ''} - 余额流水`}
width={680}
size={680}
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
@@ -372,15 +414,15 @@ const WalletsPage: React.FC = () => {
title: '金额',
dataIndex: 'amount',
render: (value: number) => (
<span style={{ color: Number(value) >= 0 ? '#389e0d' : '#cf1322' }}>
{Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)}
<span style={{ color: value >= 0 ? '#389e0d' : '#cf1322' }}>
{value >= 0 ? '+' : ''}¥{value.toFixed(2)}
</span>
),
},
{
title: '变动后余额',
dataIndex: 'balanceAfter',
render: (value: number) => `¥${Number(value).toFixed(2)}`,
render: (value: number) => `¥${value.toFixed(2)}`,
},
{
title: '关联账单',

View File

@@ -65,7 +65,7 @@ export class BillsExportService {
const total = Number(bill.totalAmount || 0);
ws.addRow({
id: bill.id,
studentName: (bill as any).student?.name || '-',
studentName: bill.student?.name || '-',
period: `${bill.periodStart} ~ ${bill.periodEnd}`,
shared: Number(bill.sharedAmount),
personal: Number(bill.personalAmount),
@@ -96,7 +96,7 @@ export class BillsExportService {
for (const item of bill.items || []) {
ws2.addRow({
billId: bill.id,
studentName: (bill as any).student?.name || '-',
studentName: bill.student?.name || '-',
expenseType: item.expenseType,
description: item.description,
days: item.days,
@@ -158,7 +158,9 @@ export class BillsExportService {
fontRegistered = true;
break;
}
} catch {}
} catch {
// 字体注册失败时回退到默认字体
}
}
if (!fontRegistered) {
// 如果没有中文字体,使用 Helvetica中文可能乱码
@@ -183,7 +185,7 @@ export class BillsExportService {
// 基本信息
doc.fontSize(12).fillColor('#000');
doc.text(`学生姓名: ${(bill as any).student?.name || '-'}`);
doc.text(`学生姓名: ${bill.student?.name || '-'}`);
doc.text(`计费周期: ${bill.periodStart} ~ ${bill.periodEnd}`);
doc.text(`账单状态: ${statusMap[bill.status] || bill.status}`);
doc.moveDown(0.5);

View File

@@ -0,0 +1,285 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room } from '../entities';
import { WalletsService } from '../wallets/wallets.service';
import type { GenerateBillsDto } from './dto/bill.dto';
@Injectable()
export class BillsGenerationService {
constructor(
@InjectRepository(Bill) private billRepo: Repository<Bill>,
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
@InjectRepository(Room) private roomRepo: Repository<Room>,
private dataSource: DataSource,
private walletsService: WalletsService,
) {}
async generateBillsOnce(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = dto.billingMonth
? this.resolveBillingPeriod(dto.billingMonth)
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
throw new BadRequestException('账单周期无效,结束日期不能早于开始日期');
}
const pStart = new Date(`${periodStart}T00:00:00Z`);
const pEnd = new Date(`${periodEnd}T00:00:00Z`);
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
if (existingBills.length > 0) {
throw new BadRequestException(
`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`,
);
}
const roomExpenses = await this.roomExpRepo
.createQueryBuilder('e')
.where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', {
periodStart,
periodEnd,
})
.andWhere('e.status = :status', { status: 'active' })
.getMany();
const longTermOccupancies: Occupancy[] = [];
const roomExpMap = new Map<number, RoomExpense[]>();
for (const expense of roomExpenses) {
const expenses = roomExpMap.get(expense.roomId) || [];
expenses.push(expense);
roomExpMap.set(expense.roomId, expenses);
}
const roomIds = new Set([
...roomExpMap.keys(),
...longTermOccupancies
.filter((occupancy) => occupancy.stayType === 'long')
.map((occupancy) => occupancy.roomId),
]);
const studentBillData = new Map<
number,
{ shared: number; items: Array<Record<string, unknown>> }
>();
for (const roomId of roomIds) {
const expenses = roomExpMap.get(roomId) || [];
const occupancies = await this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('o.roomId = :roomId', { roomId })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long');
const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long');
for (const occupancy of longTermOccs) {
const rent = this.calculateLongTermRent(
occupancy,
periodStart,
periodEnd,
Number(occupancy.room?.monthlyRate || 0),
);
if (rent <= 0) continue;
const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] };
data.shared += rent;
data.items.push({
roomId,
expenseType: 'rent',
description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: rent,
studentAmount: rent,
});
studentBillData.set(occupancy.studentId, data);
}
const studentDays = shortTermOccs.map((occupancy) => {
const start = new Date(
Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime()),
);
const end = occupancy.billingEndDate
? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime()))
: pEnd;
const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1);
return { studentId: occupancy.studentId, days };
});
const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0);
if (totalDays === 0) continue;
for (const expense of expenses) {
const eligibleDays = studentDays.filter((entry) => entry.days > 0);
const expenseTotal = Number(Number(expense.amount).toFixed(2));
let allocated = 0;
for (const [index, entry] of eligibleDays.entries()) {
const amount =
index === eligibleDays.length - 1
? Number((expenseTotal - allocated).toFixed(2))
: Number(((entry.days / totalDays) * expenseTotal).toFixed(2));
allocated = Number((allocated + amount).toFixed(2));
const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] };
data.shared += amount;
data.items.push({
roomExpenseId: expense.id,
roomId,
expenseType: expense.expenseType,
description: `${expense.expenseType} 分摊`,
days: entry.days,
totalRoomDays: totalDays,
roomTotalAmount: expense.amount,
studentAmount: amount,
});
studentBillData.set(entry.studentId, data);
}
}
}
const personalExps = await this.personalExpRepo
.createQueryBuilder('pe')
.where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', {
periodStart,
periodEnd,
})
.andWhere('pe.status = :status', { status: 'active' })
.andWhere('pe.billId IS NULL')
.getMany();
const personalMap = new Map<number, number>();
const personalItems = new Map<number, Array<Record<string, unknown>>>();
for (const expense of personalExps) {
personalMap.set(
expense.studentId,
(personalMap.get(expense.studentId) || 0) + Number(expense.amount),
);
const items = personalItems.get(expense.studentId) || [];
items.push({
personalExpenseId: expense.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: `个人费用: ${expense.description || expense.expenseType}`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: expense.amount,
studentAmount: expense.amount,
});
personalItems.set(expense.studentId, items);
}
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
const bills = await this.dataSource.transaction(async (manager) => {
const generated: Bill[] = [];
for (const studentId of allStudentIds) {
const shared = studentBillData.get(studentId)?.shared || 0;
const personal = personalMap.get(studentId) || 0;
const total = Number((shared + personal).toFixed(2));
let bill = await manager.save(
manager.create(Bill, {
studentId,
periodStart,
periodEnd,
sharedAmount: Number(shared.toFixed(2)),
personalAmount: personal,
totalAmount: total,
source: 'batch',
paidAmount: 0,
outstandingAmount: total,
status: 'unpaid',
}),
);
const items = [
...(studentBillData.get(studentId)?.items || []),
...(personalItems.get(studentId) || []),
];
for (const item of items)
await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
if (includedPersonal.length) {
await manager
.createQueryBuilder()
.update(PersonalExpense)
.set({ billId: bill.id })
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
.execute();
}
bill = await this.walletsService.debitBill(manager, bill);
generated.push(bill);
}
return generated;
});
return {
message: `成功生成 ${bills.length} 条账单`,
count: bills.length,
bills,
periodStart,
periodEnd,
};
}
private calculateLongTermRent(
occupancy: Occupancy,
periodStart: string,
periodEnd: string,
monthlyRate: number,
) {
const activeStart =
occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart;
const activeEnd =
occupancy.billingEndDate && occupancy.billingEndDate < periodEnd
? occupancy.billingEndDate
: periodEnd;
if (activeEnd < activeStart || monthlyRate <= 0) return 0;
const [startYear, startMonth] = activeStart.split('-').map(Number);
const [endYear, endMonth] = activeEnd.split('-').map(Number);
let total = 0;
for (
let year = startYear, month = startMonth;
year < endYear || (year === endYear && month <= endMonth);
) {
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
const prefix = `${year}-${String(month).padStart(2, '0')}-`;
const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`;
const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`;
const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd;
const days =
Math.floor(
(Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) /
86_400_000,
) + 1;
total += (monthlyRate * days) / daysInMonth;
if (++month > 12) {
month = 1;
year++;
}
}
return Number(total.toFixed(2));
}
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
private resolveBillingPeriod(billingMonth: string) {
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const year = Number(matched[1]);
const month = Number(matched[2]);
if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const targetMonthStart = new Date(year, month - 1, 1);
const currentMonthStart = new Date();
currentMonthStart.setDate(1);
currentMonthStart.setHours(0, 0, 0, 0);
if (targetMonthStart >= currentMonthStart)
throw new BadRequestException('只能生成已结束月份的账单');
const targetMonthEnd = new Date(year, month, 0);
const pad = (value: number) => String(value).padStart(2, '0');
return {
periodStart: `${year}-${pad(month)}-01`,
periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}`,
};
}
}

View File

@@ -24,7 +24,7 @@ import { BillsExportService } from './bills-export.service';
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import type { Response } from 'express';
@@ -43,16 +43,9 @@ export class BillsController {
@Post('generate')
@RequirePermission('bill:generate')
async generateBills(@Body() dto: GenerateBillsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.generateBills(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '生成账单',
detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '生成账单', detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count}`,
});
// Send bill_generated notifications
try {
@@ -100,17 +93,9 @@ export class BillsController {
@Body() dto: UpdateBillStatusDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateStatus(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '确认账单',
targetId: id,
targetType: 'bill',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill',
});
// Send bill_paid notification
try {
@@ -130,16 +115,9 @@ export class BillsController {
@Put('batch/status')
@RequirePermission('bill:confirm')
async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchUpdateStatus(body.ids, body.status);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '确认账单',
detail: `IDs: ${body.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`,
});
// Send bill_paid notifications (batch)
try {
@@ -163,17 +141,8 @@ export class BillsController {
@RequirePermission('bill:delete')
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) {
const result = await this.service.cancel(id, dto, req.user?.id);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '取消账单并冲正',
targetId: id,
targetType: 'bill',
detail: dto.reason,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason,
});
return result;
}
@@ -181,17 +150,29 @@ export class BillsController {
@Delete(':id')
@RequirePermission('bill:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '归档账单',
targetId: id,
targetType: 'bill',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('bill:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purge(id);
await logAudit(this.logService, req, {
module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复',
});
return result;
}
@Post('batch-permanent-delete')
@RequirePermission('bill:purge')
async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchPurge(body.ids || []);
await logAudit(this.logService, req, {
module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@@ -199,16 +180,9 @@ export class BillsController {
@Post('batch/delete')
@RequirePermission('bill:delete')
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '批量归档账单',
detail: `IDs: ${body.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`,
});
return result;
}
@@ -223,15 +197,8 @@ export class BillsController {
@Res() res?: Response,
@Req() req?: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req?.user?.id,
username: req?.user?.username,
module: '账单管理',
action: '导出账单',
detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '导出账单', detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`,
});
return this.exportService.exportExcel(
{
@@ -247,16 +214,8 @@ export class BillsController {
@Get('export/pdf/:id')
@RequirePermission('bill:export-pdf')
async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req?.user?.id,
username: req?.user?.username,
module: '账单管理',
action: '导出账单',
targetId: id,
targetType: 'bill',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '账单管理', action: '导出账单', targetId: id, targetType: 'bill',
});
return this.exportService.exportStudentPdf(id, res);
}

View File

@@ -11,6 +11,7 @@ import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { BillsService } from './bills.service';
import { BillsGenerationService } from './bills-generation.service';
import { BillsExportService } from './bills-export.service';
import { BillsController } from './bills.controller';
@@ -30,7 +31,7 @@ import { BillsController } from './bills.controller';
WalletsModule,
],
controllers: [BillsController],
providers: [BillsService, BillsExportService],
providers: [BillsService, BillsExportService, BillsGenerationService],
exports: [BillsService],
})
export class BillsModule {}

View File

@@ -0,0 +1,33 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { BillsController } from './bills.controller';
describe('BillsController purge routes', () => {
it('requires bill:purge on permanent delete routes', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, BillsController.prototype.purge)).toEqual([
'bill:purge',
]);
expect(Reflect.getMetadata(PERMISSION_KEY, BillsController.prototype.batchPurge)).toEqual([
'bill:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除账单(不可恢复)' }) };
const log = jest.fn().mockResolvedValue(undefined);
const controller = new BillsController(
service as never,
{} as never,
{ log } as never,
{} as never,
{} as never,
{} as never,
);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge(1, req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '账单管理', action: '永久删除账单', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,71 @@
import { BadRequestException } from '@nestjs/common';
import { BillsService } from './bills.service';
describe('BillsService.purge', () => {
const createService = (overrides?: { bill?: Record<string, unknown> }) => {
const bill = {
id: 1,
studentId: 2,
status: 'cancelled',
paidAmount: 0,
...overrides?.bill,
};
const billRepo = {
findOne: jest.fn().mockResolvedValue(bill),
find: jest.fn().mockResolvedValue([bill]),
};
const personalExpRepo = { count: jest.fn().mockResolvedValue(0) };
const manager = {
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)),
};
const service = new BillsService(
billRepo as never,
{} as never,
{} as never,
personalExpRepo as never,
{} as never,
{} as never,
dataSource as never,
{} as never,
);
return { service, billRepo, personalExpRepo, dataSource, manager };
};
it('rejects bills that are not cancelled', async () => {
const { service, dataSource } = createService({ bill: { status: 'unpaid' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已取消账单可以永久删除,请先取消账单'),
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects cancelled bills with paid amount', async () => {
const { service, dataSource } = createService({ bill: { paidAmount: 100 } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('已发生资金流水的账单不能永久删除'),
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects cancelled bills still referenced by personal expenses', async () => {
const { service, personalExpRepo, dataSource } = createService();
personalExpRepo.count.mockResolvedValue(1);
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('该账单仍关联个人费用,无法永久删除'),
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('deletes bill items and bill in a transaction', async () => {
const { service, dataSource, manager } = createService();
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除账单(不可恢复)',
});
expect(dataSource.transaction).toHaveBeenCalled();
expect(manager.delete).toHaveBeenNthCalledWith(1, expect.anything(), { billId: 1 });
expect(manager.delete).toHaveBeenNthCalledWith(2, expect.anything(), 1);
});
});

View File

@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { BillsService } from './bills.service';
import { BillsGenerationService } from './bills-generation.service';
import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity';
import { RoomExpense } from '../entities/room-expense.entity';
@@ -78,6 +79,7 @@ describe('BillsService — generateBills', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BillsService,
BillsGenerationService,
{ provide: getRepositoryToken(Bill), useValue: billRepo },
{ provide: getRepositoryToken(BillItem), useValue: itemRepo },
{ provide: getRepositoryToken(RoomExpense), useValue: roomExpRepo },
@@ -567,6 +569,17 @@ describe('BillsService — allocation rounding boundary', () => {
})),
})),
};
const walletsService = { debitBill: jest.fn(async (_manager, bill) => bill) } as any;
const generation = new BillsGenerationService(
billRepo as any,
itemRepo as any,
roomExpRepo as any,
personalExpRepo as any,
occRepo as any,
roomRepo as any,
dataSource as any,
walletsService,
);
const service = new BillsService(
billRepo as any,
itemRepo as any,
@@ -575,7 +588,8 @@ describe('BillsService — allocation rounding boundary', () => {
occRepo as any,
roomRepo as any,
dataSource as any,
{ debitBill: jest.fn(async (_manager, bill) => bill) } as any,
walletsService,
generation,
);
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<RoomExpense>([
{ id: 1, roomId: 1, expenseType: 'water', amount: 100, periodStart: '2026-06-01', periodEnd: '2026-06-30' } as RoomExpense,

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource, EntityManager } from 'typeorm';
import { Repository, In, DataSource } from 'typeorm';
import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity';
import { RoomExpense } from '../entities/room-expense.entity';
@@ -11,6 +11,7 @@ import { StudentWallet } from '../entities/student-wallet.entity';
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { WalletsService } from '../wallets/wallets.service';
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
import { BillsGenerationService } from './bills-generation.service';
interface AgentBillRow {
billId: string | number;
@@ -23,7 +24,6 @@ interface AgentBillRow {
status: string;
}
@Injectable()
export class BillsService {
constructor(
@@ -35,6 +35,7 @@ export class BillsService {
@InjectRepository(Room) private roomRepo: Repository<Room>,
private dataSource: DataSource,
private walletsService: WalletsService,
private generation: BillsGenerationService,
@Optional()
private financialOperations?: FinancialOperationsService,
) {}
@@ -44,220 +45,12 @@ export class BillsService {
*/
async generateBills(dto: GenerateBillsDto) {
const { operationId, ...request } = dto;
const work = () => this.generateBillsOnce(request as GenerateBillsDto);
const work = () => this.generation.generateBillsOnce(request as GenerateBillsDto);
return this.financialOperations
? this.financialOperations.run(operationId, 'bill.generate', work)
: work();
}
private async generateBillsOnce(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = dto.billingMonth
? this.resolveBillingPeriod(dto.billingMonth)
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
throw new BadRequestException('账单周期无效,结束日期不能早于开始日期');
}
const pStart = new Date(`${periodStart}T00:00:00Z`);
const pEnd = new Date(`${periodEnd}T00:00:00Z`);
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
if (existingBills.length > 0) {
throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
}
const roomExpenses = await this.roomExpRepo
.createQueryBuilder('e')
.where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { periodStart, periodEnd })
.andWhere('e.status = :status', { status: 'active' })
.getMany();
const longTermOccupancies: Occupancy[] = [];
const roomExpMap = new Map<number, RoomExpense[]>();
for (const expense of roomExpenses) {
const expenses = roomExpMap.get(expense.roomId) || [];
expenses.push(expense);
roomExpMap.set(expense.roomId, expenses);
}
const roomIds = new Set([
...roomExpMap.keys(),
...longTermOccupancies.filter((occupancy) => occupancy.stayType === 'long').map((occupancy) => occupancy.roomId),
]);
const studentBillData = new Map<number, { shared: number; items: Array<Record<string, unknown>> }>();
for (const roomId of roomIds) {
const expenses = roomExpMap.get(roomId) || [];
const occupancies = await this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('o.roomId = :roomId', { roomId })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long');
const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long');
for (const occupancy of longTermOccs) {
const rent = this.calculateLongTermRent(
occupancy,
periodStart,
periodEnd,
Number(occupancy.room?.monthlyRate || 0),
);
if (rent <= 0) continue;
const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] };
data.shared += rent;
data.items.push({
roomId,
expenseType: 'rent',
description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: rent,
studentAmount: rent,
});
studentBillData.set(occupancy.studentId, data);
}
const studentDays = shortTermOccs.map((occupancy) => {
const start = new Date(Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime()));
const end = occupancy.billingEndDate
? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime()))
: pEnd;
const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1);
return { studentId: occupancy.studentId, days };
});
const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0);
if (totalDays === 0) continue;
for (const expense of expenses) {
const eligibleDays = studentDays.filter((entry) => entry.days > 0);
const expenseTotal = Number(Number(expense.amount).toFixed(2));
let allocated = 0;
for (const [index, entry] of eligibleDays.entries()) {
const amount = index === eligibleDays.length - 1
? Number((expenseTotal - allocated).toFixed(2))
: Number(((entry.days / totalDays) * expenseTotal).toFixed(2));
allocated = Number((allocated + amount).toFixed(2));
const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] };
data.shared += amount;
data.items.push({
roomExpenseId: expense.id,
roomId,
expenseType: expense.expenseType,
description: `${expense.expenseType} 分摊`,
days: entry.days,
totalRoomDays: totalDays,
roomTotalAmount: expense.amount,
studentAmount: amount,
});
studentBillData.set(entry.studentId, data);
}
}
}
const personalExps = await this.personalExpRepo
.createQueryBuilder('pe')
.where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { periodStart, periodEnd })
.andWhere('pe.status = :status', { status: 'active' })
.andWhere('pe.billId IS NULL')
.getMany();
const personalMap = new Map<number, number>();
const personalItems = new Map<number, Array<Record<string, unknown>>>();
for (const expense of personalExps) {
personalMap.set(expense.studentId, (personalMap.get(expense.studentId) || 0) + Number(expense.amount));
const items = personalItems.get(expense.studentId) || [];
items.push({
personalExpenseId: expense.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: `个人费用: ${expense.description || expense.expenseType}`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: expense.amount,
studentAmount: expense.amount,
});
personalItems.set(expense.studentId, items);
}
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
const bills = await this.dataSource.transaction(async (manager) => {
const generated: Bill[] = [];
for (const studentId of allStudentIds) {
const shared = studentBillData.get(studentId)?.shared || 0;
const personal = personalMap.get(studentId) || 0;
const total = Number((shared + personal).toFixed(2));
let bill = await manager.save(manager.create(Bill, {
studentId,
periodStart,
periodEnd,
sharedAmount: Number(shared.toFixed(2)),
personalAmount: personal,
totalAmount: total,
source: 'batch',
paidAmount: 0,
outstandingAmount: total,
status: 'unpaid',
}));
const items = [...(studentBillData.get(studentId)?.items || []), ...(personalItems.get(studentId) || [])];
for (const item of items) await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
if (includedPersonal.length) {
await manager.createQueryBuilder()
.update(PersonalExpense)
.set({ billId: bill.id })
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
.execute();
}
bill = await this.walletsService.debitBill(manager, bill);
generated.push(bill);
}
return generated;
});
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
}
private calculateLongTermRent(occupancy: Occupancy, periodStart: string, periodEnd: string, monthlyRate: number) {
const activeStart = occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart;
const activeEnd = occupancy.billingEndDate && occupancy.billingEndDate < periodEnd
? occupancy.billingEndDate
: periodEnd;
if (activeEnd < activeStart || monthlyRate <= 0) return 0;
const [startYear, startMonth] = activeStart.split('-').map(Number);
const [endYear, endMonth] = activeEnd.split('-').map(Number);
let total = 0;
for (let year = startYear, month = startMonth; year < endYear || (year === endYear && month <= endMonth);) {
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
const prefix = `${year}-${String(month).padStart(2, '0')}-`;
const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`;
const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`;
const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd;
const days = Math.floor((Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) / 86_400_000) + 1;
total += monthlyRate * days / daysInMonth;
if (++month > 12) { month = 1; year++; }
}
return Number(total.toFixed(2));
}
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
private resolveBillingPeriod(billingMonth: string) {
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const year = Number(matched[1]);
const month = Number(matched[2]);
if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const targetMonthStart = new Date(year, month - 1, 1);
const currentMonthStart = new Date();
currentMonthStart.setDate(1);
currentMonthStart.setHours(0, 0, 0, 0);
if (targetMonthStart >= currentMonthStart) throw new BadRequestException('只能生成已结束月份的账单');
const targetMonthEnd = new Date(year, month, 0);
const pad = (value: number) => String(value).padStart(2, '0');
return { periodStart: `${year}-${pad(month)}-01`, periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}` };
}
async createImmediatePersonalBill(
expense: PersonalExpense,
periodStart: string,
@@ -286,7 +79,8 @@ export class BillsService {
personalExpenseId: expense.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
description:
expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
days: 0,
totalRoomDays: 0,
roomTotalAmount: expense.amount,
@@ -323,19 +117,28 @@ export class BillsService {
}
async agentSearchBills(query: {
keyword?: string; periodStart?: string; periodEnd?: string; status?: string; limit?: number;
keyword?: string;
periodStart?: string;
periodEnd?: string;
status?: string;
limit?: number;
}) {
const billSelects = [
['student.name', 'studentName'],
['bill.periodStart', 'periodStart'],
['bill.periodEnd', 'periodEnd'],
['bill.totalAmount', 'totalAmount'],
['bill.paidAmount', 'paidAmount'],
['bill.outstandingAmount', 'outstandingAmount'],
['bill.status', 'status'],
] as const;
const qb = this.billRepo
.createQueryBuilder('bill')
.leftJoin('bill.student', 'student')
.select('bill.id', 'billId')
.addSelect('student.name', 'studentName')
.addSelect('bill.periodStart', 'periodStart')
.addSelect('bill.periodEnd', 'periodEnd')
.addSelect('bill.totalAmount', 'totalAmount')
.addSelect('bill.paidAmount', 'paidAmount')
.addSelect('bill.outstandingAmount', 'outstandingAmount')
.addSelect('bill.status', 'status');
.select('bill.id', 'billId');
for (const [column, alias] of billSelects) {
qb.addSelect(column, alias);
}
if (query.keyword) {
const billId = Number(query.keyword);
if (Number.isInteger(billId) && billId > 0) {
@@ -347,14 +150,21 @@ export class BillsService {
qb.andWhere('student.name LIKE :keyword', { keyword: `%${query.keyword}%` });
}
}
if (query.periodStart) qb.andWhere('bill.periodStart >= :periodStart', { periodStart: query.periodStart });
if (query.periodEnd) qb.andWhere('bill.periodEnd <= :periodEnd', { periodEnd: query.periodEnd });
if (query.periodStart)
qb.andWhere('bill.periodStart >= :periodStart', { periodStart: query.periodStart });
if (query.periodEnd)
qb.andWhere('bill.periodEnd <= :periodEnd', { periodEnd: query.periodEnd });
if (query.status) qb.andWhere('bill.status = :status', { status: query.status });
const rows = await qb.orderBy('bill.generatedAt', 'DESC').limit(query.limit ?? 20).getRawMany<AgentBillRow>();
const rows = await qb
.orderBy('bill.generatedAt', 'DESC')
.limit(query.limit ?? 20)
.getRawMany<AgentBillRow>();
return rows.map((row) => ({
...row,
billId: Number(row.billId), totalAmount: Number(row.totalAmount || 0),
paidAmount: Number(row.paidAmount || 0), outstandingAmount: Number(row.outstandingAmount || 0),
billId: Number(row.billId),
totalAmount: Number(row.totalAmount || 0),
paidAmount: Number(row.paidAmount || 0),
outstandingAmount: Number(row.outstandingAmount || 0),
}));
}
@@ -374,7 +184,9 @@ export class BillsService {
.createQueryBuilder('wallet')
.where('wallet.studentId IN (:...ids)', { ids: studentIds })
.getMany();
const balanceMap = new Map(wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]));
const balanceMap = new Map(
wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]),
);
return bills.map((bill) => ({
...bill,
walletBalance: Number((balanceMap.get(bill.studentId) || 0).toFixed(2)),
@@ -394,7 +206,8 @@ export class BillsService {
async batchUpdateStatus(ids: number[], status: string) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要更新的账单');
if (!['unpaid', 'partially_paid', 'paid'].includes(status)) throw new BadRequestException('账单状态无效');
if (!['unpaid', 'partially_paid', 'paid'].includes(status))
throw new BadRequestException('账单状态无效');
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
for (const bill of bills) this.assertStatusMatchesAmounts(bill, status);
@@ -410,16 +223,18 @@ export class BillsService {
async cancel(id: number, dto: CancelBillDto, recordedBy?: number) {
const reason = dto.reason?.trim();
if (!reason) throw new BadRequestException('取消原因不能为空');
const work = () => this.dataSource.transaction(async (manager) => {
const bill = await manager.createQueryBuilder(Bill, 'bill')
.where('bill.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消');
await manager.update(PersonalExpense, { billId: id }, { billId: null });
return this.walletsService.refundBill(manager, bill, reason, recordedBy);
});
const work = () =>
this.dataSource.transaction(async (manager) => {
const bill = await manager
.createQueryBuilder(Bill, 'bill')
.where('bill.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消');
await manager.update(PersonalExpense, { billId: id }, { billId: null });
return this.walletsService.refundBill(manager, bill, reason, recordedBy);
});
return this.financialOperations
? this.financialOperations.run(dto.operationId, `bill.cancel:${id}`, work)
: work();
@@ -441,6 +256,65 @@ export class BillsService {
return { message: '账单已归档' };
}
async purge(id: number) {
const bill = await this.billRepo.findOne({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'cancelled') {
throw new BadRequestException('仅已取消账单可以永久删除,请先取消账单');
}
if (Number(bill.paidAmount) > 0) {
throw new BadRequestException('已发生资金流水的账单不能永久删除');
}
const personalExpenseCount = await this.personalExpRepo.count({ where: { billId: id } });
if (personalExpenseCount > 0) {
throw new BadRequestException('该账单仍关联个人费用,无法永久删除');
}
await this.dataSource.transaction(async (manager) => {
await manager.delete(BillItem, { billId: id });
await manager.delete(Bill, id);
});
return { message: '已永久删除账单(不可恢复)' };
}
async batchPurge(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的账单');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('账单 ID 无效');
}
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
const personalExpenseCount = await this.personalExpRepo.count({
where: { billId: In(uniqueIds) },
});
if (personalExpenseCount > 0) {
throw new BadRequestException('选中账单仍关联个人费用,无法永久删除');
}
const deleted: number[] = [];
const skipped: string[] = [];
for (const bill of bills) {
if (bill.status !== 'cancelled') {
skipped.push(`账单${bill.id}(未取消)`);
continue;
}
if (Number(bill.paidAmount) > 0) {
skipped.push(`账单${bill.id}(已支付)`);
continue;
}
await this.dataSource.transaction(async (manager) => {
await manager.delete(BillItem, { billId: bill.id });
await manager.delete(Bill, bill.id);
});
deleted.push(bill.id);
}
const message =
skipped.length > 0
? `已永久删除 ${deleted.length} 条账单;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已永久删除 ${deleted.length} 条账单(不可恢复)`;
return { message, deleted: deleted.length, skipped: skipped.length };
}
async batchRemove(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的账单');
@@ -469,11 +343,12 @@ export class BillsService {
private assertStatusMatchesAmounts(bill: Bill, status: string) {
const paid = Number(bill.paidAmount || 0);
const outstanding = Number(bill.outstandingAmount || 0);
const matches = status === 'paid'
? outstanding <= 0
: status === 'partially_paid'
? paid > 0 && outstanding > 0
: status === 'unpaid' && paid <= 0 && outstanding > 0;
const matches =
status === 'paid'
? outstanding <= 0
: status === 'partially_paid'
? paid > 0 && outstanding > 0
: status === 'unpaid' && paid <= 0 && outstanding > 0;
if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致');
}
}

View File

@@ -0,0 +1,172 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository, In } from 'typeorm';
import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entities';
import { Classroom } from '../entities/classroom.entity';
import { syncDingTalkStudents } from '../integration/dingtalk-student-sync';
import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
interface AgentClassRow {
id: string | number;
name: string;
code: string;
studentCount: string | number;
}
@Injectable()
export class ClassesQueriesService {
constructor(
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
@InjectRepository(ClassStudent) private readonly classStudentRepo: Repository<ClassStudent>,
@InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository<AttendanceRecord>,
private readonly dataSource: DataSource,
) {}
async agentSearchClasses(
accessibleClassIds: number[] | undefined,
query: { keyword?: string; status?: string; limit?: number },
) {
if (accessibleClassIds?.length === 0) return [];
const qb = this.classRepo
.createQueryBuilder('class')
.leftJoin(
ClassStudent,
'classStudent',
'classStudent.classId = class.id AND classStudent.status = :activeStudent',
{ activeStudent: 'active' },
)
.select('class.id', 'id');
const classSelects = [
['class.name', 'name'],
['class.code', 'code'],
['class.classType', 'classType'],
['class.status', 'status'],
['class.startDate', 'startDate'],
['class.endDate', 'endDate'],
['COUNT(classStudent.id)', 'studentCount'],
] as const;
for (const [column, alias] of classSelects) {
qb.addSelect(column, alias);
}
qb.where('class.isArchived = :isArchived', { isArchived: false });
if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds });
if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` });
if (query.status) qb.andWhere('class.status = :status', { status: query.status });
const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany<AgentClassRow>();
return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) }));
}
async batchImportStudents(
classId: number,
users: Array<{ dingUserId: string; name: string; mobile?: string }>,
): Promise<{ imported: number; skipped: number; conflicts: number }> {
if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 };
return this.dataSource.transaction(async (manager) => {
const classEntity = await manager.findOne(Class, { where: { id: classId } });
if (!classEntity) throw new NotFoundException('班级不存在');
const synced = await syncDingTalkStudents(manager, users);
const studentIds = [...new Set(synced.studentIds.values())];
if (studentIds.length === 0) {
return { imported: 0, skipped: 0, conflicts: synced.conflicts.length };
}
const existingClassStudents = await manager.find(ClassStudent, {
where: { classId, studentId: In(studentIds) },
});
const existingByStudentId = new Map(
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
);
const today = new Date().toISOString().slice(0, 10);
let skipped = 0;
const memberships = studentIds.flatMap((studentId) => {
const existing = existingByStudentId.get(studentId);
if (existing?.status === 'active') {
skipped++;
return [];
}
if (existing) {
existing.status = 'active';
existing.joinDate = today;
existing.leaveDate = null;
return [existing];
}
return [
manager.create(ClassStudent, {
classId,
studentId,
status: 'active',
joinDate: today,
}),
];
});
if (memberships.length > 0) await manager.save(ClassStudent, memberships);
return {
imported: memberships.length,
skipped,
conflicts: synced.conflicts.length,
};
});
}
async getSchedule(classId: number, query: QueryClassScheduleDto) {
const qb = this.scheduleRepo
.createQueryBuilder('cs')
.leftJoinAndSelect('cs.classroom', 'classroom')
.where('cs.classId = :classId', { classId });
if (query.startDate) {
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
}
const schedules = await qb
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.getMany();
return schedules.map((s) => ({
...s,
classroomName: (s.classroom as Classroom | undefined)?.name || null,
}));
}
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.where('ar.classId = :classId', { classId });
if (query.startDate) {
qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate });
}
const rows = await qb.getMany();
const total = rows.length;
const present = rows.filter((r) => r.status === 'present').length;
const late = rows.filter((r) => r.status === 'late').length;
const absent = rows.filter((r) => r.status === 'absent').length;
const leave = rows.filter((r) => r.status === 'leave').length;
return {
total,
present,
late,
absent,
leave,
presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0,
absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0,
lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0,
leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0,
};
}
}

View File

@@ -1,4 +1,5 @@
import { ClassesService } from './classes.service';
import { ClassesQueriesService } from './classes-queries.service';
import { ClassStudent, Student, StudentDingMapping } from '../entities';
describe('ClassesService — DingTalk class import membership lifecycle', () => {
@@ -32,6 +33,14 @@ describe('ClassesService — DingTalk class import membership lifecycle', () =>
create: jest.fn().mockImplementation((_entity: unknown, value: object) => value),
save: jest.fn().mockImplementation(async (_entity: unknown, value: unknown) => value),
};
const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) };
const queries = new ClassesQueriesService(
{} as never,
{} as never,
{} as never,
{} as never,
dataSource as never,
);
const service = new ClassesService(
{} as never,
{} as never,
@@ -41,7 +50,9 @@ describe('ClassesService — DingTalk class import membership lifecycle', () =>
{} as never,
{} as never,
{} as never,
{ transaction: jest.fn().mockImplementation((work) => work(manager)) } as never,
dataSource as never,
{} as never,
queries,
);
const result = await service.batchImportStudents(3, [

View File

@@ -1,3 +1,5 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { ValidationPipe } from '@nestjs/common';
import { ClassesController } from './classes.controller';
import { ClassesService } from './classes.service';
@@ -114,3 +116,25 @@ describe('QueryClassDto - query transformation', () => {
).resolves.toEqual({ isArchived: expected });
});
});
describe('ClassesController purge route', () => {
it('requires class:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, ClassesController.prototype.purge)).toEqual([
'class:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = {
purge: jest.fn().mockResolvedValue({ message: '已永久删除班级(不可恢复)' }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ClassesController(service as never, { log } as never, {} as never, {} as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '班级管理', action: '永久删除班级', targetId: 1 }),
);
});
});

View File

@@ -27,7 +27,7 @@ import {
} from './dto/class.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity';
@@ -115,18 +115,9 @@ export class ClassesController {
@Post()
@RequirePermission('class:create')
async create(@Body() dto: CreateClassDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '创建班级',
targetId: result.id,
targetType: 'class',
detail: `班级${result.code} ${result.name}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '创建班级', targetId: result.id, targetType: 'class', detail: `班级${result.code} ${result.name}`,
});
return result;
}
@@ -155,18 +146,9 @@ export class ClassesController {
@Put(':id')
@RequirePermission('class:edit')
async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '编辑班级',
targetId: +id,
targetType: 'class',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto),
});
return result;
}
@@ -174,17 +156,19 @@ export class ClassesController {
@Delete(':id')
@RequirePermission('class:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '归档班级',
targetId: +id,
targetType: 'class',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('class:purge')
async purge(@Param('id') id: string, @Request() req: any) {
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复',
});
return result;
}
@@ -242,18 +226,9 @@ export class ClassesController {
@Post(':id/students')
@RequirePermission('class:edit')
async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addStudents(+id, dto.studentIds);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '添加学生',
targetId: +id,
targetType: 'class',
detail: `新增${result.added}名学生`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`,
});
try {
const cls = await this.service.findOne(+id);
@@ -265,7 +240,9 @@ export class ClassesController {
content: `班级新增${result.added}名学生`,
});
}
} catch {}
} catch {
// 通知失败不影响班级新增结果
}
return result;
}
@@ -276,18 +253,9 @@ export class ClassesController {
@Param('studentId') studentId: string,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.removeStudent(+id, +studentId);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '移除学生',
targetId: +id,
targetType: 'class',
detail: `移除学生${studentId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`,
});
return result;
}
@@ -302,18 +270,9 @@ export class ClassesController {
@Post(':id/teachers')
@RequirePermission('class:edit')
async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addTeacher(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '添加教师',
targetId: +id,
targetType: 'class',
detail: `教师${dto.userId} 角色${dto.roleType}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`,
});
try {
void this.notificationsService.create({
@@ -322,7 +281,9 @@ export class ClassesController {
title: '班级分配',
content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`,
});
} catch {}
} catch {
// 通知失败不影响班级分配结果
}
return result;
}
@@ -333,18 +294,9 @@ export class ClassesController {
@Param('assignmentId') assignmentId: string,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '移除教师角色',
targetId: +id,
targetType: 'class',
detail: `移除教师分配${assignmentId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`,
});
return result;
}
@@ -356,18 +308,9 @@ export class ClassesController {
@Param('userId') userId: string,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.removeTeacher(+id, +userId);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '班级管理',
action: '移除教师',
targetId: +id,
targetType: 'class',
detail: `移除教师${userId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`,
});
return result;
}

View File

@@ -1,15 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping } from '../entities';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping } from '../entities';
import { ClassesService } from './classes.service';
import { ClassesQueriesService } from './classes-queries.service';
import { ClassesController } from './classes.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
controllers: [ClassesController],
providers: [ClassesService],
providers: [ClassesService, ClassesQueriesService],
exports: [ClassesService],
})
export class ClassesModule {}

View File

@@ -0,0 +1,54 @@
import { BadRequestException } from '@nestjs/common';
import { ClassesService } from './classes.service';
describe('ClassesService.purge', () => {
const createService = (overrides?: {
cls?: Record<string, unknown>;
counts?: Record<string, number>;
}) => {
const cls = { id: 1, name: '冲刺班', code: 'C1', isArchived: true, ...overrides?.cls };
const repo = {
findOne: jest.fn().mockResolvedValue(cls),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const counts = overrides?.counts ?? {};
const countFor = (key: string) => jest.fn().mockResolvedValue(counts[key] ?? 0);
const service = new ClassesService(
repo as never,
{ count: countFor('classStudent') } as never,
{ count: countFor('classTeacher') } as never,
{ count: countFor('schedule') } as never,
{ count: countFor('attendance') } as never,
{ count: countFor('session') } as never,
{} as never,
{} as never,
{} as never,
{ count: countFor('exam') } as never,
);
return { service, repo };
};
it('rejects classes that are not archived', async () => {
const { service, repo } = createService({ cls: { isArchived: false } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档班级可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects classes with students, teachers, schedules, exams, or attendance', async () => {
const { service, repo } = createService({ counts: { classStudent: 1 } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('该班级存在关联数据(班级学生),无法永久删除'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived class with no references', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除班级(不可恢复)',
});
expect(repo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -1,23 +1,26 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository, In, Like } from 'typeorm';
import { DataSource,
Repository,
In,
Like } from 'typeorm';
import {
Class,
ClassStudent,
ClassTeacher,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
Classroom,
Student,
StudentDingMapping,
ClassStudent,
ClassTeacher,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
Exam,
Student,
StudentDingMapping
} from '../entities';
import { syncDingTalkStudents } from '../integration/dingtalk-student-sync';
import { ClassesQueriesService } from './classes-queries.service';
import { normalizeDateOnly } from '../database/date-normalization';
import {
CreateClassDto,
@@ -33,17 +36,6 @@ interface RawStudentCount {
count: string;
}
interface AgentClassRow {
id: string | number;
name: string;
code: string;
classType: string;
status: string;
startDate: string | null;
endDate: string | null;
studentCount: string | number;
}
@Injectable()
export class ClassesService {
constructor(
@@ -64,6 +56,9 @@ export class ClassesService {
@InjectRepository(StudentDingMapping)
private studentDingMappingRepo: Repository<StudentDingMapping>,
private dataSource: DataSource,
@InjectRepository(Exam)
private examRepo: Repository<Exam>,
private queries: ClassesQueriesService,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
@@ -84,30 +79,22 @@ export class ClassesService {
query: { keyword?: string; status?: string; limit?: number },
) {
const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll);
if (accessibleClassIds?.length === 0) return [];
return this.queries.agentSearchClasses(accessibleClassIds, query);
}
const qb = this.classRepo
.createQueryBuilder('class')
.leftJoin(
ClassStudent,
'classStudent',
'classStudent.classId = class.id AND classStudent.status = :activeStudent',
{ activeStudent: 'active' },
)
.select('class.id', 'id')
.addSelect('class.name', 'name')
.addSelect('class.code', 'code')
.addSelect('class.classType', 'classType')
.addSelect('class.status', 'status')
.addSelect('class.startDate', 'startDate')
.addSelect('class.endDate', 'endDate')
.addSelect('COUNT(classStudent.id)', 'studentCount')
.where('class.isArchived = :isArchived', { isArchived: false });
if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds });
if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` });
if (query.status) qb.andWhere('class.status = :status', { status: query.status });
const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany<AgentClassRow>();
return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) }));
async batchImportStudents(
classId: number,
users: Array<{ dingUserId: string; name: string; mobile?: string }>,
): Promise<{ imported: number; skipped: number; conflicts: number }> {
return this.queries.batchImportStudents(classId, users);
}
async getSchedule(classId: number, query: QueryClassScheduleDto) {
return this.queries.getSchedule(classId, query);
}
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
return this.queries.getAttendanceSummary(classId, query);
}
async findAll(query: QueryClassDto, accessibleClassIds?: number[]) {
@@ -227,64 +214,6 @@ export class ClassesService {
return this.findOne(saved.id);
}
async batchImportStudents(
classId: number,
users: Array<{
dingUserId: string;
name: string;
mobile?: string;
}>,
): Promise<{ imported: number; skipped: number; conflicts: number }> {
if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 };
return this.dataSource.transaction(async (manager) => {
const classEntity = await manager.findOne(Class, { where: { id: classId } });
if (!classEntity) throw new NotFoundException('班级不存在');
const synced = await syncDingTalkStudents(manager, users);
const studentIds = [...new Set(synced.studentIds.values())];
if (studentIds.length === 0) {
return { imported: 0, skipped: 0, conflicts: synced.conflicts.length };
}
const existingClassStudents = await manager.find(ClassStudent, {
where: { classId, studentId: In(studentIds) },
});
const existingByStudentId = new Map(
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
);
const today = new Date().toISOString().slice(0, 10);
let skipped = 0;
const memberships = studentIds.flatMap((studentId) => {
const existing = existingByStudentId.get(studentId);
if (existing?.status === 'active') {
skipped++;
return [];
}
if (existing) {
existing.status = 'active';
existing.joinDate = today;
existing.leaveDate = null;
return [existing];
}
return [
manager.create(ClassStudent, {
classId,
studentId,
status: 'active',
joinDate: today,
}),
];
});
if (memberships.length > 0) await manager.save(ClassStudent, memberships);
return {
imported: memberships.length,
skipped,
conflicts: synced.conflicts.length,
};
});
}
async update(id: number, dto: UpdateClassDto) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
@@ -323,6 +252,33 @@ export class ClassesService {
return this.archive(id);
}
/** 永久删除班级(仅已归档) */
async purge(id: number) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
if (!cls.isArchived) throw new BadRequestException('仅已归档班级可以永久删除,请先归档');
const [studentCount, teacherCount, scheduleCount, examCount, sessionCount, attendanceCount] =
await Promise.all([
this.classStudentRepo.count({ where: { classId: id } }),
this.classTeacherRepo.count({ where: { classId: id } }),
this.scheduleRepo.count({ where: { classId: id } }),
this.examRepo.count({ where: { classId: id } }),
this.attendanceSessionRepo.count({ where: { classId: id } }),
this.attendanceRepo.count({ where: { classId: id } }),
]);
const references: string[] = [];
if (studentCount > 0) references.push('班级学生');
if (teacherCount > 0) references.push('任课教师');
if (scheduleCount > 0) references.push('排课');
if (examCount > 0) references.push('考试');
if (sessionCount > 0 || attendanceCount > 0) references.push('考勤记录');
if (references.length > 0) {
throw new BadRequestException(`该班级存在关联数据(${references.join('、')}),无法永久删除`);
}
await this.classRepo.delete(id);
return { message: '已永久删除班级(不可恢复)' };
}
async getStudents(classId: number) {
return this.classStudentRepo.find({
where: { classId },
@@ -447,61 +403,4 @@ export class ClassesService {
academicTeacherId: academic?.userId ?? null,
} as Partial<Class>);
}
async getSchedule(classId: number, query: QueryClassScheduleDto) {
const qb = this.scheduleRepo
.createQueryBuilder('cs')
.leftJoinAndSelect('cs.classroom', 'classroom')
.where('cs.classId = :classId', { classId });
if (query.startDate) {
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
}
const schedules = await qb
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.getMany();
return schedules.map((s) => ({
...s,
classroomName: (s.classroom as Classroom | undefined)?.name || null,
}));
}
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.where('ar.classId = :classId', { classId });
if (query.startDate) {
qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate });
}
const rows = await qb.getMany();
const total = rows.length;
const present = rows.filter((r) => r.status === 'present').length;
const late = rows.filter((r) => r.status === 'late').length;
const absent = rows.filter((r) => r.status === 'absent').length;
const leave = rows.filter((r) => r.status === 'leave').length;
return {
total,
present,
late,
absent,
leave,
presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0,
absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0,
lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0,
leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0,
};
}
}

View File

@@ -21,7 +21,7 @@ import { ClassroomRentalsService } from './classroom-rentals.service';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@@ -102,18 +102,9 @@ export class ClassroomRentalsController {
@Post()
@RequirePermission('rental:create')
async create(@Body() dto: CreateRentalDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '新增租赁',
targetId: result.id,
targetType: 'classroom-rental',
detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '新增租赁', targetId: result.id, targetType: 'classroom-rental', detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`,
});
return result;
}
@@ -121,18 +112,9 @@ export class ClassroomRentalsController {
@Put(':id')
@RequirePermission('rental:edit')
async update(@Param('id') id: string, @Body() dto: UpdateRentalDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '编辑租赁',
targetId: +id,
targetType: 'classroom-rental',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto),
});
return result;
}
@@ -140,17 +122,9 @@ export class ClassroomRentalsController {
@Put(':id/cancel')
@RequirePermission('rental:edit')
async cancel(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.cancel(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '取消租赁',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental',
});
return result;
}
@@ -158,17 +132,9 @@ export class ClassroomRentalsController {
@Put(':id/end')
@RequirePermission('rental:edit')
async end(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.end(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '结束租赁',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental',
});
return result;
}
@@ -176,17 +142,19 @@ export class ClassroomRentalsController {
@Delete(':id')
@RequirePermission('rental:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '归档租赁',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('rental:purge')
async purge(@Param('id') id: string, @Request() req: any) {
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复',
});
return result;
}
@@ -211,18 +179,9 @@ export class ClassroomRentalsController {
@Request() req: any,
) {
if (!file) throw new BadRequestException('请上传合同文件');
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.attachContract(+id, file);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '上传合同',
targetId: +id,
targetType: 'classroom-rental',
detail: file.originalname,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental', detail: file.originalname,
});
return result;
}
@@ -243,17 +202,9 @@ export class ClassroomRentalsController {
@Delete(':id/contract')
@RequirePermission('rental:edit')
async deleteContract(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.removeContract(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '移除合同',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental',
});
return result;
}

View File

@@ -4,17 +4,27 @@ import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { RentalScheduleService } from './rental-schedule.service';
import { ClassroomRentalsController } from './classroom-rentals.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [
TypeOrmModule.forFeature([ClassroomRental, Classroom, Organization, ClassSchedule]),
TypeOrmModule.forFeature([
ClassroomRental,
Classroom,
Organization,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
]),
OperationLogsModule,
],
controllers: [ClassroomRentalsController],
providers: [ClassroomRentalsService],
providers: [ClassroomRentalsService, RentalScheduleService],
exports: [ClassroomRentalsService],
})
export class ClassroomRentalsModule {}

View File

@@ -0,0 +1,25 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { ClassroomRentalsController } from './classroom-rentals.controller';
describe('ClassroomRentalsController purge route', () => {
it('requires rental:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, ClassroomRentalsController.prototype.purge)).toEqual([
'rental:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = {
purge: jest.fn().mockResolvedValue({ message: '已永久删除租赁订单(不可恢复)' }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ClassroomRentalsController(service as never, { log } as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '教室租赁', action: '永久删除租赁订单', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,92 @@
import { BadRequestException } from '@nestjs/common';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { RentalScheduleService } from './rental-schedule.service';
describe('ClassroomRentalsService.purge', () => {
const createService = (overrides?: {
rental?: Record<string, unknown>;
schedules?: Record<string, unknown>[];
sessionCount?: number;
recordCount?: number;
}) => {
const rental = {
id: 1,
classroomId: 2,
status: 'cancelled',
startDate: '2026-01-01',
endDate: '2026-01-31',
contractPath: null,
...overrides?.rental,
};
const repo = {
findOne: jest.fn().mockResolvedValue(rental),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const scheduleRepo = {
find: jest.fn().mockResolvedValue(overrides?.schedules ?? []),
};
const attendanceRepo = { count: jest.fn().mockResolvedValue(overrides?.recordCount ?? 0) };
const attendanceSessionRepo = {
count: jest.fn().mockResolvedValue(overrides?.sessionCount ?? 0),
};
const manager = {
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb(manager)),
};
const scheduleService = new RentalScheduleService(repo as never, {} as never, scheduleRepo as never);
const service = new ClassroomRentalsService(
repo as never,
{} as never,
{} as never,
scheduleRepo as never,
attendanceRepo as never,
attendanceSessionRepo as never,
dataSource as never,
scheduleService,
);
return { service, repo, scheduleRepo, attendanceRepo, attendanceSessionRepo, dataSource, manager };
};
it('rejects rentals that are not cancelled', async () => {
const { service, dataSource } = createService({ rental: { status: 'active' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已取消租赁订单可以永久删除,请先取消'),
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects cancelled rentals whose schedules have attendance history', async () => {
const withSession = createService({
schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }],
sessionCount: 1,
});
await expect(withSession.service.purge(1)).rejects.toThrow(
new BadRequestException('该租赁的排课已有考勤记录,无法永久删除'),
);
const withRecord = createService({
schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }],
recordCount: 1,
});
await expect(withRecord.service.purge(1)).rejects.toThrow(
new BadRequestException('该租赁的排课已有考勤记录,无法永久删除'),
);
expect(withRecord.dataSource.transaction).not.toHaveBeenCalled();
});
it('deletes schedules and rental without attendance history', async () => {
const { service, dataSource, manager } = createService({
schedules: [{ id: 5, rentalId: 1, scheduleType: 'RENTAL' }],
});
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除租赁订单(不可恢复)',
});
expect(dataSource.transaction).toHaveBeenCalled();
expect(manager.delete).toHaveBeenNthCalledWith(1, expect.anything(), {
id: expect.anything(),
});
expect(manager.delete).toHaveBeenNthCalledWith(2, expect.anything(), 1);
});
});

View File

@@ -1,12 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConflictException } from '@nestjs/common';
import { Not, Repository } from 'typeorm';
import { DataSource, Not, Repository } from 'typeorm';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { RentalScheduleService } from './rental-schedule.service';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
function mockQueryBuilder<T>(results: T[] = []) {
@@ -28,6 +31,8 @@ describe('ClassroomRentalsService — findConflicts', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClassroomRentalsService,
RentalScheduleService,
RentalScheduleService,
{
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
@@ -35,6 +40,12 @@ describe('ClassroomRentalsService — findConflicts', () => {
{ provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Organization), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
{ provide: getRepositoryToken(AttendanceRecord), useValue: {} },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
{
provide: DataSource,
useValue: { transaction: jest.fn((cb: (m: unknown) => Promise<unknown>) => cb({})) },
},
],
}).compile();
@@ -131,10 +142,17 @@ describe('ClassroomRentalsService — unavailable dates', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClassroomRentalsService,
RentalScheduleService,
{ provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } },
{ provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Organization), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } },
{ provide: getRepositoryToken(AttendanceRecord), useValue: {} },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
{
provide: DataSource,
useValue: { transaction: jest.fn((cb: (m: unknown) => Promise<unknown>) => cb({})) },
},
],
}).compile();
@@ -225,10 +243,17 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClassroomRentalsService,
RentalScheduleService,
{ provide: getRepositoryToken(ClassroomRental), useValue: rentalRepo },
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo },
{ provide: getRepositoryToken(Organization), useValue: organizationRepo },
{ provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo },
{ provide: getRepositoryToken(AttendanceRecord), useValue: {} },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
{
provide: DataSource,
useValue: { transaction: jest.fn((cb: (m: unknown) => Promise<unknown>) => cb({})) },
},
],
}).compile();
@@ -475,11 +500,16 @@ describe('ClassroomRentalsService — organization roles', () => {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassSchedule>([])),
} as any;
const scheduleService = new RentalScheduleService(rentalRepo, classroomRepo, scheduleRepo);
const service = new ClassroomRentalsService(
rentalRepo,
classroomRepo,
organizationRepo,
scheduleRepo,
{} as any,
{} as any,
{} as any,
scheduleService,
);
await service.create({

View File

@@ -4,30 +4,35 @@ import {
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
import { RentalScheduleService } from './rental-schedule.service';
import * as path from 'path';
import * as fs from 'fs';
// 预设色板(与 organizations.service 保持一致,作为颜色兜底)
const COLOR_PALETTE = [
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
function rentalConflictError(
message: string,
conflicts: Array<{ id: number; startDate: string; endDate: string; lesseeOrganization?: { name?: string | null } | null }>,
) {
return new ConflictException({
message,
conflicts: conflicts.map((c) => ({
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
organizationName: c.lesseeOrganization?.name,
})),
});
}
@Injectable()
export class ClassroomRentalsService {
@@ -36,6 +41,10 @@ export class ClassroomRentalsService {
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectDataSource() private dataSource: DataSource,
private schedule: RentalScheduleService,
) {}
get uploadDir(): string {
@@ -74,7 +83,7 @@ export class ClassroomRentalsService {
qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE });
}
const rentals = await qb.getMany();
return rentals.map((rental) => this.withEffectiveStatus(rental));
return rentals.map((rental) => this.schedule.withEffectiveStatus(rental));
}
/**
@@ -98,19 +107,24 @@ export class ClassroomRentalsService {
contractName: string | null;
}[]
> {
const rentalSelects = [
['classroom.name', 'classroomName'],
['lesseeOrganization.name', 'lesseeOrganizationName'],
['r.startDate', 'startDate'],
['r.endDate', 'endDate'],
['r.dailyRate', 'dailyRate'],
['r.totalAmount', 'totalAmount'],
['r.status', 'status'],
['r.contractOriginalName', 'contractName'],
] as const;
const qb = this.repo
.createQueryBuilder('r')
.leftJoin('r.classroom', 'classroom')
.leftJoin('r.lesseeOrganization', 'lesseeOrganization')
.select('r.id', 'id')
.addSelect('classroom.name', 'classroomName')
.addSelect('lesseeOrganization.name', 'lesseeOrganizationName')
.addSelect('r.startDate', 'startDate')
.addSelect('r.endDate', 'endDate')
.addSelect('r.dailyRate', 'dailyRate')
.addSelect('r.totalAmount', 'totalAmount')
.addSelect('r.status', 'status')
.addSelect('r.contractOriginalName', 'contractName');
.select('r.id', 'id');
for (const [column, alias] of rentalSelects) {
qb.addSelect(column, alias);
}
if (query?.classroomId) {
qb.andWhere('r.classroomId = :classroomId', { classroomId: query.classroomId });
}
@@ -148,142 +162,19 @@ export class ClassroomRentalsService {
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
});
if (!rental) throw new NotFoundException('租赁订单不存在');
return this.withEffectiveStatus(rental);
return this.schedule.withEffectiveStatus(rental);
}
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const [rentals, schedules] = await Promise.all([
this.repo.find({
where: {
...(excludeId ? { id: Not(excludeId) } : {}),
classroomId,
status: ClassroomRentalStatus.ACTIVE,
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
}),
this.scheduleRepo.find({
where: {
classroomId,
status: ClassroomRentalStatus.ACTIVE,
scheduleType: 'INTERNAL',
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
}),
]);
const unavailableDates = new Set<string>();
for (const rental of rentals) {
this.addDateRange(
unavailableDates,
rental.startDate > monthStart ? rental.startDate : monthStart,
rental.endDate < monthEnd ? rental.endDate : monthEnd,
);
}
for (const schedule of schedules) {
this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd);
}
return { dates: Array.from(unavailableDates).sort() };
return this.schedule.getUnavailableDates(classroomId, year, month, excludeId);
}
/**
* 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课
* 重叠判定start1 <= end2 AND start2 <= end1
*/
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.where('r.classroomId = :cid', { cid: classroomId })
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
.andWhere('r.startDate <= :end', { end: endDate })
.andWhere('r.endDate >= :start', { start: startDate });
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
const rentals = await qb.getMany();
// 检测同一教室同一日期段是否存在内部排课
const scheduleCandidates = await this.scheduleRepo
.createQueryBuilder('cs')
.where('cs.classroomId = :cid', { cid: classroomId })
.andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })
.andWhere('cs.startDate <= :end', { end: endDate })
.andWhere('cs.endDate >= :start', { start: startDate })
.getMany();
const scheduleConflicts = scheduleCandidates.filter((schedule) =>
this.hasScheduleOccurrence(schedule, startDate, endDate),
);
if (scheduleConflicts.length > 0) {
throw new ConflictException({
message: '该教室在此时间段已有排课',
conflicts: scheduleConflicts.map((s) => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate,
organizationName: `[内部排课] ${s.subject}`,
})),
});
}
return rentals;
return this.schedule.findConflicts(classroomId, startDate, endDate, excludeId);
}
private hasScheduleOccurrence(
schedule: ClassSchedule,
startDate: string,
endDate: string,
): boolean {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
if (overlapStart > overlapEnd) return false;
const startUtc = this.toUtcDate(overlapStart);
const endUtc = this.toUtcDate(overlapEnd);
const startWeekDay = startUtc.getUTCDay() || 7;
const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7;
startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence);
return startUtc <= endUtc;
}
private toUtcDate(date: string): Date {
const [year, month, day] = date.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
private addDateRange(dates: Set<string>, startDate: string, endDate: string) {
const current = this.toUtcDate(startDate);
const end = this.toUtcDate(endDate);
while (current <= end) {
dates.add(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 1);
}
}
private addScheduleOccurrences(
dates: Set<string>,
schedule: ClassSchedule,
startDate: string,
endDate: string,
) {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
if (overlapStart > overlapEnd) return;
const current = this.toUtcDate(overlapStart);
const end = this.toUtcDate(overlapEnd);
const startWeekDay = current.getUTCDay() || 7;
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
while (current <= end) {
dates.add(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 7);
}
async getSchedule(year: number, month: number) {
return this.schedule.getSchedule(year, month);
}
async create(dto: CreateRentalDto, userId?: number) {
@@ -309,15 +200,7 @@ export class ClassroomRentalsService {
const conflicts = await this.findConflicts(dto.classroomId, dto.startDate, dto.endDate);
if (conflicts.length > 0) {
throw new ConflictException({
message: '该教室在此时间段已有租赁',
conflicts: conflicts.map((c) => ({
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
organizationName: c.lesseeOrganization?.name,
})),
});
throw rentalConflictError('该教室在此时间段已有租赁', conflicts);
}
const rental = this.repo.create({
...dto,
@@ -327,7 +210,7 @@ export class ClassroomRentalsService {
status: ClassroomRentalStatus.ACTIVE,
});
const saved = await this.repo.save(rental);
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
await this.schedule.syncScheduleFromRental(saved, lesseeOrganization.name);
return saved;
}
@@ -351,15 +234,7 @@ export class ClassroomRentalsService {
if (dto.classroomId || dto.startDate || dto.endDate) {
const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id);
if (conflicts.length > 0) {
throw new ConflictException({
message: '修改后时间段与已有租赁冲突',
conflicts: conflicts.map((c) => ({
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
organizationName: c.lesseeOrganization?.name,
})),
});
throw rentalConflictError('修改后时间段与已有租赁冲突', conflicts);
}
}
const newLessorId = dto.lessorOrganizationId ?? rental.lessorOrganizationId;
@@ -381,7 +256,7 @@ export class ClassroomRentalsService {
}
await this.repo.update(id, dto);
const updated = await this.findOne(id);
await this.syncScheduleFromRental(updated);
await this.schedule.syncScheduleFromRental(updated);
return updated;
}
@@ -412,7 +287,7 @@ export class ClassroomRentalsService {
endDate: rental.endDate > today ? today : rental.endDate,
});
const ended = await this.findOne(id);
await this.syncScheduleFromRental(ended);
await this.schedule.syncScheduleFromRental(ended);
return ended;
}
@@ -426,56 +301,40 @@ export class ClassroomRentalsService {
return { message: '租赁订单已归档(合同文件已保留)' };
}
private withEffectiveStatus(rental: ClassroomRental) {
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const effectiveStatus =
rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today
? ClassroomRentalStatus.ENDED
: rental.status;
return Object.assign(rental, { effectiveStatus });
}
/**
* 同步租赁订单到 class_schedulesschedule_type = 'RENTAL'
*/
private async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) {
const name = organizationName || rental.lesseeOrganization?.name || '承租机构';
const weekDay = this.dateToWeekDay(rental.startDate);
let schedule = await this.scheduleRepo.findOne({
where: { rentalId: rental.id, scheduleType: 'RENTAL' },
});
const data = {
classroomId: rental.classroomId,
classId: null,
weekDay,
startTime: '00:00',
endTime: '23:59',
startDate: rental.startDate,
endDate: rental.endDate,
subject: `${name} 租赁`,
teacherId: null,
scheduleType: 'RENTAL',
rentalId: rental.id,
status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',
notes: rental.notes,
};
if (schedule) {
await this.scheduleRepo.update(schedule.id, data);
} else {
schedule = this.scheduleRepo.create(data);
await this.scheduleRepo.save(schedule);
async purge(id: number) {
const rental = await this.findOne(id);
if (rental.status !== ClassroomRentalStatus.CANCELLED) {
throw new BadRequestException('仅已取消租赁订单可以永久删除,请先取消');
}
}
private dateToWeekDay(date: string): number {
const d = new Date(date);
const day = d.getDay();
return day === 0 ? 7 : day;
const schedules = await this.scheduleRepo.find({
where: { rentalId: id, scheduleType: 'RENTAL' },
});
const scheduleIds = schedules.map((schedule) => schedule.id);
if (scheduleIds.length > 0) {
const [sessionCount, recordCount] = await Promise.all([
this.attendanceSessionRepo.count({ where: { scheduleId: In(scheduleIds) } }),
this.attendanceRepo.count({ where: { scheduleId: In(scheduleIds) } }),
]);
if (sessionCount > 0 || recordCount > 0) {
throw new BadRequestException('该租赁的排课已有考勤记录,无法永久删除');
}
}
await this.dataSource.transaction(async (manager) => {
if (scheduleIds.length > 0) {
await manager.delete(ClassSchedule, { id: In(scheduleIds) });
}
await manager.delete(ClassroomRental, id);
});
if (rental.contractPath) {
const fullPath = path.join(this.uploadDir, rental.contractPath);
try {
if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath);
} catch (error) {
// 文件删除失败仅告警,不阻塞数据库删除
console.warn(`[ClassroomRentalsService] 合同文件删除失败: ${fullPath}`, error);
}
}
return { message: '已永久删除租赁订单(不可恢复)' };
}
async attachContract(id: number, file: Express.Multer.File) {
@@ -488,9 +347,7 @@ export class ClassroomRentalsService {
const ext = path.extname(file.originalname).toLowerCase();
if (ext !== '.pdf') throw new BadRequestException('文件扩展名必须为 .pdf');
// UUID 文件名
const uuid =
(globalThis as any).crypto?.randomUUID?.() ||
require('crypto').randomBytes(16).toString('hex');
const uuid = require('crypto').randomBytes(16).toString('hex');
const filename = `${uuid}.pdf`;
const fullPath = path.join(this.uploadDir, filename);
// 路径遍历防护
@@ -525,7 +382,7 @@ export class ClassroomRentalsService {
/* ignore */
}
}
await this.repo.update(id, { contractPath: null as any, contractOriginalName: null as any });
await this.repo.update(id, { contractPath: null, contractOriginalName: null });
return { message: '合同已移除' };
}
@@ -544,127 +401,4 @@ export class ClassroomRentalsService {
/**
* 获取月度排期矩阵
*/
async getSchedule(year: number, month: number) {
const lastDay = new Date(year, month, 0).getDate();
const first = `${year}-${String(month).padStart(2, '0')}-01`;
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const classrooms = await this.classroomRepo.find({
where: { status: Not(ClassroomStatus.ARCHIVED) },
order: { building: 'ASC', name: 'ASC' },
});
const rentals = await this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.leftJoinAndSelect('r.classroom', 'classroom')
.where('r.status IN (:...statuses)', {
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
})
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
.getMany();
const organizationMap = new Map<number, any>();
const matrix: Record<number, Record<number, any>> = {};
const summary: Record<
number,
{ totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }
> = {};
for (const cls of classrooms) {
matrix[cls.id] = {};
summary[cls.id] = { totalDays: lastDay, rentedDays: 0, idleDays: lastDay, occupancyRate: 0 };
}
for (const rental of rentals) {
const start = new Date(rental.startDate);
const end = new Date(rental.endDate);
const monthStart = new Date(first);
const monthEnd = new Date(last);
const effStart = start < monthStart ? monthStart : start;
const effEnd = end > monthEnd ? monthEnd : end;
if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) {
organizationMap.set(rental.lesseeOrganization.id, {
id: rental.lesseeOrganization.id,
name: rental.lesseeOrganization.name,
color:
rental.lesseeOrganization.color ||
COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length],
});
}
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
const day = d.getDate();
if (!matrix[rental.classroomId]) continue;
matrix[rental.classroomId][day] = {
scheduleType: 'RENTAL',
rentalId: rental.id,
organizationId: rental.lesseeOrganizationId,
organizationName: rental.lesseeOrganization?.name || '未知',
color:
rental.lesseeOrganization?.color ||
COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length],
hasContract: !!rental.contractPath,
};
}
}
// ── Overlay internal class schedules ──
const schedules = await this.scheduleRepo
.createQueryBuilder('s')
.leftJoinAndSelect('s.class', 'class')
.leftJoinAndSelect('s.teacher', 'teacher')
.where('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last })
.getMany();
for (const sched of schedules) {
if (!sched.classroomId) continue;
const schedStart = new Date(
Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()),
);
const schedEnd = new Date(
Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()),
);
for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) {
const dow = d.getDay() === 0 ? 7 : d.getDay();
if (dow !== sched.weekDay) continue;
const day = d.getDate();
if (!matrix[sched.classroomId]) continue;
matrix[sched.classroomId][day] = {
scheduleType: 'INTERNAL',
scheduleId: sched.id,
className: (sched.class as any)?.name || '',
subject: sched.subject,
teacherName: (sched.teacher as any)?.name || '',
startTime: sched.startTime,
endTime: sched.endTime,
color: '#52c41a',
};
}
}
// 统计
for (const cls of classrooms) {
const rented = Object.keys(matrix[cls.id]).length;
summary[cls.id].rentedDays = rented;
summary[cls.id].idleDays = lastDay - rented;
summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0;
}
return {
year,
month,
days: lastDay,
classrooms: classrooms.map((c) => ({
id: c.id,
name: c.name,
building: c.building,
floor: c.floor,
roomType: c.roomType,
capacity: c.capacity,
})),
organizations: Array.from(organizationMap.values()),
matrix,
summary,
};
}
}

View File

@@ -0,0 +1,341 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { ClassroomRental, Classroom, ClassSchedule, ClassroomStatus } from '../entities';
import { ClassroomRentalStatus } from '../entities/classroom-rental.entity';
const COLOR_PALETTE = [
"#5B8FF9",
"#61DDAA",
"#65789B",
"#F6BD16",
"#7262FD",
"#78D3F8",
"#9661BC",
"#F6903D",
"#008685",
"#F08BB4"
];
@Injectable()
export class RentalScheduleService {
constructor(
@InjectRepository(ClassroomRental) private repo: Repository<ClassroomRental>,
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
) {}
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const [rentals, schedules] = await Promise.all([
this.repo.find({
where: {
...(excludeId ? { id: Not(excludeId) } : {}),
classroomId,
status: ClassroomRentalStatus.ACTIVE,
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
}),
this.scheduleRepo.find({
where: {
classroomId,
status: ClassroomRentalStatus.ACTIVE,
scheduleType: 'INTERNAL',
startDate: LessThanOrEqual(monthEnd),
endDate: MoreThanOrEqual(monthStart),
},
}),
]);
const unavailableDates = new Set<string>();
for (const rental of rentals) {
this.addDateRange(
unavailableDates,
rental.startDate > monthStart ? rental.startDate : monthStart,
rental.endDate < monthEnd ? rental.endDate : monthEnd,
);
}
for (const schedule of schedules) {
this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd);
}
return { dates: Array.from(unavailableDates).sort() };
}
/**
* 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课
* 重叠判定start1 <= end2 AND start2 <= end1
*/
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.where('r.classroomId = :cid', { cid: classroomId })
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
.andWhere('r.startDate <= :end', { end: endDate })
.andWhere('r.endDate >= :start', { start: startDate });
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
const rentals = await qb.getMany();
// 检测同一教室同一日期段是否存在内部排课
const scheduleCandidates = await this.scheduleRepo
.createQueryBuilder('cs')
.where('cs.classroomId = :cid', { cid: classroomId })
.andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })
.andWhere('cs.startDate <= :end', { end: endDate })
.andWhere('cs.endDate >= :start', { start: startDate })
.getMany();
const scheduleConflicts = scheduleCandidates.filter((schedule) =>
this.hasScheduleOccurrence(schedule, startDate, endDate),
);
if (scheduleConflicts.length > 0) {
throw new ConflictException({
message: '该教室在此时间段已有排课',
conflicts: scheduleConflicts.map((s) => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate,
organizationName: `[内部排课] ${s.subject}`,
})),
});
}
return rentals;
}
private hasScheduleOccurrence(
schedule: ClassSchedule,
startDate: string,
endDate: string,
): boolean {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
if (overlapStart > overlapEnd) return false;
const startUtc = this.toUtcDate(overlapStart);
const endUtc = this.toUtcDate(overlapEnd);
const startWeekDay = startUtc.getUTCDay() || 7;
const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7;
startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence);
return startUtc <= endUtc;
}
private toUtcDate(date: string): Date {
const [year, month, day] = date.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
private addDateRange(dates: Set<string>, startDate: string, endDate: string) {
const current = this.toUtcDate(startDate);
const end = this.toUtcDate(endDate);
while (current <= end) {
dates.add(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 1);
}
}
private addScheduleOccurrences(
dates: Set<string>,
schedule: ClassSchedule,
startDate: string,
endDate: string,
) {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
if (overlapStart > overlapEnd) return;
const current = this.toUtcDate(overlapStart);
const end = this.toUtcDate(overlapEnd);
const startWeekDay = current.getUTCDay() || 7;
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
while (current <= end) {
dates.add(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 7);
}
}
async getSchedule(year: number, month: number) {
const lastDay = new Date(year, month, 0).getDate();
const first = `${year}-${String(month).padStart(2, '0')}-01`;
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const classrooms = await this.classroomRepo.find({
where: { status: Not(ClassroomStatus.ARCHIVED) },
order: { building: 'ASC', name: 'ASC' },
});
const rentals = await this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.leftJoinAndSelect('r.classroom', 'classroom')
.where('r.status IN (:...statuses)', {
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
})
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
.getMany();
const organizationMap = new Map<number, any>();
const matrix: Record<number, Record<number, any>> = {};
const summary: Record<
number,
{ totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }
> = {};
for (const cls of classrooms) {
matrix[cls.id] = {};
summary[cls.id] = { totalDays: lastDay, rentedDays: 0, idleDays: lastDay, occupancyRate: 0 };
}
for (const rental of rentals) {
const start = new Date(rental.startDate);
const end = new Date(rental.endDate);
const monthStart = new Date(first);
const monthEnd = new Date(last);
const effStart = start < monthStart ? monthStart : start;
const effEnd = end > monthEnd ? monthEnd : end;
if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) {
organizationMap.set(rental.lesseeOrganization.id, {
id: rental.lesseeOrganization.id,
name: rental.lesseeOrganization.name,
color:
rental.lesseeOrganization.color ||
COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length],
});
}
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
const day = d.getDate();
if (!matrix[rental.classroomId]) continue;
matrix[rental.classroomId][day] = {
scheduleType: 'RENTAL',
rentalId: rental.id,
organizationId: rental.lesseeOrganizationId,
organizationName: rental.lesseeOrganization?.name || '未知',
color:
rental.lesseeOrganization?.color ||
COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length],
hasContract: !!rental.contractPath,
};
}
}
// ── Overlay internal class schedules ──
const schedules = await this.scheduleRepo
.createQueryBuilder('s')
.leftJoinAndSelect('s.class', 'class')
.leftJoinAndSelect('s.teacher', 'teacher')
.where('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last })
.getMany();
for (const sched of schedules) {
if (!sched.classroomId) continue;
const schedStart = new Date(
Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()),
);
const schedEnd = new Date(
Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()),
);
for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) {
const dow = d.getDay() === 0 ? 7 : d.getDay();
if (dow !== sched.weekDay) continue;
const day = d.getDate();
if (!matrix[sched.classroomId]) continue;
matrix[sched.classroomId][day] = {
scheduleType: 'INTERNAL',
scheduleId: sched.id,
className: (sched.class as { name?: string } | null)?.name || '',
subject: sched.subject,
teacherName: (sched.teacher as { name?: string } | null)?.name || '',
startTime: sched.startTime,
endTime: sched.endTime,
color: '#52c41a',
};
}
}
// 统计
for (const cls of classrooms) {
const rented = Object.keys(matrix[cls.id]).length;
summary[cls.id].rentedDays = rented;
summary[cls.id].idleDays = lastDay - rented;
summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0;
}
return {
year,
month,
days: lastDay,
classrooms: classrooms.map((c) => ({
id: c.id,
name: c.name,
building: c.building,
floor: c.floor,
roomType: c.roomType,
capacity: c.capacity,
})),
organizations: Array.from(organizationMap.values()),
matrix,
summary,
};
}
withEffectiveStatus(rental: ClassroomRental) {
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const effectiveStatus =
rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today
? ClassroomRentalStatus.ENDED
: rental.status;
return Object.assign(rental, { effectiveStatus });
}
/**
* 同步租赁订单到 class_schedulesschedule_type = 'RENTAL'
*/
async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) {
const name = organizationName || rental.lesseeOrganization?.name || '承租机构';
const weekDay = this.dateToWeekDay(rental.startDate);
let schedule = await this.scheduleRepo.findOne({
where: { rentalId: rental.id, scheduleType: 'RENTAL' },
});
const data = {
classroomId: rental.classroomId,
classId: null,
weekDay,
startTime: '00:00',
endTime: '23:59',
startDate: rental.startDate,
endDate: rental.endDate,
subject: `${name} 租赁`,
teacherId: null,
scheduleType: 'RENTAL',
rentalId: rental.id,
status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',
notes: rental.notes,
};
if (schedule) {
await this.scheduleRepo.update(schedule.id, data);
} else {
schedule = this.scheduleRepo.create(data);
await this.scheduleRepo.save(schedule);
}
}
dateToWeekDay(date: string): number {
const d = new Date(date);
const day = d.getDay();
return day === 0 ? 7 : day;
}
}

View File

@@ -19,6 +19,7 @@ import { ClassroomsService } from './classrooms.service';
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { logAudit } from '../common/with-audit-log';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import * as ExcelJS from 'exceljs';
@@ -104,18 +105,9 @@ export class ClassroomsController {
@Post()
@RequirePermission('classroom:create')
async create(@Body() dto: CreateClassroomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '新增教室',
targetId: result.id,
targetType: 'classroom',
detail: dto.name,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室', action: '新增教室', targetId: result.id, targetType: 'classroom', detail: dto.name,
});
return result;
}
@@ -123,18 +115,9 @@ export class ClassroomsController {
@Put(':id')
@RequirePermission('classroom:edit')
async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '编辑教室',
targetId: +id,
targetType: 'classroom',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室', action: '编辑教室', targetId: +id, targetType: 'classroom', detail: JSON.stringify(dto),
});
return result;
}
@@ -142,17 +125,19 @@ export class ClassroomsController {
@Delete(':id')
@RequirePermission('classroom:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '归档教室',
targetId: +id,
targetType: 'classroom',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('classroom:purge')
async purge(@Param('id') id: string, @Request() req: any) {
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '教室', action: '永久删除教室', targetId: +id, targetType: 'classroom', detail: '物理删除,不可恢复',
});
return result;
}
@@ -160,17 +145,9 @@ export class ClassroomsController {
@Put(':id/restore')
@RequirePermission('classroom:edit')
async restore(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '恢复教室',
targetId: +id,
targetType: 'classroom',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom',
});
return result;
}
@@ -181,7 +158,7 @@ export class ClassroomsController {
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const ws = workbook.worksheets[0];
const rows: any[] = [];
ws.eachRow((row, idx) => {

View File

@@ -3,12 +3,16 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Classroom } from '../entities/classroom.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { AttendanceDevice } from '../entities/attendance-device.entity';
import { ClassroomsService } from './classrooms.service';
import { ClassroomsController } from './classrooms.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule]), OperationLogsModule],
imports: [
TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule, AttendanceDevice]),
OperationLogsModule,
],
controllers: [ClassroomsController],
providers: [ClassroomsService],
exports: [ClassroomsService],

View File

@@ -0,0 +1,23 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { ClassroomsController } from './classrooms.controller';
describe('ClassroomsController purge route', () => {
it('requires classroom:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, ClassroomsController.prototype.purge)).toEqual([
'classroom:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除教室(不可恢复)' }) };
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ClassroomsController(service as never, { log } as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '教室', action: '永久删除教室', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,59 @@
import { BadRequestException } from '@nestjs/common';
import { ClassroomsService } from './classrooms.service';
describe('ClassroomsService.purge', () => {
const createService = (overrides?: {
classroom?: Record<string, unknown>;
scheduleCount?: number;
rentalCount?: number;
deviceCount?: number;
}) => {
const classroom = { id: 1, name: '101教室', status: 'archived', ...overrides?.classroom };
const repo = {
findOne: jest.fn().mockResolvedValue(classroom),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const scheduleRepo = { count: jest.fn().mockResolvedValue(overrides?.scheduleCount ?? 0) };
const rentalRepo = { count: jest.fn().mockResolvedValue(overrides?.rentalCount ?? 0) };
const deviceRepo = { count: jest.fn().mockResolvedValue(overrides?.deviceCount ?? 0) };
const service = new ClassroomsService(
repo as never,
rentalRepo as never,
scheduleRepo as never,
deviceRepo as never,
);
return { service, repo, scheduleRepo, rentalRepo, deviceRepo };
};
it('rejects classrooms that are not archived', async () => {
const { service, repo } = createService({ classroom: { status: 'available' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档教室可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects classrooms with schedules, rentals, or devices', async () => {
const withSchedule = createService({ scheduleCount: 1 });
await expect(withSchedule.service.purge(1)).rejects.toThrow(
new BadRequestException('该教室存在排课记录,无法永久删除'),
);
const withRental = createService({ rentalCount: 1 });
await expect(withRental.service.purge(1)).rejects.toThrow(
new BadRequestException('该教室存在租赁订单,无法永久删除'),
);
const withDevice = createService({ deviceCount: 1 });
await expect(withDevice.service.purge(1)).rejects.toThrow(
new BadRequestException('该教室绑定了考勤机,无法永久删除'),
);
expect(withDevice.repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived classroom with no references', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除教室(不可恢复)' });
expect(repo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -4,6 +4,7 @@ import { Repository, Not, MoreThanOrEqual, Like } from 'typeorm';
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { AttendanceDevice } from '../entities/attendance-device.entity';
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
@Injectable()
@@ -12,6 +13,7 @@ export class ClassroomsService {
@InjectRepository(Classroom) private repo: Repository<Classroom>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceDevice) private deviceRepo: Repository<AttendanceDevice>,
) {}
async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) {
@@ -113,6 +115,24 @@ export class ClassroomsService {
return this.repo.findOne({ where: { id } });
}
async purge(id: number) {
const classroom = await this.repo.findOne({ where: { id } });
if (!classroom) throw new NotFoundException('教室不存在');
if (classroom.status !== ClassroomStatus.ARCHIVED) {
throw new BadRequestException('仅已归档教室可以永久删除,请先归档');
}
const [scheduleCount, rentalCount, deviceCount] = await Promise.all([
this.scheduleRepo.count({ where: { classroomId: id } }),
this.rentalRepo.count({ where: { classroomId: id } }),
this.deviceRepo.count({ where: { classroomId: id } }),
]);
if (scheduleCount > 0) throw new BadRequestException('该教室存在排课记录,无法永久删除');
if (rentalCount > 0) throw new BadRequestException('该教室存在租赁订单,无法永久删除');
if (deviceCount > 0) throw new BadRequestException('该教室绑定了考勤机,无法永久删除');
await this.repo.delete(id);
return { message: '已永久删除教室(不可恢复)' };
}
private withEffectiveStatus(
classroom: Classroom,
usage?: {
@@ -214,17 +234,23 @@ export class ClassroomsService {
};
const weekDay = weekDayMap[shanghaiParts];
const schedules = await this.scheduleRepo
const qb = this.scheduleRepo
.createQueryBuilder('s')
.leftJoin('Class', 'c', 'c.id = s.classId')
.select('s.classroomId', 'classroomId')
.addSelect('s.startTime', 'startTime')
.addSelect('s.endTime', 'endTime')
.addSelect('s.startDate', 'startDate')
.addSelect('s.endDate', 'endDate')
.addSelect('s.weekDay', 'weekDay')
.addSelect('s.subject', 'subject')
.addSelect('c.name', 'className')
.select('s.classroomId', 'classroomId');
const scheduleSelects = [
['s.startTime', 'startTime'],
['s.endTime', 'endTime'],
['s.startDate', 'startDate'],
['s.endDate', 'endDate'],
['s.weekDay', 'weekDay'],
['s.subject', 'subject'],
['c.name', 'className'],
] as const;
for (const [column, alias] of scheduleSelects) {
qb.addSelect(column, alias);
}
const schedules = await qb
.where('s.classroomId IN (:...ids)', { ids: classroomIds })
.andWhere('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })

View File

@@ -1,5 +1,13 @@
function makeExpensesService(
a: never, b: never, c: never, d: never, e: never, f: never,
) {
const operations = new ExpenseOperationsService(a, b, c, d, e, f);
return new ExpensesService(a, b, c, d, e, f, operations);
}
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExpensesService } from '../expenses/expenses.service';
import { ExpenseOperationsService } from '../expenses/expense-operations.service';
import { OccupanciesService } from '../occupancies/occupancies.service';
import { RoomsService } from '../rooms/rooms.service';
import { StudentsService } from '../students/students.service';
@@ -41,7 +49,7 @@ describe('batch restore service semantics', () => {
const rooms = new RoomsService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
const expenses = new ExpensesService(
const expenses = makeExpensesService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
const occupancies = new OccupanciesService(
@@ -129,7 +137,7 @@ describe('batch restore service semantics', () => {
createQueryBuilder: jest.fn(() => qb),
};
const billItemsRepo = { count: jest.fn().mockResolvedValue(1) };
const service = new ExpensesService(
const service = makeExpensesService(
roomExpRepo as never,
{} as never,
{} as never,
@@ -150,7 +158,7 @@ describe('batch restore service semantics', () => {
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
const service = makeExpensesService(
roomExpRepo as never, {} as never, {} as never, {} as never, {} as never,
{ getRepository: jest.fn(() => ({ count: jest.fn().mockResolvedValue(0) })) } as never,
);
@@ -168,7 +176,7 @@ describe('batch restore service semantics', () => {
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
const service = makeExpensesService(
roomExpRepo as never, {} as never, {} as never, {} as never, {} as never,
{ getRepository: jest.fn(() => ({ count })) } as never,
);
@@ -185,7 +193,7 @@ describe('batch restore service semantics', () => {
find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived', billId: 9 }]),
createQueryBuilder: jest.fn(),
};
const service = new ExpensesService(
const service = makeExpensesService(
{} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestorePersonalExpenses([1])).rejects.toBeInstanceOf(BadRequestException);
@@ -201,7 +209,7 @@ describe('batch restore service semantics', () => {
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
const service = makeExpensesService(
{} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestorePersonalExpenses([1, 1, 2])).resolves.toMatchObject({
@@ -220,7 +228,7 @@ describe('batch restore service semantics', () => {
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
const service = makeExpensesService(
{} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestorePersonalExpenses([1, 2])).resolves.toMatchObject({
@@ -233,7 +241,7 @@ describe('batch restore service semantics', () => {
it('uses archived status when querying expense archive views', async () => {
const roomQb = listQb();
const personalRepo = { find: jest.fn().mockResolvedValue([]) };
const service = new ExpensesService(
const service = makeExpensesService(
{ createQueryBuilder: jest.fn(() => roomQb) } as never,
personalRepo as never,
{} as never, {} as never, {} as never, {} as never,
@@ -245,7 +253,7 @@ describe('batch restore service semantics', () => {
});
it('rejects invalid expense query status values', async () => {
const service = new ExpensesService(
const service = makeExpensesService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.findRoomExpenses({ status: 'deleted' as never })).rejects.toBeInstanceOf(BadRequestException);

View File

@@ -0,0 +1,241 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Occupancy } from '../entities/occupancy.entity';
import { Bill } from '../entities/bill.entity';
import { RoomExpense } from '../entities/room-expense.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
export function nextMonth(ym: string): string {
const d = new Date(`${ym}-01`);
d.setMonth(d.getMonth() + 1);
return d.toISOString().slice(0, 7) + '-01';
}
export function applyClassScope(
qb: { andWhere: (condition: string, parameters?: Record<string, unknown>) => unknown },
alias: string,
accessibleClassIds?: number[],
) {
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) {
qb.andWhere('1 = 0');
return;
}
qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds });
}
}
@Injectable()
export class DashboardQueriesService {
constructor(
@InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(Bill) private readonly billRepo: Repository<Bill>,
@InjectRepository(Occupancy) private readonly occRepo: Repository<Occupancy>,
@InjectRepository(RoomExpense) private readonly expRepo: Repository<RoomExpense>,
) {}
async getAttendanceTrend(
attendanceRepo: Repository<AttendanceRecord>,
todayStr: string,
accessibleClassIds?: number[],
) {
const thirtyDaysAgo = new Date(todayStr);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29);
const startStr = thirtyDaysAgo.toISOString().slice(0, 10);
const trendQb = attendanceRepo
.createQueryBuilder('a')
.select('a.attendanceDate', 'date')
.addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('a.attendanceDate >= :start', { start: startStr })
.andWhere('a.attendanceDate <= :today', { today: todayStr });
applyClassScope(trendQb, 'a', accessibleClassIds);
const rows = await trendQb
.groupBy('a.attendanceDate')
.addGroupBy('a.status')
.orderBy('a.attendanceDate', 'ASC')
.getRawMany();
const dayMap = new Map<string, { total: number; present: number }>();
for (const row of rows) {
const d = dayMap.get(row.date) || { total: 0, present: 0 };
const cnt = parseInt(row.count, 10);
d.total += cnt;
if (row.status === 'present') d.present += cnt;
dayMap.set(row.date, d);
}
return Array.from(dayMap.entries()).map(([date, d]) => ({
date,
rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0,
}));
}
async getIncomeTrend(
billRepo: Repository<Bill>,
currentMonth: string,
) {
const results: { month: string; amount: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(`${currentMonth}-01`);
d.setMonth(d.getMonth() - i);
const m = d.toISOString().slice(0, 7);
const row = await billRepo
.createQueryBuilder('b')
.select('SUM(b.totalAmount)', 'total')
.where('b.status = :paid', { paid: 'paid' })
.andWhere('b.periodStart >= :start', { start: `${m}-01` })
.andWhere('b.periodStart < :end', { end: nextMonth(m) })
.getRawOne();
results.push({
month: m,
amount: parseFloat(row?.total || '0'),
});
}
return results;
}
// 甘特图数据:每个宿舍的入住时间线
async getGanttData(
occRepo: Repository<Occupancy>,
assertPeriodRange: (start?: string, end?: string) => void,
query?: { periodStart?: string; periodEnd?: string; building?: string },
) {
assertPeriodRange(query?.periodStart, query?.periodEnd);
const qb = occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('room.status != :archived', { archived: 'archived' })
.orderBy('room.roomNumber', 'ASC')
.addOrderBy('o.checkInDate', 'ASC');
if (query?.building) {
qb.andWhere('room.building = :building', { building: query.building });
}
if (query?.periodStart) {
qb.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :ps)', { ps: query.periodStart });
}
if (query?.periodEnd) {
qb.andWhere('o.checkInDate <= :pe', { pe: query.periodEnd });
}
const records = await qb.getMany();
// 按宿舍分组
const roomMap = new Map<string, Record<string, unknown>[]>();
for (const r of records) {
const key = r.room?.roomNumber || String(r.roomId);
if (!roomMap.has(key)) roomMap.set(key, []);
roomMap.get(key)!.push({
studentName: r.student?.name || '未知',
studentId: r.studentId,
checkInDate: r.checkInDate,
checkOutDate: r.checkOutDate,
billingStartDate: r.billingStartDate,
billingEndDate: r.billingEndDate,
});
}
return Array.from(roomMap.entries()).map(([roomNumber, occupancies]) => ({
roomNumber,
occupancies,
}));
}
// 费用统计
async getExpenseStats(
expRepo: Repository<RoomExpense>,
assertPeriodRange: (start?: string, end?: string) => void,
periodStart?: string,
periodEnd?: string,
) {
assertPeriodRange(periodStart, periodEnd);
const qb = expRepo
.createQueryBuilder('e')
.select('e.expenseType', 'type')
.addSelect('SUM(e.amount)', 'total')
.groupBy('e.expenseType');
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany();
}
// 各宿舍费用排行
async getRoomExpenseRanking(
expRepo: Repository<RoomExpense>,
assertPeriodRange: (start?: string, end?: string) => void,
periodStart?: string,
periodEnd?: string,
) {
assertPeriodRange(periodStart, periodEnd);
const qb = expRepo
.createQueryBuilder('e')
.leftJoin('e.room', 'room')
.select('room.roomNumber', 'roomNumber')
.addSelect('SUM(e.amount)', 'total')
.where('room.status != :archived', { archived: 'archived' })
.groupBy('e.roomId')
.orderBy('total', 'DESC')
.limit(20);
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany();
}
// 班级考勤排行
async getClassAttendanceRanking(
attendanceRepo: Repository<AttendanceRecord>,
applyClassScope: (
qb: { andWhere: (condition: string, parameters?: Record<string, unknown>) => unknown },
alias: string,
accessibleClassIds?: number[],
) => void,
accessibleClassIds?: number[],
) {
if (accessibleClassIds?.length === 0) return { top: [], bottom: [] };
const qb = attendanceRepo
.createQueryBuilder('a')
.leftJoin('a.class', 'class')
.select('class.id', 'classId')
.addSelect('class.name', 'className')
.addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count');
applyClassScope(qb, 'a', accessibleClassIds);
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
const raw = await qb.getRawMany();
const classMap = new Map<number, { className: string; present: number; total: number }>();
for (const r of raw) {
if (!r.classId) continue;
if (!classMap.has(Number(r.classId)))
classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 });
const entry = classMap.get(Number(r.classId))!;
const n = parseInt(r.count, 10);
entry.total += n;
if (r.status === 'present') entry.present += n;
}
const ranked = Array.from(classMap.values())
.map((e) => ({
...e,
rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0,
}))
.sort((a, b) => b.rate - a.rate);
return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() };
}
}

View File

@@ -14,6 +14,7 @@ import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { DashboardService } from './dashboard.service';
import { DashboardQueriesService } from './dashboard-queries.service';
import { DashboardController } from './dashboard.controller';
@Module({
@@ -35,7 +36,7 @@ import { DashboardController } from './dashboard.controller';
]),
],
controllers: [DashboardController],
providers: [DashboardService],
providers: [DashboardService, DashboardQueriesService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -1,4 +1,8 @@
import { DashboardService } from './dashboard.service';
import { DashboardQueriesService } from './dashboard-queries.service';
const queriesService = (attendanceRepo?: unknown) =>
new DashboardQueriesService(attendanceRepo as never, {} as never, {} as never, {} as never);
const createQb = () => ({
leftJoin: jest.fn().mockReturnThis(),
@@ -32,7 +36,7 @@ describe('DashboardService — teacher class scope', () => {
{} as never,
{} as never,
{} as never,
{},
queriesService(attendanceRepo),
);
await service.getClassAttendanceRanking([8, 9]);
@@ -51,6 +55,7 @@ describe('DashboardService — boundary conditions', () => {
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, attendanceRepo as never, {} as never, {} as never, {} as never,
{} as never, {} as never,
queriesService(attendanceRepo),
);
await (service as unknown as {
@@ -69,6 +74,7 @@ describe('DashboardService — boundary conditions', () => {
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never,
queriesService(),
);
await expect((service[method] as (...values: never[]) => Promise<unknown>)(...(args as never[])))
.rejects.toThrow('结束日期不能早于开始日期');
@@ -79,6 +85,7 @@ describe('DashboardService — boundary conditions', () => {
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never,
queriesService(),
);
expect((service as unknown as { getChinaDate: (date: Date) => string })
.getChinaDate(new Date('2026-07-13T16:30:00.000Z'))).toBe('2026-07-14');

View File

@@ -14,6 +14,7 @@ import { Deposit } from '../entities/deposit.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { DashboardQueriesService } from './dashboard-queries.service';
interface AgentAttendanceStatusRow {
status: string;
@@ -36,6 +37,7 @@ export class DashboardService {
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
private readonly queries: DashboardQueriesService,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
@@ -50,24 +52,39 @@ export class DashboardService {
const totalStudents = accessibleClassIds
? await this.countStudentsInClasses(accessibleClassIds)
: await this.studentRepo.count({ where: { status: 'active' } });
const classCount = accessibleClassIds ? accessibleClassIds.length : await this.classRepo.count({ where: { isArchived: false } });
const classCount = accessibleClassIds
? accessibleClassIds.length
: await this.classRepo.count({ where: { isArchived: false } });
const attendanceQb = this.attendanceRepo
.createQueryBuilder('attendance')
.select('attendance.status', 'status')
.addSelect('COUNT(attendance.id)', 'count')
.where('attendance.attendanceDate = :today', { today });
this.applyClassScope(attendanceQb, 'attendance', accessibleClassIds);
const rows = await attendanceQb.groupBy('attendance.status').getRawMany<AgentAttendanceStatusRow>();
const attendanceByStatus = rows.reduce((result, row) => {
result[String(row.status)] = Number(row.count || 0);
return result;
}, {} as Record<string, number>);
const rows = await attendanceQb
.groupBy('attendance.status')
.getRawMany<AgentAttendanceStatusRow>();
const attendanceByStatus = rows.reduce(
(result, row) => {
result[String(row.status)] = Number(row.count || 0);
return result;
},
{} as Record<string, number>,
);
const attendanceTotal = Object.values(attendanceByStatus).reduce<number>(
(sum, count) => sum + Number(count),
0,
);
const present = attendanceByStatus.present ?? 0;
return { date: today, totalStudents, classCount, attendanceTotal, present, attendanceRate: attendanceTotal ? Number(((present / attendanceTotal) * 100).toFixed(1)) : 0, attendanceByStatus };
return {
date: today,
totalStudents,
classCount,
attendanceTotal,
present,
attendanceRate: attendanceTotal ? Number(((present / attendanceTotal) * 100).toFixed(1)) : 0,
attendanceByStatus,
};
}
async getStats(accessibleClassIds?: number[]) {
@@ -117,12 +134,9 @@ export class DashboardService {
this.applyClassScope(attTodayQb, 'a', accessibleClassIds);
attTodayQb.groupBy('a.status');
const attTodayStats = await attTodayQb.getRawMany();
const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const todayPresent = attTodayStats
.filter((r) => r.status === 'present')
.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const todayAttendanceRate = todayTotal > 0 ? ((todayPresent / todayTotal) * 100).toFixed(1) : 0;
const incomeQb = this.billRepo
.createQueryBuilder('b')
.select('SUM(b.totalAmount)', 'total')
@@ -135,7 +149,6 @@ export class DashboardService {
const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds);
const incomeTrend = await this.getIncomeTrend(currentMonth);
// --- New stats ---
const classCount = accessibleClassIds
? accessibleClassIds.length
: await this.classRepo.count({ where: {} });
@@ -226,64 +239,32 @@ export class DashboardService {
return new Set(classStudents.map((item) => item.studentId)).size;
}
private async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) {
const thirtyDaysAgo = new Date(todayStr);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29);
const startStr = thirtyDaysAgo.toISOString().slice(0, 10);
const trendQb = this.attendanceRepo
.createQueryBuilder('a')
.select('a.attendanceDate', 'date')
.addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('a.attendanceDate >= :start', { start: startStr })
.andWhere('a.attendanceDate <= :today', { today: todayStr });
this.applyClassScope(trendQb, 'a', accessibleClassIds);
const rows = await trendQb
.groupBy('a.attendanceDate')
.addGroupBy('a.status')
.orderBy('a.attendanceDate', 'ASC')
.getRawMany();
const dayMap = new Map<string, { total: number; present: number }>();
for (const row of rows) {
const d = dayMap.get(row.date) || { total: 0, present: 0 };
const cnt = parseInt(row.count, 10);
d.total += cnt;
if (row.status === 'present') d.present += cnt;
dayMap.set(row.date, d);
}
return Array.from(dayMap.entries()).map(([date, d]) => ({
date,
rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0,
}));
async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) {
return this.queries.getAttendanceTrend(this.attendanceRepo, todayStr, accessibleClassIds);
}
private async getIncomeTrend(currentMonth: string) {
const results: { month: string; amount: number }[] = [];
async getIncomeTrend(currentMonth: string) {
return this.queries.getIncomeTrend(this.billRepo, currentMonth);
}
for (let i = 5; i >= 0; i--) {
const d = new Date(`${currentMonth}-01`);
d.setMonth(d.getMonth() - i);
const m = d.toISOString().slice(0, 7);
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
return this.queries.getGanttData(this.occRepo, (a, b) => this.assertPeriodRange(a, b), query);
}
const row = await this.billRepo
.createQueryBuilder('b')
.select('SUM(b.totalAmount)', 'total')
.where('b.status = :paid', { paid: 'paid' })
.andWhere('b.periodStart >= :start', { start: `${m}-01` })
.andWhere('b.periodStart < :end', { end: this.nextMonth(m) })
.getRawOne();
async getExpenseStats(periodStart?: string, periodEnd?: string) {
return this.queries.getExpenseStats(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd);
}
results.push({
month: m,
amount: parseFloat(row?.total || '0'),
});
}
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
return this.queries.getRoomExpenseRanking(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd);
}
return results;
async getClassAttendanceRanking(accessibleClassIds?: number[]) {
return this.queries.getClassAttendanceRanking(
this.attendanceRepo,
(qb, alias, ids) => this.applyClassScope(qb, alias, ids),
accessibleClassIds,
);
}
private nextMonth(ym: string): string {
@@ -292,114 +273,6 @@ export class DashboardService {
return d.toISOString().slice(0, 7) + '-01';
}
// 甘特图数据:每个宿舍的入住时间线
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
this.assertPeriodRange(query?.periodStart, query?.periodEnd);
const qb = this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('room.status != :archived', { archived: 'archived' })
.orderBy('room.roomNumber', 'ASC')
.addOrderBy('o.checkInDate', 'ASC');
if (query?.building) {
qb.andWhere('room.building = :building', { building: query.building });
}
if (query?.periodStart) {
qb.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :ps)', { ps: query.periodStart });
}
if (query?.periodEnd) {
qb.andWhere('o.checkInDate <= :pe', { pe: query.periodEnd });
}
const records = await qb.getMany();
// 按宿舍分组
const roomMap = new Map<string, Record<string, unknown>[]>();
for (const r of records) {
const key = r.room?.roomNumber || String(r.roomId);
if (!roomMap.has(key)) roomMap.set(key, []);
roomMap.get(key)!.push({
studentName: r.student?.name || '未知',
studentId: r.studentId,
checkInDate: r.checkInDate,
checkOutDate: r.checkOutDate,
billingStartDate: r.billingStartDate,
billingEndDate: r.billingEndDate,
});
}
return Array.from(roomMap.entries()).map(([roomNumber, occupancies]) => ({
roomNumber,
occupancies,
}));
}
// 费用统计
async getExpenseStats(periodStart?: string, periodEnd?: string) {
this.assertPeriodRange(periodStart, periodEnd);
const qb = this.expRepo
.createQueryBuilder('e')
.select('e.expenseType', 'type')
.addSelect('SUM(e.amount)', 'total')
.groupBy('e.expenseType');
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany();
}
// 各宿舍费用排行
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
this.assertPeriodRange(periodStart, periodEnd);
const qb = this.expRepo
.createQueryBuilder('e')
.leftJoin('e.room', 'room')
.select('room.roomNumber', 'roomNumber')
.addSelect('SUM(e.amount)', 'total')
.where('room.status != :archived', { archived: 'archived' })
.groupBy('e.roomId')
.orderBy('total', 'DESC')
.limit(20);
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany();
}
// 班级考勤排行
async getClassAttendanceRanking(accessibleClassIds?: number[]) {
if (accessibleClassIds?.length === 0) return { top: [], bottom: [] };
const qb = this.attendanceRepo
.createQueryBuilder('a')
.leftJoin('a.class', 'class')
.select('class.id', 'classId')
.addSelect('class.name', 'className')
.addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count');
this.applyClassScope(qb, 'a', accessibleClassIds);
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
const raw = await qb.getRawMany();
const classMap = new Map<number, { className: string; present: number; total: number }>();
for (const r of raw) {
if (!r.classId) continue;
if (!classMap.has(Number(r.classId)))
classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 });
const entry = classMap.get(Number(r.classId))!;
const n = parseInt(r.count, 10);
entry.total += n;
if (r.status === 'present') entry.present += n;
}
const ranked = Array.from(classMap.values())
.map((e) => ({
...e,
rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0,
}))
.sort((a, b) => b.rate - a.rate);
return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() };
}
async getClassroomOccupancy() {
const classrooms = await this.classroomRepo.find({
where: { status: 'available' as const },

View File

@@ -25,7 +25,7 @@ import {
} from './dto/deposit.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@@ -78,31 +78,11 @@ export class DepositsController {
@Post()
@RequirePermission('deposit:create')
async create(@Body() dto: CreateDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '收取押金',
targetId: result.id,
targetType: 'deposit',
detail: `学生${dto.studentId} ¥${dto.amount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`,
});
// Send deposit_due notification
try {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: 'deposit_due',
title: '押金待缴',
content: `您有一笔押金待缴纳,金额: ¥${dto.amount}`,
});
}
} catch (_) { /* don't block response */ }
await this.notifyDeposit(dto.studentId, 'deposit_due', '押金待缴', `您有一笔押金待缴纳,金额: ¥${dto.amount}`);
return result;
}
@@ -110,17 +90,9 @@ export class DepositsController {
@Post('batch')
@RequirePermission('deposit:create')
async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchCreate(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '批量收取押金',
targetType: 'deposit',
detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '批量收取押金', targetType: 'deposit', detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
});
return result;
}
@@ -132,18 +104,9 @@ export class DepositsController {
@Body() body: CreateDepositInstallmentDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addInstallment(id, body.amount, body.dueDate);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '新增分期',
targetId: result.id,
targetType: 'deposit-installment',
detail: `押金${id} 新增分期 ¥${result.amount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '新增分期', targetId: result.id, targetType: 'deposit-installment', detail: `押金${id} 新增分期 ¥${result.amount}`,
});
return result;
}
@@ -155,18 +118,9 @@ export class DepositsController {
@Body() body: UpdateDepositInstallmentDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateInstallment(installmentId, body);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '更新分期',
targetId: installmentId,
targetType: 'deposit-installment',
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '更新分期', targetId: installmentId, targetType: 'deposit-installment', detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
});
return result;
}
@@ -177,18 +131,9 @@ export class DepositsController {
@Param('installmentId', ParseIntPipe) installmentId: number,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deleteInstallment(installmentId);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '归档分期',
targetId: installmentId,
targetType: 'deposit-installment',
detail: `归档分期${installmentId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '归档分期', targetId: installmentId, targetType: 'deposit-installment', detail: `归档分期${installmentId}`,
});
return result;
}
@@ -196,48 +141,46 @@ export class DepositsController {
@Put(':id/refund')
@RequirePermission('deposit:refund')
async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.refund(id, dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '退还押金',
targetId: id,
targetType: 'deposit',
detail: `退还全部可用押金 ¥${result.refundAmount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '退还押金', targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`,
});
// Send deposit_refunded notification
try {
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: 'deposit_refunded',
title: '押金已退还',
content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`,
});
}
} catch (_) { /* don't block response */ }
await this.notifyDeposit(result.studentId, 'deposit_refunded', '押金已退还', `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`);
return result;
}
private async notifyDeposit(
studentId: number,
type: 'deposit_due' | 'deposit_refunded',
title: string,
content: string,
): Promise<void> {
try {
const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (student?.userId) {
void this.notificationsService.create({ recipientIds: [student.userId], type, title, content });
}
} catch {
// 通知失败不影响主流程
}
}
@Delete(':id')
@RequirePermission('deposit:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '归档押金记录',
targetId: id,
targetType: 'deposit',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '归档押金记录', targetId: id, targetType: 'deposit',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('deposit:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purge(id);
await logAudit(this.logService, req, {
module: '押金管理', action: '永久删除押金', targetId: id, targetType: 'deposit', detail: '物理删除,不可恢复',
});
return result;
}

View File

@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { DepositsController } from './deposits.controller';
describe('DepositsController purge route', () => {
it('requires deposit:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, DepositsController.prototype.purge)).toEqual([
'deposit:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除押金(不可恢复)' }) };
const log = jest.fn().mockResolvedValue(undefined);
const controller = new DepositsController(
service as never,
{ log } as never,
{} as never,
{} as never,
);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge(1, req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '押金管理', action: '永久删除押金', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,65 @@
import { BadRequestException } from '@nestjs/common';
import { DepositsService } from './deposits.service';
describe('DepositsService.purge', () => {
const createService = (overrides?: { deposit?: Record<string, unknown> }) => {
const deposit = {
id: 1,
studentId: 2,
amount: 500,
status: 'archived',
refundAmount: null,
deductionAmount: 0,
...overrides?.deposit,
};
const repo = {
findOne: jest.fn().mockResolvedValue(deposit),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const installmentRepo = { count: jest.fn().mockResolvedValue(0) };
const service = new DepositsService(
repo as never,
installmentRepo as never,
{} as never,
);
return { service, repo, installmentRepo };
};
it('rejects deposits that are not archived', async () => {
const { service, repo } = createService({ deposit: { status: 'paid' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档押金可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects deposits with refund or deduction amounts', async () => {
const withRefund = createService({ deposit: { refundAmount: 100 } });
await expect(withRefund.service.purge(1)).rejects.toThrow(
new BadRequestException('该押金已有退款金额,无法永久删除'),
);
const withDeduction = createService({ deposit: { deductionAmount: 50 } });
await expect(withDeduction.service.purge(1)).rejects.toThrow(
new BadRequestException('该押金已有抵扣金额,无法永久删除'),
);
expect(withDeduction.repo.delete).not.toHaveBeenCalled();
});
it('rejects deposits with paid installments', async () => {
const { service, installmentRepo, repo } = createService();
installmentRepo.count.mockResolvedValue(1);
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('该押金存在已支付分期,无法永久删除'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived deposit with no paid history', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除押金(不可恢复)',
});
expect(repo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -67,16 +67,21 @@ export class DepositsService {
.leftJoin(Deposit, 'deposit', 'deposit.student_id = student.id AND deposit.status != :archived', {
archived: 'archived',
})
.select('student.id', 'studentId')
.addSelect('student.name', 'studentName')
.addSelect('student.studentNo', 'studentNo')
.addSelect('room.id', 'roomId')
.addSelect('room.roomNumber', 'roomNumber')
.addSelect('room.building', 'building')
.addSelect('room.roomType', 'roomType')
.addSelect('room.capacity', 'capacity')
.addSelect('deposit.amount', 'depositAmount')
.where('o.status = :activeStatus', { activeStatus: 'active' })
.select('student.id', 'studentId');
const eligibleSelects = [
['student.name', 'studentName'],
['student.studentNo', 'studentNo'],
['room.id', 'roomId'],
['room.roomNumber', 'roomNumber'],
['room.building', 'building'],
['room.roomType', 'roomType'],
['room.capacity', 'capacity'],
['deposit.amount', 'depositAmount'],
] as const;
for (const [column, alias] of eligibleSelects) {
qb.addSelect(column, alias);
}
qb.where('o.status = :activeStatus', { activeStatus: 'active' })
.andWhere('o.checkOutDate IS NULL')
.andWhere('student.status = :studentStatus', { studentStatus: 'active' })
.orderBy('room.building', 'ASC')
@@ -167,15 +172,20 @@ export class DepositsService {
const qb = this.repo
.createQueryBuilder('d')
.leftJoin('d.student', 'student')
.select('d.id', 'id')
.addSelect('student.name', 'studentName')
.addSelect('student.studentNo', 'studentNo')
.addSelect('d.amount', 'amount')
.addSelect('d.status', 'status')
.addSelect('d.paidDate', 'paidDate')
.addSelect('d.refundAmount', 'refundAmount')
.addSelect('d.refundDate', 'refundDate')
.where('d.status != :archived', { archived: 'archived' });
.select('d.id', 'id');
const depositSelects = [
['student.name', 'studentName'],
['student.studentNo', 'studentNo'],
['d.amount', 'amount'],
['d.status', 'status'],
['d.paidDate', 'paidDate'],
['d.refundAmount', 'refundAmount'],
['d.refundDate', 'refundDate'],
] as const;
for (const [column, alias] of depositSelects) {
qb.addSelect(column, alias);
}
qb.where('d.status != :archived', { archived: 'archived' });
if (query?.keyword) {
qb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
@@ -226,8 +236,8 @@ export class DepositsService {
existing.paidDate = dto.paidDate;
existing.status = 'paid';
existing.recordedBy = userId ?? null;
existing.refundDate = null as unknown as string;
existing.refundAmount = null as unknown as number;
existing.refundDate = null;
existing.refundAmount = null;
existing.refundedBy = null;
existing.refundedAt = null;
if (dto.notes) existing.notes = dto.notes;
@@ -309,6 +319,28 @@ export class DepositsService {
return { message: '已归档' };
}
async purge(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (deposit.status !== 'archived') {
throw new BadRequestException('仅已归档押金可以永久删除,请先归档');
}
if (Number(deposit.refundAmount || 0) > 0) {
throw new BadRequestException('该押金已有退款金额,无法永久删除');
}
if (Number(deposit.deductionAmount || 0) > 0) {
throw new BadRequestException('该押金已有抵扣金额,无法永久删除');
}
const paidInstallments = await this.installmentRepo.count({
where: { depositId: id, status: 'paid' },
});
if (paidInstallments > 0) {
throw new BadRequestException('该押金存在已支付分期,无法永久删除');
}
await this.repo.delete(id);
return { message: '已永久删除押金(不可恢复)' };
}
async getStats() {
const qb = this.repo
.createQueryBuilder('d')

View File

@@ -8,6 +8,8 @@ import {
JoinColumn,
Check,
} from 'typeorm';
import type { Class } from './class.entity';
import type { User } from './user.entity';
export enum ScheduleType {
INTERNAL = 'INTERNAL',
@@ -26,7 +28,7 @@ export class ClassSchedule {
// Forward reference — Class entity
@ManyToOne('Class', { nullable: true })
@JoinColumn({ name: 'class_id' })
class: unknown;
class: Class | null;
@Column({ name: 'classroom_id', type: 'integer' })
classroomId: number;
@@ -64,7 +66,7 @@ export class ClassSchedule {
// Forward reference — User entity
@ManyToOne('User', { nullable: true })
@JoinColumn({ name: 'teacher_id' })
teacher: unknown;
teacher: User | null;
@Column({ name: 'schedule_type', length: 20, default: 'INTERNAL' })
scheduleType: string;

View File

@@ -51,11 +51,11 @@ export class ClassroomRental {
endDate: string;
// 合同 PDF 相对路径(相对 UPLOAD_DIR仅存文件名
@Column({ name: 'contract_path', length: 255, nullable: true })
contractPath: string;
@Column({ name: 'contract_path', type: 'varchar', length: 255, nullable: true })
contractPath: string | null;
@Column({ name: 'contract_original_name', length: 255, nullable: true })
contractOriginalName: string;
@Column({ name: 'contract_original_name', type: 'varchar', length: 255, nullable: true })
contractOriginalName: string | null;
@Column({ name: 'daily_rate', type: 'decimal', precision: 10, scale: 2, nullable: true })
dailyRate: number;

View File

@@ -29,10 +29,10 @@ export class Deposit {
paidDate: string;
@Column({ name: 'refund_date', type: 'date', nullable: true })
refundDate: string;
refundDate: string | null;
@Column({ name: 'refund_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })
refundAmount: number;
refundAmount: number | null;
@Column({ name: 'deduction_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
deductionAmount: number;

View File

@@ -53,3 +53,6 @@ export {
AiForm,
AiReview,
} from '../ai-chat/entities';
export { ImportRun } from '../imports/entities/import-run.entity';
export { ImportStep } from '../imports/entities/import-step.entity';
export { ImportRow } from '../imports/entities/import-row.entity';

View File

@@ -1,12 +1,4 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm';
import { Occupancy } from './occupancy.entity';
import { RoomExpense } from './room-expense.entity';

View File

@@ -60,4 +60,24 @@ describe('ExamsController batch archive and restore', () => {
['批量恢复考试', 'IDs: 3,4'],
]);
});
it('requires exam:purge and writes permanent delete logs', async () => {
expect(Reflect.getMetadata(PERMISSION_KEY, ExamsController.prototype.purge)).toEqual([
'exam:purge',
]);
expect(
Reflect.getMetadata(PERMISSION_KEY, ExamsController.prototype.batchPurge),
).toEqual(['exam:purge']);
const service = {
purge: jest.fn().mockResolvedValue({ message: '已永久删除考试(不可恢复)' }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ExamsController(service as never, { log } as never);
await controller.purge(1, req);
expect(service.purge).toHaveBeenCalledWith(1, 7, true);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '考试管理', action: '永久删除考试', targetId: 1 }),
);
});
});

Some files were not shown because too many files have changed in this diff Show More