fix: resolve all admin typecheck errors (typed api wrapper, unused imports, missing hooks, PermissionButton children)

This commit is contained in:
2026-07-06 01:02:26 +08:00
parent 4a48a65a48
commit 6b0a21d266
22 changed files with 4051 additions and 273 deletions

View File

@@ -1,11 +1,11 @@
import axios from 'axios';
import axios, { type AxiosRequestConfig } from 'axios';
const api = axios.create({
const instance = axios.create({
baseURL: '/api',
timeout: 10000,
});
api.interceptors.request.use((config) => {
instance.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -17,7 +17,7 @@ api.interceptors.request.use((config) => {
return config;
});
api.interceptors.response.use(
instance.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
@@ -34,4 +34,24 @@ api.interceptors.response.use(
},
);
const request = <T>(
method: string,
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<T> => instance.request({ ...config, method, url, data }) as Promise<T>;
const api = {
get: <T>(url: string, config?: AxiosRequestConfig): Promise<T> =>
request<T>('get', url, undefined, config),
post: <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> =>
request<T>('post', url, data, config),
put: <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> =>
request<T>('put', url, data, config),
patch: <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> =>
request<T>('patch', url, data, config),
delete: <T>(url: string, config?: AxiosRequestConfig): Promise<T> =>
request<T>('delete', url, undefined, config),
};
export default api;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback } from 'react';
import api from '../api';
interface Notification {

View File

@@ -266,6 +266,7 @@ const MainLayout: React.FC = () => {
)
}
onClick={() => (isMobile || isTablet ? setDrawerOpen(true) : setCollapsed(!collapsed))}
/>
{isDesktop && <CampusSwitcher />}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<NotificationBell />

View File

@@ -14,11 +14,13 @@ import {
Tooltip,
Row,
Col,
Tabs,
} from 'antd';
import {
PlusOutlined,
CalendarOutlined,
UnorderedListOutlined,
ExportOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
@@ -61,6 +63,17 @@ const SOURCE_OPTIONS = [
{ value: 'dingtalk', label: '钉钉导入' },
];
const MATCH_STATUS_MAP: Record<string, { text: string; color: string }> = {
unmatched: { text: '未处理', color: 'default' },
pending: { text: '待匹配', color: 'orange' },
matched: { text: '已匹配', color: 'green' },
};
const MATCH_STATUS_OPTIONS = Object.entries(MATCH_STATUS_MAP).map(([value, { text }]) => ({
value,
label: text,
}));
const SOURCE_MAP: Record<string, string> = {
manual: '手动录入',
dingtalk: '钉钉导入',
@@ -94,6 +107,15 @@ interface BatchRecordInput {
studentName: string;
}
interface DingRecord {
id: number;
dingUserId: string;
checkTime: string;
rawStatus: string;
matchStatus: string;
studentId?: number;
}
// ── Component ──
const AttendancePage: React.FC = () => {
@@ -119,6 +141,25 @@ const AttendancePage: React.FC = () => {
>([]);
const [calendarLoading, setCalendarLoading] = useState(false);
// Tab
const [activeTab, setActiveTab] = useState('records');
// DingTalk raw data
const [dingRecords, setDingRecords] = useState<DingRecord[]>([]);
const [dingLoading, setDingLoading] = useState(false);
const [dingPage, setDingPage] = useState(1);
const [dingPageSize, setDingPageSize] = useState(20);
const [dingTotal, setDingTotal] = useState(0);
const [dingMatchStatus, setDingMatchStatus] = useState<string | undefined>(undefined);
// Match modal
const [matchModalOpen, setMatchModalOpen] = useState(false);
const [matchRecordId, setMatchRecordId] = useState<number | null>(null);
const [matchStudentSearch, setMatchStudentSearch] = useState('');
const [matchStudentResults, setMatchStudentResults] = useState<{ id: number; name: string }[]>([]);
const [matchStudentLoading, setMatchStudentLoading] = useState(false);
const [matchSubmitting, setMatchSubmitting] = useState(false);
// Batch modal
const [batchModalOpen, setBatchModalOpen] = useState(false);
const [batchStudents, setBatchStudents] = useState<BatchRecordInput[]>([]);
@@ -159,8 +200,8 @@ const AttendancePage: React.FC = () => {
// ── Fetch classes ──
const fetchClasses = useCallback(async () => {
try {
const res = await api.get('/attendance-records/classes');
setClassOptions(res as ClassOption[]);
const res = await api.get<ClassOption[]>('/attendance-records/classes');
setClassOptions(res);
} catch {
// Silently fail — class filter just stays empty
}
@@ -178,8 +219,7 @@ const AttendancePage: React.FC = () => {
if (filterStatus) params.status = filterStatus;
if (filterSource) params.source = filterSource;
const res = await api.get('/attendance-records', { params });
const data = res as { list: AttendanceRecordItem[]; total: number };
const data = await api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { params });
setRecords(data.list);
setTotal(data.total);
} catch (e: unknown) {
@@ -198,10 +238,10 @@ const AttendancePage: React.FC = () => {
}
setCalendarLoading(true);
try {
const res = await api.get('/attendance-records/calendar', {
const res = await api.get<{ studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]>('/attendance-records/calendar', {
params: { classId: filterClassId },
});
setCalendarData(res as { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]);
setCalendarData(res);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '获取日历数据失败');
@@ -210,6 +250,27 @@ const AttendancePage: React.FC = () => {
}
}, [filterClassId]);
// ── Fetch DingTalk raw data ──
const fetchDingRecords = useCallback(async () => {
setDingLoading(true);
try {
const params: Record<string, string | number> = { page: dingPage, pageSize: dingPageSize };
if (filterClassId) params.classId = filterClassId;
if (filterDateRange?.[0]) params.dateFrom = filterDateRange[0].format('YYYY-MM-DD');
if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD');
if (dingMatchStatus) params.matchStatus = dingMatchStatus;
const data = await api.get<{ list: DingRecord[]; total: number }>('/ding-attendance-raw', { params });
setDingRecords(data.list);
setDingTotal(data.total);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '获取钉钉原始数据失败');
} finally {
setDingLoading(false);
}
}, [dingPage, dingPageSize, filterClassId, filterDateRange, dingMatchStatus]);
// ── Effects ──
useEffect(() => {
fetchClasses();
@@ -223,6 +284,12 @@ const AttendancePage: React.FC = () => {
}
}, [calendarView, fetchRecords, fetchCalendar]);
useEffect(() => {
if (activeTab === 'dingtalk') {
fetchDingRecords();
}
}, [activeTab, fetchDingRecords]);
// ── Student search ──
const handleStudentSearch = useCallback(async (value: string) => {
setStudentSearch(value);
@@ -232,8 +299,7 @@ const AttendancePage: React.FC = () => {
}
setStudentSearchLoading(true);
try {
const res = await api.get('/students', { params: { search: value, pageSize: 10 } });
const data = res as { list?: { id: number; name: string }[] } | { id: number; name: string }[];
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } });
const list = Array.isArray(data) ? data : data.list ?? [];
setStudentSearchResults(list);
} catch {
@@ -300,6 +366,126 @@ const AttendancePage: React.FC = () => {
setPage(1);
}, []);
// ── Match modal ──
const openMatchModal = useCallback((recordId: number) => {
setMatchRecordId(recordId);
setMatchStudentSearch('');
setMatchStudentResults([]);
setMatchModalOpen(true);
}, []);
const handleMatchStudentSearch = useCallback(async (value: string) => {
setMatchStudentSearch(value);
if (!value || value.length < 1) {
setMatchStudentResults([]);
return;
}
setMatchStudentLoading(true);
try {
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } });
const list = Array.isArray(data) ? data : data.list ?? [];
setMatchStudentResults(list);
} catch {
setMatchStudentResults([]);
} finally {
setMatchStudentLoading(false);
}
}, []);
const handleMatchSubmit = useCallback(async (studentId: number) => {
if (matchRecordId === null) return;
setMatchSubmitting(true);
try {
await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId });
message.success('匹配成功');
setMatchModalOpen(false);
setMatchRecordId(null);
fetchDingRecords();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '匹配失败');
} finally {
setMatchSubmitting(false);
}
}, [matchRecordId, fetchDingRecords]);
// ── Report download ──
const handleExportReport = useCallback(() => {
const baseURL = '/api';
const token = localStorage.getItem('token');
const params = new URLSearchParams();
if (filterClassId) params.set('classId', String(filterClassId));
if (filterDateRange?.[0]) params.set('dateFrom', filterDateRange[0].format('YYYY-MM-DD'));
if (filterDateRange?.[1]) params.set('dateTo', filterDateRange[1].format('YYYY-MM-DD'));
fetch(`${baseURL}/attendance-records/report?${params.toString()}`, {
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 = '考勤报表.xlsx';
a.click();
URL.revokeObjectURL(url);
message.success('报表下载成功');
})
.catch(() => message.error('报表下载失败'));
}, [filterClassId, filterDateRange]);
// ── DingTalk table columns ──
const dingColumns = useMemo(
() => [
{
title: '钉钉用户ID',
dataIndex: 'dingUserId',
key: 'dingUserId',
width: 160,
},
{
title: '打卡时间',
dataIndex: 'checkTime',
key: 'checkTime',
width: 160,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'),
},
{
title: '打卡状态',
dataIndex: 'rawStatus',
key: 'rawStatus',
width: 120,
},
{
title: '匹配状态',
dataIndex: 'matchStatus',
key: 'matchStatus',
width: 100,
render: (v: string) => {
const item = MATCH_STATUS_MAP[v];
return item ? <Tag color={item.color}>{item.text}</Tag> : v;
},
},
{
title: '操作',
key: 'actions',
width: 80,
render: (_: unknown, record: DingRecord) => {
if (record.matchStatus === 'matched') return <span>-</span>;
return (
<Button size="small" type="link" onClick={() => openMatchModal(record.id)}>
</Button>
);
},
},
],
[openMatchModal],
);
// ── Table columns ──
const columns = useMemo(
() => [
@@ -391,172 +577,264 @@ const AttendancePage: React.FC = () => {
// ── Render ──
return (
<div>
{/* ── Filter bar ── */}
<Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={[12, 12]} align="middle">
<Col>
<Select
placeholder="选择班级"
allowClear
style={{ width: 160 }}
value={filterClassId}
onChange={(v) => {
setFilterClassId(v);
setPage(1);
}}
options={classOptions.map((c) => ({
value: c.classId,
label: c.className,
}))}
/>
</Col>
<Col>
<RangePicker
value={filterDateRange}
onChange={(dates) => {
setFilterDateRange(dates as [Dayjs, Dayjs] | null);
setPage(1);
}}
placeholder={['开始日期', '结束日期']}
/>
</Col>
<Col>
<Select
placeholder="时段"
allowClear
style={{ width: 100 }}
value={filterSession}
onChange={(v) => {
setFilterSession(v);
setPage(1);
}}
options={SESSION_OPTIONS}
/>
</Col>
<Col>
<Select
placeholder="状态"
allowClear
style={{ width: 100 }}
value={filterStatus}
onChange={(v) => {
setFilterStatus(v);
setPage(1);
}}
options={STATUS_OPTIONS}
/>
</Col>
<Col>
<Select
placeholder="来源"
allowClear
style={{ width: 110 }}
value={filterSource}
onChange={(v) => {
setFilterSource(v);
setPage(1);
}}
options={SOURCE_OPTIONS}
/>
</Col>
<Col>
<Space>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Card>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'records',
label: '考勤记录',
children: (
<>
{/* ── Filter bar ── */}
<Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={[12, 12]} align="middle">
<Col>
<Select
placeholder="选择班级"
allowClear
style={{ width: 160 }}
value={filterClassId}
onChange={(v) => {
setFilterClassId(v);
setPage(1);
}}
options={classOptions.map((c) => ({
value: c.classId,
label: c.className,
}))}
/>
</Col>
<Col>
<RangePicker
value={filterDateRange}
onChange={(dates) => {
setFilterDateRange(dates as [Dayjs, Dayjs] | null);
setPage(1);
}}
placeholder={['开始日期', '结束日期']}
/>
</Col>
<Col>
<Select
placeholder="时段"
allowClear
style={{ width: 100 }}
value={filterSession}
onChange={(v) => {
setFilterSession(v);
setPage(1);
}}
options={SESSION_OPTIONS}
/>
</Col>
<Col>
<Select
placeholder="状态"
allowClear
style={{ width: 100 }}
value={filterStatus}
onChange={(v) => {
setFilterStatus(v);
setPage(1);
}}
options={STATUS_OPTIONS}
/>
</Col>
<Col>
<Select
placeholder="来源"
allowClear
style={{ width: 110 }}
value={filterSource}
onChange={(v) => {
setFilterSource(v);
setPage(1);
}}
options={SOURCE_OPTIONS}
/>
</Col>
<Col>
<Space>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Card>
{/* ── Action bar ── */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Space>
<PermissionButton
permission="attendance:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => setBatchModalOpen(true)}
>
</PermissionButton>
</Space>
<Button
icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />}
onClick={() => setCalendarView(!calendarView)}
>
{calendarView ? '列表视图' : '日历视图'}
</Button>
</div>
{/* ── Action bar ── */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Space>
<PermissionButton
permission="attendance:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => setBatchModalOpen(true)}
>
</PermissionButton>
<Button
icon={<ExportOutlined />}
onClick={handleExportReport}
>
</Button>
</Space>
<Button
icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />}
onClick={() => setCalendarView(!calendarView)}
>
{calendarView ? '列表视图' : '日历视图'}
</Button>
</div>
{/* ── Table view ── */}
{!calendarView && (
<Table
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t: number) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
)}
{/* ── Table view ── */}
{!calendarView && (
<Table
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t: number) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
)}
{/* ── Calendar view ── */}
{calendarView && (
<Card loading={calendarLoading} title={calendarView ? '考勤日历' : undefined}>
{calendarData.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
) : (
<Table
rowKey="studentId"
dataSource={calendarData}
pagination={false}
scroll={{ x: Math.max(800, calendarDates.length * 80) }}
columns={[
{
title: '学生',
dataIndex: 'studentName',
key: 'studentName',
width: 100,
fixed: 'left' as const,
},
...calendarDates.map((date) => ({
title: (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<div>{dayjs(date).format('MM/DD')}</div>
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
</div>
),
key: date,
width: 80,
render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => {
const dayRecord = record.days.find((d) => d.date === date);
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
const statusInfo = STATUS_MAP[dayRecord.status];
return (
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
{statusInfo?.text || dayRecord.status}
</Tag>
</Tooltip>
);
},
})),
]}
/>
)}
</Card>
)}
{/* ── Calendar view ── */}
{calendarView && (
<Card loading={calendarLoading} title="考勤日历">
{calendarData.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
) : (
<Table
rowKey="studentId"
dataSource={calendarData}
pagination={false}
scroll={{ x: Math.max(800, calendarDates.length * 80) }}
columns={[
{
title: '学生',
dataIndex: 'studentName',
key: 'studentName',
width: 100,
fixed: 'left' as const,
},
...calendarDates.map((date) => ({
title: (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<div>{dayjs(date).format('MM/DD')}</div>
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
</div>
),
key: date,
width: 80,
render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => {
const dayRecord = record.days.find((d) => d.date === date);
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
const statusInfo = STATUS_MAP[dayRecord.status];
return (
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
{statusInfo?.text || dayRecord.status}
</Tag>
</Tooltip>
);
},
})),
]}
/>
)}
</Card>
)}
</>
),
},
{
key: 'dingtalk',
label: '钉钉原始数据',
children: (
<>
{/* ── DingTalk filter ── */}
<Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={[12, 12]} align="middle">
<Col>
<Select
placeholder="选择班级"
allowClear
style={{ width: 160 }}
value={filterClassId}
onChange={(v) => {
setFilterClassId(v);
setDingPage(1);
}}
options={classOptions.map((c) => ({
value: c.classId,
label: c.className,
}))}
/>
</Col>
<Col>
<RangePicker
value={filterDateRange}
onChange={(dates) => {
setFilterDateRange(dates as [Dayjs, Dayjs] | null);
setDingPage(1);
}}
placeholder={['开始日期', '结束日期']}
/>
</Col>
<Col>
<Select
placeholder="匹配状态"
allowClear
style={{ width: 120 }}
value={dingMatchStatus}
onChange={(v) => {
setDingMatchStatus(v);
setDingPage(1);
}}
options={MATCH_STATUS_OPTIONS}
/>
</Col>
</Row>
</Card>
{/* ── DingTalk table ── */}
<Table
rowKey="id"
columns={dingColumns}
dataSource={dingRecords}
loading={dingLoading}
scroll={{ x: 700 }}
pagination={{
current: dingPage,
pageSize: dingPageSize,
total: dingTotal,
showSizeChanger: true,
showTotal: (t: number) => `${t}`,
onChange: (p, ps) => {
setDingPage(p);
setDingPageSize(ps);
},
}}
/>
</>
),
},
]}
/>
{/* ── Batch add modal ── */}
<Modal
@@ -612,7 +890,8 @@ const AttendancePage: React.FC = () => {
placeholder="搜索学生姓名或学号"
filterOption={false}
onSearch={handleStudentSearch}
onSelect={(value: number) => {
onSelect={(value: number | undefined) => {
if (value === undefined) return;
const student = studentSearchResults.find((s) => s.id === value);
if (student) addStudentToBatch(student.id, student.name);
}}
@@ -666,6 +945,48 @@ const AttendancePage: React.FC = () => {
</Form>
</Modal>
{/* ── Match student modal ── */}
<Modal
title="匹配学生"
open={matchModalOpen}
onOk={() => {
const selected = matchStudentResults.find((s) => s.name === matchStudentSearch);
if (selected) handleMatchSubmit(selected.id);
}}
onCancel={() => {
setMatchModalOpen(false);
setMatchRecordId(null);
setMatchStudentSearch('');
setMatchStudentResults([]);
}}
confirmLoading={matchSubmitting}
okText="确定"
cancelText="取消"
>
<Form layout="vertical">
<Form.Item label="选择学生" required>
<Select
showSearch
value={undefined}
placeholder="搜索学生姓名或学号"
filterOption={false}
onSearch={handleMatchStudentSearch}
onSelect={(value: number | undefined) => {
if (value === undefined) return;
handleMatchSubmit(value);
}}
loading={matchStudentLoading}
options={matchStudentResults.map((s) => ({
value: s.id,
label: `${s.name} (ID: ${s.id})`,
}))}
style={{ width: '100%' }}
notFoundContent={matchStudentSearch ? '未找到匹配的学生' : '输入关键词搜索'}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
};

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState, useMemo } from 'react';
import {
Table,
Button,
Modal,
Form,
DatePicker,

View File

@@ -82,8 +82,8 @@ const ClassesPage: React.FC = () => {
const params: ClassQueryParams = {};
if (filterStatus) params.status = filterStatus;
if (filterType) params.classType = filterType;
const res = await api.get('/classes', { params });
setData(res as ClassItem[]);
const res = await api.get<ClassItem[]>('/classes', { params });
setData(res);
} catch (e) {
console.error(e);
} finally {
@@ -111,6 +111,7 @@ const ClassesPage: React.FC = () => {
setEditing(record);
form.setFieldsValue({
...record,
notes: record.notes ?? undefined,
startDate: record.startDate ? dayjs(record.startDate) : undefined,
endDate: record.endDate ? dayjs(record.endDate) : undefined,
});

View File

@@ -172,19 +172,21 @@ const ClassroomSchedulePage: React.FC = () => {
</Col>
</Row>
{/* 租赁方图例 */}
{data && data.tenants.length > 0 && (
<Card size="small" style={{ marginBottom: 16 }} title="租赁方图例">
{/* 图例 */}
{data && (
<Card size="small" style={{ marginBottom: 16 }} title="图例">
<Space wrap>
<Tag color="#52c41a"></Tag>
{data.tenants.map((t) => (
<Tag
key={t.id}
color={t.color}
style={{ background: t.color, color: '#fff', borderColor: t.color }}
>
{t.name}
{t.name} ()
</Tag>
))}
<Tag color="#d9d9d9" style={{ color: '#999' }}></Tag>
</Space>
</Card>
)}
@@ -288,25 +290,33 @@ const ClassroomSchedulePage: React.FC = () => {
</td>
{Array.from({ length: data.days }, (_, i) => i + 1).map((d) => {
const cell = data.matrix[c.id]?.[d];
const isInternal = cell?.scheduleType === 'INTERNAL';
const isRental = cell?.scheduleType === 'RENTAL';
return (
<td
key={d}
onClick={() => cell && showDetail(cell.rentalId)}
onClick={() => {
if (isRental) showDetail(cell.rentalId);
}}
style={{
padding: 0,
border: '1px solid #f0f0f0',
background: cell?.color || '#fff',
height: 26,
cursor: cell ? 'pointer' : 'default',
cursor: isRental ? 'pointer' : 'default',
textAlign: 'center',
}}
>
{cell && (
<Tooltip
title={`${cell.tenantName}${cell.hasContract ? ' · 有合同' : ''}`}
title={
isInternal
? `${cell.className} · ${cell.subject}\n${cell.teacherName} · ${cell.startTime}-${cell.endTime}`
: `${cell.tenantName}${cell.hasContract ? ' · 有合同' : ''}`
}
>
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
{cell.hasContract ? '📄' : ''}
{isInternal ? '📖' : cell.hasContract ? '📄' : ''}
</span>
</Tooltip>
)}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid } from 'antd';
import { TeamOutlined, HomeOutlined, DollarOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined, SolutionOutlined, WalletOutlined, FileProtectOutlined } from '@ant-design/icons';
import { TeamOutlined, HomeOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined, SolutionOutlined, FileProtectOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
import api from '../../api';
@@ -61,8 +61,6 @@ interface DashboardStats {
incomeTrend: IncomeTrendRow[];
}
interface ApiResponse<T> { data: T }
const DashboardPage: React.FC = () => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
@@ -70,7 +68,7 @@ const DashboardPage: React.FC = () => {
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 [roomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]);
const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState<[string, string]>([
dayjs().startOf('month').format('YYYY-MM-DD'),
@@ -80,21 +78,21 @@ const DashboardPage: React.FC = () => {
const fetchData = async () => {
setLoading(true);
try {
const [s, r, cr, g] = await Promise.all([
api.get('/dashboard/stats'),
const [s, , cr, g] = await Promise.all([
api.get<DashboardStats>('/dashboard/stats'),
api.get('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get('/dashboard/class-attendance-ranking'),
api.get('/dashboard/gantt', {
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>('/dashboard/class-attendance-ranking'),
api.get<GanttRoom[]>('/dashboard/gantt', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
]);
setStats(s);
setClassRanking(cr as unknown as { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] });
setGanttData(g as GanttRoom[]);
const co = await api.get('/dashboard/classroom-occupancy');
setClassroomOccupancy(co as ClassroomOccupancy[]);
setClassRanking(cr);
setGanttData(g);
const co = await api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy');
setClassroomOccupancy(co);
} catch (e) {
console.error(e);
}
@@ -108,7 +106,7 @@ const DashboardPage: React.FC = () => {
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
useEffect(() => {
api.get('/expense-types').then((types: any[]) => {
api.get<Array<{ code: string; name: string }>>('/expense-types').then((types) => {
const map: Record<string, string> = {};
for (const t of types) map[t.code] = t.name;
setExpenseTypeMap(map);
@@ -265,9 +263,12 @@ const DashboardPage: React.FC = () => {
coord: (p: [string | number, string | number]) => [number, number];
size: (p: [number, number]) => [number, number];
}) => {
const cat = api.value(0);
const start = api.coord([api.value(1), cat]);
const end = api.coord([api.value(2), cat]);
// ECharts value API returns mixed datum entries; narrow to the known gantt shape.
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],
@@ -278,7 +279,7 @@ const DashboardPage: React.FC = () => {
return {
type: 'rect' as const,
shape: rectShape,
style: { fill: api.value(3) ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 },
style: { fill: isActive ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 },
};
},
encode: { x: [1, 2], y: 0 },

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState, useMemo } from 'react';
import {
Table,
Button,
Modal,
Form,
Select,
@@ -40,6 +39,15 @@ const installmentStatusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
};
interface PendingRefund {
id: number;
student?: { name?: string };
amount?: number;
paidDate?: string;
refundStatus?: string;
refundRequestedAt?: string;
}
const DepositsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
@@ -48,7 +56,7 @@ const DepositsPage: React.FC = () => {
const [refundModal, setRefundModal] = useState<any>(null);
const [detailModal, setDetailModal] = useState<any>(null);
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
const [pendingRefunds, setPendingRefunds] = useState<any[]>([]);
const [pendingRefunds, setPendingRefunds] = useState<PendingRefund[]>([]);
const [pendingLoading, setPendingLoading] = useState(false);
const [createForm] = Form.useForm();
const [refundForm] = Form.useForm();
@@ -72,7 +80,7 @@ const DepositsPage: React.FC = () => {
const fetchPendingRefunds = async () => {
setPendingLoading(true);
try {
const res = await api.get('/deposits/pending-refunds');
const res = await api.get<PendingRefund[]>('/deposits/pending-refunds');
setPendingRefunds(res || []);
} catch (e) {
console.error(e);
@@ -273,7 +281,9 @@ const DepositsPage: React.FC = () => {
size="small"
danger
icon={<DeleteOutlined />}
/>
>
</PermissionButton>
</Popconfirm>
</Space>
),
@@ -536,7 +546,9 @@ const DepositsPage: React.FC = () => {
title="确定删除?"
onConfirm={() => handleDeleteInstallment(item.id)}
>
<PermissionButton key="del" permission="deposit:delete" size="small" danger icon={<DeleteOutlined />} />
<PermissionButton key="del" permission="deposit:delete" size="small" danger icon={<DeleteOutlined />}>
</PermissionButton>
</Popconfirm>
].filter(Boolean)}
>

View File

@@ -29,35 +29,7 @@ import PermissionButton from '../../components/PermissionButton';
const { RangePicker } = DatePicker;
const expenseTypeOptions = [
{ value: 'water', label: '水费' },
{ value: 'electricity', label: '电费' },
{ value: 'cleaning', label: '保洁费' },
{ value: 'damage', label: '损坏赔偿' },
{ value: 'other', label: '其他' },
];
const personalExpenseTypeOptions = [
{ value: 'damage', label: '物品损坏' },
{ value: 'cleaning', label: '保洁费' },
{ value: 'penalty', label: '罚款' },
{ value: 'key', label: '钥匙费' },
{ value: 'remote', label: '空调遥控器' },
{ value: 'deposit_deduction', label: '押金扣除' },
{ value: 'other', label: '其他' },
];
const typeMap: Record<string, string> = {
water: '水费',
electricity: '电费',
cleaning: '保洁费',
damage: '损坏赔偿',
penalty: '罚款',
key: '钥匙费',
remote: '空调遥控器',
deposit_deduction: '押金扣除',
other: '其他',
};
const ExpensesPage: React.FC = () => {
const [roomExpenses, setRoomExpenses] = useState<any[]>([]);
@@ -78,6 +50,31 @@ const ExpensesPage: React.FC = () => {
const [selectedRoomKeys, setSelectedRoomKeys] = useState<number[]>([]);
const [selectedPersonalKeys, setSelectedPersonalKeys] = useState<number[]>([]);
// Dynamic expense type options from API
const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]);
const [personalTypeOptions, setPersonalTypeOptions] = useState<{ value: string; label: string }[]>([]);
const [typeMap, setTypeMap] = useState<Record<string, string>>({});
useEffect(() => {
api.get<Array<{ code: string; name: string; category: string }>>('/expense-types').then((types) => {
const roomTypes: { value: string; label: string }[] = [];
const personalTypes: { value: string; label: string }[] = [];
const map: Record<string, string> = {};
for (const t of types) {
map[t.code] = t.name;
if (t.category === 'room' || t.category === 'both') {
roomTypes.push({ value: t.code, label: t.name });
}
if (t.category === 'personal' || t.category === 'both') {
personalTypes.push({ value: t.code, label: t.name });
}
}
setTypeOptions(roomTypes);
setPersonalTypeOptions(personalTypes);
setTypeMap(map);
}).catch(() => {});
}, []);
const handleBatchDeleteRoom = async () => {
try {
const res: any = await api.post('/expenses/room/batch-delete', { ids: selectedRoomKeys });
@@ -237,7 +234,7 @@ const ExpensesPage: React.FC = () => {
setRoomModal(true);
}}
>
{''}
</PermissionButton>
<Popconfirm
title="确定删除?"
@@ -252,7 +249,9 @@ const ExpensesPage: React.FC = () => {
size="small"
danger
icon={<DeleteOutlined />}
/>
>
</PermissionButton>
</Popconfirm>
</Space>
),
@@ -291,7 +290,7 @@ const ExpensesPage: React.FC = () => {
setPersonalModal(true);
}}
>
{''}
</PermissionButton>
<Popconfirm
title="确定删除?"
@@ -306,7 +305,9 @@ const ExpensesPage: React.FC = () => {
size="small"
danger
icon={<DeleteOutlined />}
/>
>
</PermissionButton>
</Popconfirm>
</Space>
),
@@ -347,7 +348,7 @@ const ExpensesPage: React.FC = () => {
style={{ width: 120 }}
value={roomTypeFilter}
onChange={(v) => setRoomTypeFilter(v)}
options={expenseTypeOptions}
options={typeOptions}
/>
<Upload
accept=".xlsx,.xls"
@@ -480,7 +481,7 @@ const ExpensesPage: React.FC = () => {
style={{ width: 120 }}
value={personalTypeFilter}
onChange={(v) => setPersonalTypeFilter(v)}
options={personalExpenseTypeOptions}
options={personalTypeOptions}
/>
<Upload
accept=".xlsx,.xls"
@@ -625,7 +626,7 @@ const ExpensesPage: React.FC = () => {
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={expenseTypeOptions} />
<Select options={typeOptions} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
@@ -670,7 +671,7 @@ const ExpensesPage: React.FC = () => {
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={personalExpenseTypeOptions} />
<Select options={personalTypeOptions} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />

View File

@@ -227,7 +227,9 @@ const OccupanciesPage: React.FC = () => {
}
}}
>
<PermissionButton permission="occupancy:delete" size="small" danger icon={<DeleteOutlined />} />
<PermissionButton permission="occupancy:delete" size="small" danger icon={<DeleteOutlined />}>
</PermissionButton>
</Popconfirm>
</Space>
),

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,

View File

@@ -44,11 +44,15 @@ const RoomVisualPage: React.FC = () => {
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
const getCardStyle = (room: any): React.CSSProperties => {
if (room.status === 'maintenance') return { background: '#f5f5f5', borderColor: '#d9d9d9' };
if (room.currentCount === 0) return { background: '#f6ffed', borderColor: '#b7eb8f' };
if (room.currentCount >= room.capacity)
return { background: '#fff2f0', borderColor: '#ffccc7' };
return { background: '#e6f4ff', borderColor: '#91caff' };
let base: React.CSSProperties;
if (room.status === 'maintenance') base = { background: '#f5f5f5', borderColor: '#d9d9d9' };
else if (room.currentCount === 0) base = { background: '#f6ffed', borderColor: '#b7eb8f' };
else if (room.currentCount >= room.capacity) base = { background: '#fff2f0', borderColor: '#ffccc7' };
else base = { background: '#e6f4ff', borderColor: '#91caff' };
if (room.tenantColor) {
return { ...base, background: `color-mix(in srgb, ${room.tenantColor} 15%, ${base.background || '#fff'} 85%)` };
}
return base;
};
const getStatusLabel = (room: any) => {
@@ -130,7 +134,14 @@ const RoomVisualPage: React.FC = () => {
marginBottom: 8,
}}
>
<span style={{ fontSize: 16, fontWeight: 600, color: '#1d1d1f' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 16, fontWeight: 600, color: '#1d1d1f' }}>
{room.tenantColor && (
<span style={{
width: 10, height: 10, borderRadius: '50%',
backgroundColor: room.tenantColor, display: 'inline-block',
flexShrink: 0,
}} />
)}
{room.roomNumber}
</span>
{getStatusLabel(room)}
@@ -205,6 +216,14 @@ const RoomVisualPage: React.FC = () => {
<div style={{ marginBottom: 8, fontWeight: 500 }}>
{detailRoom.building || '-'} {detailRoom.floor ? `${detailRoom.floor}F` : ''}
</div>
{detailRoom.tenantColor && (
<div style={{ marginBottom: 8 }}>
<Tag color={detailRoom.tenantColor}>
{detailRoom.occupants[0]?.tenantName || '租户'}
</Tag>
</div>
)}
<div style={{ marginBottom: 16 }}>{getStatusLabel(detailRoom)}</div>
{detailRoom.occupants.length > 0 ? (
<div>

View File

@@ -14,6 +14,7 @@ import {
Badge,
Upload,
} from 'antd';
import type { UploadRequestError, UploadRequestOption } from '@rc-component/upload/lib/interface';
import {
PlusOutlined,
UploadOutlined,
@@ -190,6 +191,22 @@ const RoomsPage: React.FC = () => {
{ title: '楼栋', dataIndex: 'building' },
{ title: '楼层', dataIndex: 'floor' },
{ title: '类型', dataIndex: 'roomType', render: (v: any) => v || '-' },
{
title: '租赁类型',
dataIndex: 'rentalCategory',
width: 80,
render: (v: string) => {
if (v === 'long') return <Tag color="blue"></Tag>;
if (v === 'short') return <Tag color="green"></Tag>;
return '-';
},
},
{
title: '月租金',
dataIndex: 'monthlyRate',
width: 80,
render: (v: number) => (v ? `¥${v}` : '-'),
},
{ title: '额定人数', dataIndex: 'capacity' },
{
title: '当前入住',
@@ -333,17 +350,25 @@ const RoomsPage: React.FC = () => {
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
customRequest={async (options: UploadRequestOption<{ message?: string }>) => {
const { file, onSuccess, onError } = options;
if (typeof file === 'string') {
message.error('不支持字符串文件');
return;
}
try {
const res: any = await api.post('/rooms/import', formData, {
const formData = new FormData();
formData.append('file', file);
const res = await api.post<{ message?: string }>('/rooms/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success(res.message);
message.success(res.message || '导入成功');
onSuccess?.(res);
fetchData();
} catch (e: any) {
message.error(e?.message || '导入失败');
onError?.(e);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '导入失败');
onError?.(e as UploadRequestError);
}
}}
>

View File

@@ -348,7 +348,7 @@ const SchedulesPage: React.FC = () => {
>
</th>
{WEEKDAYS.map((day, idx) => (
{WEEKDAYS.map((day) => (
<th
key={day}
style={{

View File

@@ -11,6 +11,7 @@ import {
Tag,
Popconfirm,
Upload,
App,
} from 'antd';
import {
PlusOutlined,
@@ -20,10 +21,21 @@ import {
InboxOutlined,
ExportOutlined,
DeleteOutlined,
EyeOutlined,
} from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const maskPhone = (phone: string) => {
if (!phone || phone.length < 7) return phone || '-';
return phone.slice(0, 3) + '****' + phone.slice(-4);
};
const maskIdNumber = (id: string) => {
if (!id || id.length < 8) return id || '-';
return id.slice(0, 3) + '***********' + id.slice(-4);
};
const statusMap: Record<string, { text: string; color: string }> = {
active: { text: '在读', color: 'green' },
graduated: { text: '已毕业', color: 'blue' },
@@ -32,9 +44,11 @@ const statusMap: Record<string, { text: string; color: string }> = {
};
const StudentsPage: React.FC = () => {
const { modal } = App.useApp();
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [tenants, setTenants] = useState<any[]>([]);
const [editing, setEditing] = useState<any>(null);
const [searchName, setSearchName] = useState('');
const [showArchived, setShowArchived] = useState(false);
@@ -42,6 +56,29 @@ const StudentsPage: React.FC = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [form] = Form.useForm();
const handleViewSensitive = (studentId: number, field: string, value: string) => {
modal.confirm({
title: '查看敏感信息',
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
okText: '确认查看',
cancelText: '取消',
onOk: async () => {
api.post('/operation-logs/audit', {
module: '学生管理',
action: '查看敏感信息',
targetId: studentId,
targetType: 'student',
detail: `查看${field}`,
}).catch(() => {});
modal.info({
title: field,
content: value,
okText: '关闭',
});
},
});
};
const handleBatchDelete = async () => {
try {
const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys });
@@ -72,6 +109,12 @@ const StudentsPage: React.FC = () => {
fetchData();
}, [searchName, showArchived]);
useEffect(() => {
api.get('/tenants', { params: { includeArchived: 'false' } }).then((res: unknown) => {
setTenants(res as Array<{ id: number; name: string }>);
}).catch(() => {});
}, []);
const handleSave = async () => {
const values = await form.validateFields();
try {
@@ -152,7 +195,23 @@ const StudentsPage: React.FC = () => {
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '姓名', dataIndex: 'name', ellipsis: true },
{ title: '性别', dataIndex: 'gender', width: 60 },
{ title: '电话', dataIndex: 'phone', ellipsis: true },
{
title: '电话',
dataIndex: 'phone',
width: 140,
ellipsis: true,
render: (v: string, record: any) => {
if (!v) return '-';
return (
<span>
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
<a onClick={() => handleViewSensitive(record.id, '电话', v)} title="点击查看完整号码">
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
</span>
);
},
},
{
title: '学号',
dataIndex: 'studentNumber',
@@ -165,15 +224,26 @@ const StudentsPage: React.FC = () => {
dataIndex: 'idNumber',
width: 180,
ellipsis: true,
render: (v: string) => v || '-',
render: (v: string, record: any) => {
if (!v) return '-';
return (
<span>
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
<a onClick={() => handleViewSensitive(record.id, '身份证号', v)} title="点击查看完整号码">
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
</span>
);
},
},
{ title: '民族', dataIndex: 'ethnicity', width: 80 },
{ title: '紧急联系人', dataIndex: 'emergencyContact', ellipsis: true },
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', ellipsis: true },
{
title: '所属机构',
dataIndex: 'organization',
render: (v: string) => (v ? <Tag color="purple">{v}</Tag> : '-'),
dataIndex: 'tenant',
render: (tenant: { name?: string } | null) =>
tenant?.name ? <Tag color="purple">{tenant.name}</Tag> : '-',
},
{ title: '负责人', dataIndex: 'supervisor', ellipsis: true },
{
@@ -369,11 +439,18 @@ const StudentsPage: React.FC = () => {
<Input />
</Form.Item>
<Form.Item
name="organization"
name="tenantId"
label="所属机构"
tooltip="外部合作公司/机构名称,留空表示本机构"
tooltip="选择租赁方,留空表示本机构"
>
<Input placeholder="如XXX教育科技公司" />
<Select
allowClear
placeholder="选择租赁方"
options={tenants.map((t: { id: number; name: string }) => ({
value: t.id,
label: t.name,
}))}
/>
</Form.Item>
<Form.Item name="supervisor" label="负责人/班主任">
<Input />

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, Input, Space, message, Tag, Popconfirm } from 'antd';
import { Table, Modal, Form, Input, Space, message, Tag, Popconfirm } from 'antd';
import { PlusOutlined, InboxOutlined } from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
@@ -12,7 +11,7 @@ import {
Popconfirm,
message,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
@@ -25,9 +24,31 @@ const UsersPage: React.FC = () => {
const [pwdModalOpen, setPwdModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [resetTarget, setResetTarget] = useState<any>(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [profileUser, setProfileUser] = useState<any>(null);
const [profileForm] = Form.useForm();
const [form] = Form.useForm();
const [pwdForm] = Form.useForm();
const handleOpenProfile = async (record: any) => {
setProfileUser(record);
try {
const res: any = await api.get(`/rbac/users/${record.id}/profile`);
profileForm.setFieldsValue(res);
} catch {
profileForm.setFieldsValue({});
}
setProfileModalOpen(true);
};
const handleProfileSubmit = async () => {
const values = await profileForm.validateFields();
await api.put(`/rbac/users/${profileUser.id}/profile`, values);
message.success('档案更新成功');
setProfileModalOpen(false);
fetchData();
};
const fetchData = async () => {
setLoading(true);
try {
@@ -157,10 +178,19 @@ const UsersPage: React.FC = () => {
},
{
title: '操作',
width: 220,
width: 280,
fixed: 'right' as const,
render: (_: any, record: any) => (
<Space>
<PermissionButton
permission="user:edit"
type="link"
size="small"
icon={<IdcardOutlined />}
onClick={() => handleOpenProfile(record)}
>
</PermissionButton>
<PermissionButton
permission="user:edit"
type="link"
@@ -290,7 +320,44 @@ const UsersPage: React.FC = () => {
</Form.Item>
</Form>
</Modal>
</div>
<Modal
title={`教师档案 - ${profileUser?.name || profileUser?.username}`}
open={profileModalOpen}
onOk={handleProfileSubmit}
onCancel={() => setProfileModalOpen(false)}
destroyOnHidden
>
<Form form={profileForm} layout="vertical">
<Form.Item name="joinedAt" label="入职日期">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="qualifications" label="资质">
<Input.TextArea placeholder="教师资格证号、学历等" rows={2} />
</Form.Item>
<Form.Item
name="subjects"
label="任教学科"
>
<Select
mode="tags"
placeholder="输入学科后回车添加"
options={[
{ value: '语文', label: '语文' },
{ value: '数学', label: '数学' },
{ value: '英语', label: '英语' },
{ value: '政治', label: '政治' },
{ value: '历史', label: '历史' },
{ value: '地理', label: '地理' },
{ value: '物理', label: '物理' },
{ value: '化学', label: '化学' },
{ value: '生物', label: '生物' },
]}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
};