Compare commits
7 Commits
b5ef91bf2d
...
45d2ab7bc4
| Author | SHA1 | Date | |
|---|---|---|---|
| 45d2ab7bc4 | |||
| ebce2463f0 | |||
| 910a4263f6 | |||
| 6d42241918 | |||
| 70283d9dfe | |||
| 187e4f2af0 | |||
| 619b3b22bd |
@@ -16,7 +16,10 @@ instance.interceptors.request.use((config) => {
|
||||
instance.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
const isLoginRequest =
|
||||
err.config?.url === '/auth/login' || err.config?.url === 'auth/login';
|
||||
|
||||
if (err.response?.status === 401 && !isLoginRequest) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('permissions');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Badge, Popover, Button, List, Typography, Empty } from 'antd';
|
||||
import { BellOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
||||
|
||||
interface NotificationItem {
|
||||
id: number;
|
||||
@@ -14,18 +15,6 @@ interface NotificationItem {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
bill_generated: '账单',
|
||||
bill_paid: '账单',
|
||||
check_in: '入住',
|
||||
check_out: '退宿',
|
||||
deposit_due: '押金',
|
||||
deposit_refunded: '押金',
|
||||
class_change: '班级',
|
||||
schedule_conflict: '排课',
|
||||
announcement: '公告',
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
@@ -163,7 +152,7 @@ const NotificationBell: React.FC = () => {
|
||||
strong={!item.isRead}
|
||||
style={{ fontSize: 14 }}
|
||||
>
|
||||
[{typeLabels[item.type] || item.type}] {item.title}
|
||||
[{notificationTypeLabels[item.type] || item.type}] {formatNotificationText(item.title)}
|
||||
</Typography.Text>
|
||||
}
|
||||
description={
|
||||
|
||||
@@ -321,7 +321,11 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 15 }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50],
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加报读记录"
|
||||
@@ -428,7 +432,11 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 15 }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50],
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加考试成绩"
|
||||
@@ -529,7 +537,11 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({ data, st
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 15 }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50],
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加学情记录"
|
||||
@@ -712,7 +724,11 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 15 }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50],
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -399,7 +399,12 @@ const BillsPage: React.FC = () => {
|
||||
dataSource={filteredBills}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRows,
|
||||
|
||||
@@ -527,7 +527,11 @@ const ClassDetailPage: React.FC = () => {
|
||||
columns={studentColumns}
|
||||
dataSource={students}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 20 }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加学员"
|
||||
@@ -571,7 +575,11 @@ const ClassDetailPage: React.FC = () => {
|
||||
columns={teacherColumns}
|
||||
dataSource={teachers}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 20 }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加教师"
|
||||
@@ -630,7 +638,11 @@ const ClassDetailPage: React.FC = () => {
|
||||
columns={scheduleColumns}
|
||||
dataSource={schedules}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 20 }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -264,7 +264,11 @@ const ClassesPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
pagination={{ pageSize: 20 }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -436,7 +436,12 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
<Modal
|
||||
|
||||
@@ -287,7 +287,12 @@ const ClassroomsPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? '编辑教室' : '添加教室'}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
@@ -91,10 +90,9 @@ const DepositsPage: React.FC = () => {
|
||||
const studentOptions = useMemo(
|
||||
() =>
|
||||
students
|
||||
.filter((s: any) => s.status === 'active')
|
||||
.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`,
|
||||
label: s.studentNo ? `${s.name} (${s.studentNo})` : s.name,
|
||||
})),
|
||||
[students],
|
||||
);
|
||||
@@ -320,7 +318,12 @@ const DepositsPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const isFormValidationError = (error: unknown) =>
|
||||
typeof error === 'object'
|
||||
&& error !== null
|
||||
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
||||
|
||||
|
||||
|
||||
const ExpensesPage: React.FC = () => {
|
||||
@@ -157,16 +162,16 @@ const ExpensesPage: React.FC = () => {
|
||||
|
||||
const handleRoomExpense = async () => {
|
||||
setSaving(true);
|
||||
const values = await roomForm.validateFields();
|
||||
const payload = {
|
||||
roomId: values.roomId,
|
||||
expenseType: values.expenseType,
|
||||
amount: values.amount,
|
||||
periodStart: values.period[0].format('YYYY-MM-DD'),
|
||||
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
};
|
||||
try {
|
||||
const values = await roomForm.validateFields();
|
||||
const payload = {
|
||||
roomId: values.roomId,
|
||||
expenseType: values.expenseType,
|
||||
amount: values.amount,
|
||||
periodStart: values.period[0].format('YYYY-MM-DD'),
|
||||
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
};
|
||||
if (editingRoom) {
|
||||
await api.put(`/expenses/room/${editingRoom.id}`, payload);
|
||||
message.success('更新成功');
|
||||
@@ -179,7 +184,9 @@ const ExpensesPage: React.FC = () => {
|
||||
roomForm.resetFields();
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
if (!isFormValidationError(e)) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -187,16 +194,16 @@ const ExpensesPage: React.FC = () => {
|
||||
|
||||
const handlePersonalExpense = async () => {
|
||||
setSaving(true);
|
||||
const values = await personalForm.validateFields();
|
||||
const payload = {
|
||||
studentId: values.studentId,
|
||||
roomId: values.roomId,
|
||||
expenseType: values.expenseType,
|
||||
amount: values.amount,
|
||||
expenseDate: values.expenseDate.format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
};
|
||||
try {
|
||||
const values = await personalForm.validateFields();
|
||||
const payload = {
|
||||
studentId: values.studentId,
|
||||
roomId: values.roomId,
|
||||
expenseType: values.expenseType,
|
||||
amount: values.amount,
|
||||
expenseDate: values.expenseDate.format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
};
|
||||
if (editingPersonal) {
|
||||
await api.put(`/expenses/personal/${editingPersonal.id}`, payload);
|
||||
message.success('更新成功');
|
||||
@@ -209,7 +216,9 @@ const ExpensesPage: React.FC = () => {
|
||||
personalForm.resetFields();
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
if (!isFormValidationError(e)) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -443,7 +452,12 @@ const ExpensesPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRoomKeys,
|
||||
@@ -566,7 +580,12 @@ const ExpensesPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedPersonalKeys,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { formatNotificationText } from '../../utils/notification-display';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
@@ -169,7 +170,7 @@ const NotificationsPage: React.FC = () => {
|
||||
strong={!item.isRead}
|
||||
style={{ fontSize: 15 }}
|
||||
>
|
||||
{item.title}
|
||||
{formatNotificationText(item.title)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{timeAgo(item.createdAt)}
|
||||
@@ -183,7 +184,7 @@ const NotificationsPage: React.FC = () => {
|
||||
ellipsis={{ rows: 1 }}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{item.content}
|
||||
{formatNotificationText(item.content)}
|
||||
</Typography.Paragraph>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -495,7 +495,12 @@ const OccupanciesPage: React.FC = () => {
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
rowSelection={rowSelection}
|
||||
/>
|
||||
<Modal
|
||||
|
||||
@@ -26,13 +26,14 @@ const OperationLogsPage: React.FC = () => {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [filterModule, setFilterModule] = useState<string | undefined>();
|
||||
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, pageSize: 20 };
|
||||
const params: any = { page, pageSize };
|
||||
if (filterModule) params.module = filterModule;
|
||||
if (dateRange) {
|
||||
params.startDate = dateRange[0];
|
||||
@@ -46,7 +47,7 @@ const OperationLogsPage: React.FC = () => {
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page, filterModule, dateRange]);
|
||||
}, [page, pageSize, filterModule, dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -159,8 +160,13 @@ const OperationLogsPage: React.FC = () => {
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: setPage,
|
||||
pageSize,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -235,7 +235,12 @@ const OrganizationsPage: React.FC = () => {
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无机构" /> }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个机构` }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 个机构`,
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
||||
|
||||
@@ -519,7 +519,12 @@ const RoomsPage: React.FC = () => {
|
||||
scroll={{ x: 1200 }}
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 间` }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 间`,
|
||||
}}
|
||||
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
|
||||
@@ -322,7 +322,28 @@ const StudentsPage: React.FC = () => {
|
||||
},
|
||||
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
|
||||
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
|
||||
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
|
||||
{
|
||||
title: '紧急联系人电话',
|
||||
dataIndex: 'emergencyPhone',
|
||||
width: 150,
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: '8px 4px' }}
|
||||
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
|
||||
title="点击查看完整号码"
|
||||
>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '所属机构',
|
||||
dataIndex: 'organization',
|
||||
@@ -556,7 +577,12 @@ const StudentsPage: React.FC = () => {
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
scroll={{ x: 1410 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 人` }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 人`,
|
||||
}}
|
||||
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
|
||||
@@ -146,7 +146,12 @@ const TeacherWorkspacePage: React.FC = () => {
|
||||
columns={classColumns}
|
||||
dataSource={data.assignedClasses}
|
||||
rowKey="classId"
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个班级` }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 个班级`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无分配的班级" />
|
||||
@@ -160,7 +165,12 @@ const TeacherWorkspacePage: React.FC = () => {
|
||||
columns={scheduleColumns}
|
||||
dataSource={data.todaySchedules}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 节` }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 节`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="今日无排课" />
|
||||
@@ -174,7 +184,12 @@ const TeacherWorkspacePage: React.FC = () => {
|
||||
columns={studentColumns}
|
||||
dataSource={data.myStudents}
|
||||
rowKey="studentId"
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 人` }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 人`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无学生" />
|
||||
|
||||
@@ -46,13 +46,14 @@ const ROLE_TYPE_LABELS: Record<string, string> = {
|
||||
academic_teacher: '教务老师',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
const TeachersPage: React.FC = () => {
|
||||
const [data, setData] = useState<TeacherRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [search, setSearch] = useState('');
|
||||
const [profileModal, setProfileModal] = useState<TeacherRow | null>(null);
|
||||
const [form] = Form.useForm<ProfileFormValues>();
|
||||
@@ -62,7 +63,7 @@ const TeachersPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<TeacherListResponse>('/rbac/teachers', {
|
||||
params: { search: search || undefined, page, pageSize: PAGE_SIZE },
|
||||
params: { search: search || undefined, page, pageSize },
|
||||
});
|
||||
setData(res.list);
|
||||
setTotal(res.total);
|
||||
@@ -70,7 +71,7 @@ const TeachersPage: React.FC = () => {
|
||||
// silent
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page, search]);
|
||||
}, [page, pageSize, search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -199,9 +200,14 @@ const TeachersPage: React.FC = () => {
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: setPage,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 人`,
|
||||
}}
|
||||
expandable={{
|
||||
|
||||
25
apps/admin/src/utils/notification-display.ts
Normal file
25
apps/admin/src/utils/notification-display.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const notificationTypeLabels: Record<string, string> = {
|
||||
bill_generated: '账单',
|
||||
bill_paid: '账单',
|
||||
check_in: '入住',
|
||||
check_out: '退宿',
|
||||
deposit_due: '押金',
|
||||
deposit_refunded: '押金',
|
||||
class_change: '班级',
|
||||
schedule_conflict: '排课',
|
||||
announcement: '公告',
|
||||
};
|
||||
|
||||
const teacherRoleLabels: Record<string, string> = {
|
||||
subject_teacher: '任课老师',
|
||||
head_teacher: '班主任',
|
||||
life_teacher: '生活老师',
|
||||
academic_teacher: '学服老师',
|
||||
};
|
||||
|
||||
export function formatNotificationText(text: string): string {
|
||||
return Object.entries(teacherRoleLabels).reduce(
|
||||
(result, [roleType, label]) => result.replaceAll(roleType, label),
|
||||
text,
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { extractRequestInfo } from '../common/request-utils';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import { TeacherRoleType } from '../entities';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization';
|
||||
|
||||
@@ -36,6 +37,13 @@ interface AuthenticatedRequest {
|
||||
user: AuthenticatedUser;
|
||||
}
|
||||
|
||||
const teacherRoleLabels: Record<string, string> = {
|
||||
[TeacherRoleType.SUBJECT_TEACHER]: '任课老师',
|
||||
[TeacherRoleType.HEAD_TEACHER]: '班主任',
|
||||
[TeacherRoleType.LIFE_TEACHER]: '生活老师',
|
||||
[TeacherRoleType.ACADEMIC_TEACHER]: '学服老师',
|
||||
};
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('classes')
|
||||
export class ClassesController {
|
||||
@@ -302,7 +310,7 @@ export class ClassesController {
|
||||
recipientIds: [dto.userId],
|
||||
type: NotificationType.CLASS_CHANGE,
|
||||
title: '班级分配',
|
||||
content: `您已被分配到班级担任${dto.roleType}角色`,
|
||||
content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`,
|
||||
});
|
||||
} catch {}
|
||||
return result;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
|
||||
import { IntegrationConfigService } from './config/integration-config.service';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -18,6 +19,11 @@ interface DingTalkTokenResponse {
|
||||
expireIn: number;
|
||||
}
|
||||
|
||||
interface DingTalkCredentials {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
}
|
||||
|
||||
interface DingTalkUserListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
@@ -168,6 +174,7 @@ export interface DingTalkScheduleResult {
|
||||
export class DingTalkService {
|
||||
private readonly logger = new Logger(DingTalkService.name);
|
||||
private accessToken: string | null = null;
|
||||
private accessTokenCredentialKey: string | null = null;
|
||||
private tokenExpiresAt = 0;
|
||||
private apiRequestCount = 0;
|
||||
|
||||
@@ -180,10 +187,28 @@ export class DingTalkService {
|
||||
private readonly studentRepo: Repository<Student>,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
private readonly integrationConfigService?: IntegrationConfigService,
|
||||
) {}
|
||||
|
||||
private get configured(): boolean {
|
||||
return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET);
|
||||
private async getCredentials(): Promise<DingTalkCredentials | null> {
|
||||
const rawConfig = await this.integrationConfigService?.getRawConfig('DINGTALK');
|
||||
const dbAppKey = typeof rawConfig?.agentId === 'string' ? rawConfig.agentId.trim() : '';
|
||||
const dbAppSecret = typeof rawConfig?.appSecret === 'string' ? rawConfig.appSecret.trim() : '';
|
||||
if (dbAppKey && dbAppSecret) {
|
||||
return { appKey: dbAppKey, appSecret: dbAppSecret };
|
||||
}
|
||||
|
||||
const envAppKey = process.env.DINGTALK_APP_KEY?.trim();
|
||||
const envAppSecret = process.env.DINGTALK_APP_SECRET?.trim();
|
||||
if (envAppKey && envAppSecret) {
|
||||
return { appKey: envAppKey, appSecret: envAppSecret };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async isConfigured(): Promise<boolean> {
|
||||
return !!(await this.getCredentials());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -191,16 +216,24 @@ export class DingTalkService {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
|
||||
const credentials = await this.getCredentials();
|
||||
if (!credentials) {
|
||||
throw new Error('DingTalk not configured');
|
||||
}
|
||||
|
||||
const credentialKey = `${credentials.appKey}:${credentials.appSecret}`;
|
||||
if (
|
||||
this.accessToken &&
|
||||
this.accessTokenCredentialKey === credentialKey &&
|
||||
Date.now() < this.tokenExpiresAt - 60_000
|
||||
) {
|
||||
return this.accessToken;
|
||||
}
|
||||
const appKey = process.env.DINGTALK_APP_KEY!;
|
||||
const appSecret = process.env.DINGTALK_APP_SECRET!;
|
||||
|
||||
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appKey, appSecret }),
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
const body: DingTalkTokenResponse = await res.json();
|
||||
|
||||
@@ -209,6 +242,7 @@ export class DingTalkService {
|
||||
}
|
||||
|
||||
this.accessToken = body.accessToken;
|
||||
this.accessTokenCredentialKey = credentialKey;
|
||||
this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000;
|
||||
this.logger.log('钉钉 access_token 获取成功');
|
||||
return this.accessToken;
|
||||
@@ -259,7 +293,7 @@ export class DingTalkService {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> {
|
||||
if (!this.configured) {
|
||||
if (!(await this.isConfigured())) {
|
||||
this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步');
|
||||
return { deptCount: 0, userCount: 0 };
|
||||
}
|
||||
@@ -333,7 +367,7 @@ export class DingTalkService {
|
||||
|
||||
/** 获取钉钉组织部门树(只含部门) */
|
||||
async fetchOrgTree(rootDeptId = 1): Promise<OrgDeptNode[]> {
|
||||
if (!this.configured) return [];
|
||||
if (!(await this.isConfigured())) return [];
|
||||
const token = await this.getAccessToken();
|
||||
const rootInfo = await this.getDeptInfo(token, rootDeptId);
|
||||
if (!rootInfo) return [];
|
||||
@@ -343,7 +377,7 @@ export class DingTalkService {
|
||||
|
||||
/** 获取钉钉组织部门树(含用户) */
|
||||
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<OrgDeptNodeWithUsers[]> {
|
||||
if (!this.configured) return [];
|
||||
if (!(await this.isConfigured())) return [];
|
||||
const token = await this.getAccessToken();
|
||||
const rootInfo = await this.getDeptInfo(token, rootDeptId);
|
||||
if (!rootInfo) return [];
|
||||
@@ -441,7 +475,7 @@ export class DingTalkService {
|
||||
endDate: string;
|
||||
userIds?: string[];
|
||||
}): Promise<DingTalkAttendanceResult[]> {
|
||||
if (!this.configured) throw new Error('DingTalk not configured');
|
||||
if (!(await this.isConfigured())) throw new Error('DingTalk not configured');
|
||||
if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空');
|
||||
if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人');
|
||||
const token = await this.getAccessToken();
|
||||
@@ -496,7 +530,7 @@ export class DingTalkService {
|
||||
|
||||
/** 创建或修改班次。id 不传=创建,传了=修改 */
|
||||
async upsertShift(params: DingTalkShiftParams): Promise<number> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
@@ -547,7 +581,7 @@ export class DingTalkService {
|
||||
|
||||
/** 查询所有班次摘要(每页最多200条) */
|
||||
async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
const all: DingTalkShiftSummary[] = [];
|
||||
@@ -598,7 +632,7 @@ export class DingTalkService {
|
||||
|
||||
/** 创建排班制考勤组 */
|
||||
async createAttendanceGroup(params: DingTalkGroupParams): Promise<number> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
const topGroup = this.buildAttendanceGroupBody(params);
|
||||
@@ -627,7 +661,7 @@ export class DingTalkService {
|
||||
|
||||
/** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */
|
||||
async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id };
|
||||
|
||||
@@ -687,7 +721,7 @@ export class DingTalkService {
|
||||
|
||||
/** 查询所有考勤组摘要(分页,每页10条) */
|
||||
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
const all: DingTalkGroupSummary[] = [];
|
||||
@@ -729,7 +763,7 @@ export class DingTalkService {
|
||||
}
|
||||
|
||||
async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
await this.rateLimit();
|
||||
@@ -778,7 +812,7 @@ export class DingTalkService {
|
||||
async scheduleUsers(
|
||||
groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager',
|
||||
): Promise<void> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
if (schedules.length === 0) return;
|
||||
if (schedules.length > 200) {
|
||||
throw new Error(`排班单次最多200条,当前 ${schedules.length} 条`);
|
||||
@@ -818,7 +852,7 @@ export class DingTalkService {
|
||||
async queryScheduleByUsers(
|
||||
userIds: string[], fromDate: number, toDate: number, opUserId = 'manager',
|
||||
): Promise<DingTalkScheduleResult[]> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
await this.rateLimit();
|
||||
|
||||
@@ -3,9 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User, Student, StudentDingMapping, Class } from '../entities';
|
||||
import { DingTalkService } from './dingtalk.service';
|
||||
import { WeComService } from './wecom.service';
|
||||
import { IntegrationConfigModule } from './config/config.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class]),
|
||||
IntegrationConfigModule,
|
||||
],
|
||||
providers: [DingTalkService, WeComService],
|
||||
exports: [DingTalkService, WeComService],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user