feat: 重构各业务模块管理页面与服务
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
58
apps/admin/src/components/JinshujuMatchModal.types.ts
Normal file
58
apps/admin/src/components/JinshujuMatchModal.types.ts
Normal 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: '学号' },
|
||||
];
|
||||
124
apps/admin/src/components/MatchSelector.tsx
Normal file
124
apps/admin/src/components/MatchSelector.tsx
Normal 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;
|
||||
156
apps/admin/src/components/MatchStep.tsx
Normal file
156
apps/admin/src/components/MatchStep.tsx
Normal 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;
|
||||
123
apps/admin/src/components/RuleEditor.tsx
Normal file
123
apps/admin/src/components/RuleEditor.tsx
Normal 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;
|
||||
@@ -27,13 +27,21 @@ const statusLabels: Record<string, string> = {
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
const escapeHtml = (value: unknown) =>
|
||||
String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
const HTML_ESCAPE_PAIRS: ReadonlyArray<readonly [string, string]> = [
|
||||
['&', '&'],
|
||||
['<', '<'],
|
||||
['>', '>'],
|
||||
['"', '"'],
|
||||
["'", '''],
|
||||
];
|
||||
|
||||
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)}`;
|
||||
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
557
apps/admin/src/pages/Classes/ClassDetailTabs.tsx
Normal file
557
apps/admin/src/pages/Classes/ClassDetailTabs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
340
apps/admin/src/pages/ClassroomRentals/RentalTable.tsx
Normal file
340
apps/admin/src/pages/ClassroomRentals/RentalTable.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 ? '编辑租赁' : '新增租赁'}
|
||||
|
||||
@@ -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, '加载详情失败'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
128
apps/admin/src/pages/Dashboard/Dashboard.types.ts
Normal file
128
apps/admin/src/pages/Dashboard/Dashboard.types.ts
Normal 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',
|
||||
};
|
||||
262
apps/admin/src/pages/Dashboard/DashboardCharts.ts
Normal file
262
apps/admin/src/pages/Dashboard/DashboardCharts.ts
Normal 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],
|
||||
})),
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
85
apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx
Normal file
85
apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
124
apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx
Normal file
124
apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
423
apps/admin/src/pages/Deposits/DepositModals.tsx
Normal file
423
apps/admin/src/pages/Deposits/DepositModals.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
148
apps/admin/src/pages/Deposits/DepositTable.tsx
Normal file
148
apps/admin/src/pages/Deposits/DepositTable.tsx
Normal 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="暂无数据" /> }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
166
apps/admin/src/pages/Expenses/ExpenseModals.tsx
Normal file
166
apps/admin/src/pages/Expenses/ExpenseModals.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
524
apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx
Normal file
524
apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '操作失败');
|
||||
|
||||
142
apps/admin/src/pages/Occupancies/OccupanciesTableArea.tsx
Normal file
142
apps/admin/src/pages/Occupancies/OccupanciesTableArea.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
146
apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx
Normal file
146
apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
133
apps/admin/src/pages/Occupancies/OccupancyColumns.tsx
Normal file
133
apps/admin/src/pages/Occupancies/OccupancyColumns.tsx
Normal 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)];
|
||||
};
|
||||
551
apps/admin/src/pages/Occupancies/OccupancyModals.tsx
Normal file
551
apps/admin/src/pages/Occupancies/OccupancyModals.tsx
Normal 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
65
apps/admin/src/pages/Occupancies/useOccupancyMutations.ts
Normal file
65
apps/admin/src/pages/Occupancies/useOccupancyMutations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 统一处理
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
323
apps/admin/src/pages/Rooms/RoomColumns.tsx
Normal file
323
apps/admin/src/pages/Rooms/RoomColumns.tsx
Normal 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]);
|
||||
}
|
||||
382
apps/admin/src/pages/Rooms/RoomDrawer.tsx
Normal file
382
apps/admin/src/pages/Rooms/RoomDrawer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
244
apps/admin/src/pages/Rooms/RoomModals.tsx
Normal file
244
apps/admin/src/pages/Rooms/RoomModals.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
35
apps/admin/src/pages/Rooms/RoomsTable.tsx
Normal file
35
apps/admin/src/pages/Rooms/RoomsTable.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
198
apps/admin/src/pages/Rooms/RoomsToolbar.tsx
Normal file
198
apps/admin/src/pages/Rooms/RoomsToolbar.tsx
Normal 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
163
apps/admin/src/pages/Rooms/useRoomMutations.ts
Normal file
163
apps/admin/src/pages/Rooms/useRoomMutations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
330
apps/admin/src/pages/Schedules/ScheduleGrids.tsx
Normal file
330
apps/admin/src/pages/Schedules/ScheduleGrids.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
561
apps/admin/src/pages/Schedules/ScheduleModals.tsx
Normal file
561
apps/admin/src/pages/Schedules/ScheduleModals.tsx
Normal 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
388
apps/admin/src/pages/Students/StudentColumns.tsx
Normal file
388
apps/admin/src/pages/Students/StudentColumns.tsx
Normal 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),
|
||||
];
|
||||
}
|
||||
179
apps/admin/src/pages/Students/StudentModals.tsx
Normal file
179
apps/admin/src/pages/Students/StudentModals.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
138
apps/admin/src/pages/Students/StudentsTable.tsx
Normal file
138
apps/admin/src/pages/Students/StudentsTable.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
272
apps/admin/src/pages/Students/StudentsToolbar.tsx
Normal file
272
apps/admin/src/pages/Students/StudentsToolbar.tsx
Normal 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
@@ -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})`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: '关联账单',
|
||||
|
||||
@@ -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);
|
||||
|
||||
285
apps/server/src/bills/bills-generation.service.ts
Normal file
285
apps/server/src/bills/bills-generation.service.ts
Normal 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())}`,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
33
apps/server/src/bills/bills.purge.controller.spec.ts
Normal file
33
apps/server/src/bills/bills.purge.controller.spec.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
71
apps/server/src/bills/bills.purge.spec.ts
Normal file
71
apps/server/src/bills/bills.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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('账单状态必须与实付及未付金额一致');
|
||||
}
|
||||
}
|
||||
|
||||
172
apps/server/src/classes/classes-queries.service.ts
Normal file
172
apps/server/src/classes/classes-queries.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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, [
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
54
apps/server/src/classes/classes.purge.spec.ts
Normal file
54
apps/server/src/classes/classes.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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_schedules(schedule_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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
341
apps/server/src/classroom-rentals/rental-schedule.service.ts
Normal file
341
apps/server/src/classroom-rentals/rental-schedule.service.ts
Normal 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_schedules(schedule_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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
59
apps/server/src/classrooms/classrooms.purge.spec.ts
Normal file
59
apps/server/src/classrooms/classrooms.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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' })
|
||||
|
||||
@@ -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);
|
||||
|
||||
241
apps/server/src/dashboard/dashboard-queries.service.ts
Normal file
241
apps/server/src/dashboard/dashboard-queries.service.ts
Normal 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() };
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
28
apps/server/src/deposits/deposits.purge.controller.spec.ts
Normal file
28
apps/server/src/deposits/deposits.purge.controller.spec.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
apps/server/src/deposits/deposits.purge.spec.ts
Normal file
65
apps/server/src/deposits/deposits.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user