fix: resolve all admin typecheck errors (typed api wrapper, unused imports, missing hooks, PermissionButton children)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
interface Notification {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
DatePicker,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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%' }} />
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -348,7 +348,7 @@ const SchedulesPage: React.FC = () => {
|
||||
>
|
||||
教室
|
||||
</th>
|
||||
{WEEKDAYS.map((day, idx) => (
|
||||
{WEEKDAYS.map((day) => (
|
||||
<th
|
||||
key={day}
|
||||
style={{
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
1377
docs/superpowers/plans/2026-07-05-multi-campus.md
Normal file
1377
docs/superpowers/plans/2026-07-05-multi-campus.md
Normal file
File diff suppressed because it is too large
Load Diff
1273
docs/superpowers/plans/2026-07-05-notification-center.md
Normal file
1273
docs/superpowers/plans/2026-07-05-notification-center.md
Normal file
File diff suppressed because it is too large
Load Diff
340
docs/superpowers/specs/2026-07-05-multi-campus-design.md
Normal file
340
docs/superpowers/specs/2026-07-05-multi-campus-design.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# 多校区切换/隔离 — 设计规格
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-07-05
|
||||
> 基于 PRD:`PRD-恭学教育学生管理系统.md` §23.7(多校区隔离确认需要,Department.type=campus 预留)+
|
||||
> §1.2(角色数据范围:超管全部 / 教职工指定部门+子部门 / 班主任本班 / 学生本人)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为系统引入多校区(Campus)概念,实现:
|
||||
- 校区树形组织结构(校区 → 子部门 → 班级)
|
||||
- 用户-部门绑定 + 默认校区
|
||||
- 全局数据查询按校区自动隔离
|
||||
- 前端校区切换器(支持单校区 / 全部校区视图)
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 部门表 `departments`
|
||||
|
||||
```sql
|
||||
departments
|
||||
├── id INTEGER PK AUTOINCREMENT
|
||||
├── name VARCHAR(100) NOT NULL -- 部门名称,如 "鼓楼校区"
|
||||
├── parent_id INTEGER NULLABLE FK → self -- 上级部门,NULL = 顶层校区
|
||||
├── type VARCHAR(20) DEFAULT 'department'
|
||||
-- 'campus' = 校区(顶层,parent_id=NULL)
|
||||
-- 'department' = 子部门(教学部、后勤部等)
|
||||
├── sort_order INTEGER DEFAULT 0
|
||||
├── status VARCHAR(20) DEFAULT 'active' -- active / archived
|
||||
├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
├── updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
```
|
||||
|
||||
### 2.2 用户-部门关联 `user_departments`
|
||||
|
||||
```sql
|
||||
user_departments
|
||||
├── id INTEGER PK AUTOINCREMENT
|
||||
├── user_id INTEGER NOT NULL FK → users.id
|
||||
├── department_id INTEGER NOT NULL FK → departments.id
|
||||
├── is_default BOOLEAN DEFAULT false
|
||||
├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
UNIQUE(user_id, department_id)
|
||||
```
|
||||
|
||||
超管不在此表有记录 → 默认全部数据可见。学生不发此关联,通过 `students.department_id` 表所属部门。
|
||||
|
||||
### 2.3 现有实体追加 `department_id`
|
||||
|
||||
采用**冗余存储**策略(写入时填入,避免查询时多表 JOIN):
|
||||
|
||||
| 实体 | 操作 | 说明 |
|
||||
|------|:---:|------|
|
||||
| `students` | 新增 | 学生所属部门 |
|
||||
| `classes` | 保留 | 已有 `department_id` 字段 |
|
||||
| `rooms` | 新增 | 宿舍归属校区 |
|
||||
| `classrooms` | 新增 | 教室归属校区 |
|
||||
| `class_schedules` | 新增 | 冗余加速(也可从 class→department 间接获取) |
|
||||
| `attendance_records` | 新增 | 冗余加速(也可从 student→department 间接获取) |
|
||||
| `room_expenses` | 新增 | 冗余加速(也可从 room→department 间接获取) |
|
||||
| `personal_expenses` | 新增 | 冗余加速(也可从 student→department 间接获取) |
|
||||
| `occupancies` | 新增 | 冗余加速(也可从 room→department 间接获取) |
|
||||
| `bills` | 新增 | 冗余加速(也可从 student→department 间接获取) |
|
||||
| `deposits` | 新增 | 冗余加速(也可从 student→department 间接获取) |
|
||||
| `deposit_installments` | 新增 | 冗余加速 |
|
||||
| `classroom_rentals` | 新增 | 冗余加速(也可从 classroom→department 间接获取) |
|
||||
|
||||
**全局共享不隔离**:`users`、`roles`、`permissions`、`tenants`、`operation_logs`、`notifications`、`sync_logs`、`sync_states`、`ding_attendance_raw`、`expense_types`。
|
||||
|
||||
## 3. 后端隔离机制
|
||||
|
||||
### 3.1 JWT Payload 扩展
|
||||
|
||||
```typescript
|
||||
// 登录时注入
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
permissions,
|
||||
isSuperAdmin: user.roles?.some(r => r.name === 'super_admin'),
|
||||
// 不再注入 departmentIds,改为请求级 CampusScope 实时查询
|
||||
};
|
||||
```
|
||||
|
||||
不将 `departmentIds` 写入 JWT,避免校区分配变更后需重新登录。
|
||||
|
||||
### 3.2 CampusScope(请求级 Provider)
|
||||
|
||||
```typescript
|
||||
// apps/server/src/common/campus-scope.ts
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class CampusScope {
|
||||
private _departmentIds: number[] | null = null;
|
||||
currentDepartmentId: number | null;
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST) private req: any,
|
||||
private departmentsService: DepartmentsService,
|
||||
) {
|
||||
this.currentDepartmentId = parseInt(
|
||||
req.headers['x-campus-id'] || '0'
|
||||
) || null;
|
||||
}
|
||||
|
||||
get isSuperAdmin(): boolean {
|
||||
return this.req.user?.isSuperAdmin ?? false;
|
||||
}
|
||||
|
||||
/** 获取当前用户可访问的所有部门 ID(含子部门) */
|
||||
async getDepartmentIds(): Promise<number[]> {
|
||||
if (this._departmentIds) return this._departmentIds;
|
||||
const userDepts = await this.departmentsService.getUserDepartments(
|
||||
this.req.user.id
|
||||
);
|
||||
this._departmentIds = userDepts;
|
||||
return this._departmentIds;
|
||||
}
|
||||
|
||||
/** 对 TypeORM find 条件追加校区过滤 */
|
||||
async filter<T extends Record<string, any>>(where: T): Promise<T> {
|
||||
if (this.isSuperAdmin && !this.currentDepartmentId) return where;
|
||||
const ids = await this.getEffectiveScopeIds();
|
||||
return { ...where, departmentId: In(ids) } as any;
|
||||
}
|
||||
|
||||
private async getEffectiveScopeIds(): Promise<number[]> {
|
||||
// 选择了具体校区 → 该校区 + 所有子部门
|
||||
// 未选 → 用户所有可访问部门 + 子部门
|
||||
const baseId = this.currentDepartmentId;
|
||||
if (baseId) {
|
||||
return this.departmentsService.getDescendantIds(baseId);
|
||||
}
|
||||
const allIds = await this.getDepartmentIds();
|
||||
const expanded = await Promise.all(
|
||||
allIds.map(id => this.departmentsService.getDescendantIds(id))
|
||||
);
|
||||
return [...new Set(expanded.flat())];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Controller 使用模式
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
async findAll(@Req() req: Request) {
|
||||
const scope = req.campusScope; // 由 middleware 注入
|
||||
const where = await scope.filter({ status: 'active' });
|
||||
return this.repo.find({ where, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
```
|
||||
|
||||
超管不传 `X-Campus-Id` → `filter()` 原样返回 → 不做隔离。
|
||||
|
||||
### 3.4 校区选择 Header
|
||||
|
||||
前端 axios interceptor 注入:
|
||||
|
||||
```typescript
|
||||
api.interceptors.request.use((config) => {
|
||||
const campusId = localStorage.getItem('currentCampusId');
|
||||
if (campusId) {
|
||||
config.headers['X-Campus-Id'] = campusId;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
### 3.5 部门管理 CRUD
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
|------|------|:---:|------|
|
||||
| GET | `/departments` | JWT | 部门列表(树形结构,按 sort_order + name) |
|
||||
| GET | `/departments/:id` | JWT | 部门详情 |
|
||||
| POST | `/departments` | JWT | 创建部门(指定 parent_id + type) |
|
||||
| PUT | `/departments/:id` | JWT | 编辑部门 |
|
||||
| DELETE | `/departments/:id` | JWT | 删除部门(检查无子部门+无关联用户) |
|
||||
| GET | `/departments/:id/users` | JWT | 部门下关联的用户列表 |
|
||||
| POST | `/departments/:id/users` | JWT | 为用户分配部门 `{ userId, isDefault? }` |
|
||||
| DELETE | `/departments/:id/users/:userId` | JWT | 移除用户-部门关联 |
|
||||
| GET | `/departments/tree` | JWT | 树形数据(前端级联选择器用) |
|
||||
|
||||
### 3.6 写操作时 department_id 填充
|
||||
|
||||
新建宿舍时:
|
||||
```typescript
|
||||
async create(dto: CreateRoomDto) {
|
||||
// department_id 由前端传入(校区选择器当前选中值)
|
||||
return this.repo.save({ ...dto, departmentId: dto.departmentId });
|
||||
}
|
||||
```
|
||||
|
||||
新建学生时关联班级的 department:
|
||||
```typescript
|
||||
async create(dto: CreateStudentDto) {
|
||||
const cls = await this.classesRepo.findOne({ where: { id: dto.classId } });
|
||||
return this.studentRepo.save({
|
||||
...dto,
|
||||
departmentId: cls?.departmentId,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 前端
|
||||
|
||||
### 4.1 校区选择器 `CampusSwitcher`
|
||||
|
||||
Header 左侧,Logo 旁边:
|
||||
|
||||
```
|
||||
[ 恭学教育 ] [ 鼓楼校区 ▾ ]
|
||||
```
|
||||
|
||||
- `Select` 组件,列出当前用户可访问的校区(从 JWT payload 或 `/departments` API 获取)
|
||||
- 切换时:`localStorage.setItem('currentCampusId', id)` + 刷新所有页面数据
|
||||
- 多校区权限用户底部显示「全部校区」选项(value 为空字符串)
|
||||
- 仅一个校区 → 纯文本展示,不可切换
|
||||
- 默认选中 `localStorage.getItem('currentCampusId')` 或用户默认校区
|
||||
|
||||
### 4.2 `useCampus` Hook
|
||||
|
||||
```typescript
|
||||
function useCampus() {
|
||||
const [campuses, setCampuses] = useState<Department[]>([]);
|
||||
const [currentId, setCurrentId] = useState<string>(
|
||||
() => localStorage.getItem('currentCampusId') || ''
|
||||
);
|
||||
|
||||
const switchCampus = (id: string) => {
|
||||
setCurrentId(id);
|
||||
localStorage.setItem('currentCampusId', id);
|
||||
// 触发全局数据刷新(通过 event 或 context)
|
||||
window.dispatchEvent(new CustomEvent('campus-changed', { detail: id }));
|
||||
};
|
||||
|
||||
return { campuses, currentId, switchCampus };
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 部门管理页面 `Departments/index.tsx`
|
||||
|
||||
- 左侧 `Tree` 组件展示部门树
|
||||
- 点击节点 → 右侧表单编辑部门信息
|
||||
- 右键/操作按钮:添加子部门、编辑、删除
|
||||
- 「部门成员」Tab:用户列表 + 分配/移除
|
||||
|
||||
### 4.4 数据面板适配
|
||||
|
||||
选中「全部校区」时:
|
||||
- 统计卡片数值为各校区汇总
|
||||
- 图表按校区分组(图例标注校区名)
|
||||
- 不选全部校区 → 仅显示当前校区数据
|
||||
|
||||
## 5. 文件结构
|
||||
|
||||
```
|
||||
apps/server/src/
|
||||
├── entities/
|
||||
│ ├── department.entity.ts 🆕
|
||||
│ ├── user-department.entity.ts 🆕
|
||||
│ ├── student.entity.ts ✏️ +department_id
|
||||
│ ├── room.entity.ts ✏️ +department_id
|
||||
│ ├── classroom.entity.ts ✏️ +department_id
|
||||
│ ├── class-schedule.entity.ts ✏️ +department_id
|
||||
│ ├── attendance-record.entity.ts ✏️ +department_id
|
||||
│ ├── room-expense.entity.ts ✏️ +department_id
|
||||
│ ├── personal-expense.entity.ts ✏️ +department_id
|
||||
│ ├── occupancy.entity.ts ✏️ +department_id
|
||||
│ ├── bill.entity.ts ✏️ +department_id
|
||||
│ ├── deposit.entity.ts ✏️ +department_id
|
||||
│ ├── deposit-installment.entity.ts ✏️ +department_id
|
||||
│ ├── classroom-rental.entity.ts ✏️ +department_id
|
||||
│ └── index.ts ✏️
|
||||
├── departments/ 🆕
|
||||
│ ├── departments.module.ts
|
||||
│ ├── departments.controller.ts
|
||||
│ ├── departments.service.ts
|
||||
│ └── dto/
|
||||
│ └── department.dto.ts
|
||||
├── common/
|
||||
│ └── campus-scope.ts 🆕
|
||||
├── auth/
|
||||
│ ├── auth.service.ts ✏️ 登录注入 isSuperAdmin
|
||||
│ └── strategies/jwt.strategy.ts ✏️ payload 扩展
|
||||
├── students/
|
||||
│ └── students.service.ts ✏️ create 时填充 department_id
|
||||
├── rooms/
|
||||
│ └── rooms.service.ts ✏️ create 时填充 department_id
|
||||
├── ...(各 service 使用 scope.filter())
|
||||
└── app.module.ts ✏️ 注册 DepartmentsModule + CampusScope
|
||||
|
||||
apps/admin/src/
|
||||
├── pages/
|
||||
│ └── Departments/
|
||||
│ └── index.tsx 🆕 部门管理页
|
||||
├── components/
|
||||
│ └── CampusSwitcher.tsx 🆕
|
||||
├── api/index.ts ✏️ interceptor 加 X-Campus-Id
|
||||
├── hooks/
|
||||
│ └── useCampus.ts 🆕
|
||||
├── layouts/
|
||||
│ └── MainLayout.tsx ✏️ 挂载 CampusSwitcher
|
||||
└── App.tsx ✏️ 注册 /departments 路由
|
||||
```
|
||||
|
||||
## 6. 数据库迁移
|
||||
|
||||
### 新增表
|
||||
|
||||
`departments`、`user_departments` — TypeORM `synchronize: true` 自动建表。
|
||||
|
||||
### 现有表 ALTER
|
||||
|
||||
```sql
|
||||
ALTER TABLE students ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE rooms ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE classrooms ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE class_schedules ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE attendance_records ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE room_expenses ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE personal_expenses ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE occupancies ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE bills ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE deposits ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE deposit_installments ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE classroom_rentals ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
```
|
||||
|
||||
### 数据回填
|
||||
|
||||
1. 创建默认校区 "主校区"(`departments` type=campus)
|
||||
2. 所有现有数据的 `department_id` 回填为默认校区 ID
|
||||
3. 现有用户全部关联到默认校区(`user_departments`)
|
||||
|
||||
## 7. 钉钉同步集成
|
||||
|
||||
钉钉组织架构拉取已有能力(PRD §19.1),同步时将钉钉部门树映射到 `departments` 表:
|
||||
- 根部门 → `type = 'campus'`
|
||||
- 子部门 → `type = 'department'`
|
||||
- 同步时维护 `parent_id` 树结构
|
||||
233
docs/superpowers/specs/2026-07-05-notification-center-design.md
Normal file
233
docs/superpowers/specs/2026-07-05-notification-center-design.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# 站内信通知中心 — 设计规格
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-07-05
|
||||
> 基于 PRD:`PRD-恭学教育学生管理系统.md` §23.7(通知中心确认需要) + §12.3(账单通知推送 P2)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为系统全部角色(超管、教职工、班主任、学生)提供统一的站内信通知中心,支撑以下业务场景的实时通知,同时预留钉钉/企微外发扩展点。
|
||||
|
||||
## 2. 通知场景
|
||||
|
||||
| 场景 | 触发方 | 通知类型 | 接收方 |
|
||||
|------|--------|----------|--------|
|
||||
| 账单生成 | 系统/管理员 | `bill_generated` | 学生 + 财务 |
|
||||
| 账单确认/已付 | 管理员 | `bill_paid` | 学生 + 宿管 |
|
||||
| 入住登记 | 宿管 | `check_in` | 宿管 + 学生 |
|
||||
| 退宿 | 宿管 | `check_out` | 宿管 + 学生 |
|
||||
| 押金催缴 | 财务 | `deposit_due` | 学生 + 财务 |
|
||||
| 押金退还 | 财务 | `deposit_refunded` | 学生 + 财务 |
|
||||
| 班级学员增减 | 教务 | `class_change` | 班主任 |
|
||||
| 班级教师调整 | 教务 | `class_change` | 相关教师 |
|
||||
| 排课冲突 | 系统检测 | `schedule_conflict` | 教务 |
|
||||
| 系统公告 | 管理员手动 | `announcement` | 全员/指定角色 |
|
||||
|
||||
## 3. 技术方案
|
||||
|
||||
**SSE (Server-Sent Events) 推送 + 轮询兜底。**
|
||||
|
||||
- NestJS 原生 `@Sse()` + RxJS `Observable`
|
||||
- 前端 `EventSource` 建立长连接,断开时自动重连
|
||||
- 重连间隙兜底轮询 `GET /notifications/unread-count`(60s 间隔)
|
||||
- 钉钉/企微外发通过 EventEmitter2 异步解耦
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
```sql
|
||||
notifications
|
||||
├── id INTEGER PK AUTOINCREMENT
|
||||
├── recipient_id INTEGER NOT NULL -- FK → users.id
|
||||
├── type VARCHAR(30) NOT NULL -- bill_generated | bill_paid | check_in | check_out |
|
||||
-- deposit_due | deposit_refunded | class_change |
|
||||
-- schedule_conflict | announcement
|
||||
├── title VARCHAR(200) NOT NULL -- 通知标题
|
||||
├── content TEXT -- 通知正文(支持模板变量)
|
||||
├── link VARCHAR(500) NULLABLE -- 点击跳转路径,如 /bills/123
|
||||
├── is_read BOOLEAN DEFAULT false
|
||||
├── read_at DATETIME NULLABLE
|
||||
├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
```
|
||||
|
||||
设计决策:
|
||||
- **一通知一接收方** — 同一事件对 N 个用户各建一条记录,避免 `is_read` 共享状态。
|
||||
- **无软删除** — 通知不可删除(保留审计痕迹),支持"全部已读"。
|
||||
- **cursor-based 分页** — `?after=<id>&limit=20`,适合实时追加场景。
|
||||
|
||||
## 5. 后端模块
|
||||
|
||||
### 5.1 文件结构
|
||||
|
||||
```
|
||||
apps/server/src/
|
||||
├── entities/
|
||||
│ └── notification.entity.ts 🆕
|
||||
├── notifications/ 🆕
|
||||
│ ├── notifications.module.ts
|
||||
│ ├── notifications.controller.ts
|
||||
│ ├── notifications.service.ts
|
||||
│ └── dto/
|
||||
│ └── notification.dto.ts
|
||||
└── app.module.ts ✏️ 注册 NotificationsModule
|
||||
```
|
||||
|
||||
### 5.2 API
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
|------|------|:---:|------|
|
||||
| GET | `/notifications` | JWT | 当前用户通知列表(cursor 分页,`?after=&limit=20`) |
|
||||
| GET | `/notifications/unread-count` | JWT | `{ count: number }` |
|
||||
| GET | `/notifications/stream` | JWT | SSE 端点,`text/event-stream` |
|
||||
| PUT | `/notifications/:id/read` | JWT | 标记单条已读 |
|
||||
| PUT | `/notifications/read-all` | JWT | 当前用户全部已读 |
|
||||
|
||||
### 5.3 Service 接口
|
||||
|
||||
```typescript
|
||||
class NotificationsService {
|
||||
create(dto: CreateNotificationDto): Promise<Notification>;
|
||||
findByUser(userId: number, after?: number, limit?: number): Promise<Notification[]>;
|
||||
getUnreadCount(userId: number): Promise<number>;
|
||||
markRead(id: number, userId: number): Promise<void>;
|
||||
markAllRead(userId: number): Promise<void>;
|
||||
subscribe(userId: number): Observable<Notification>; // SSE
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 SSE 实现要点
|
||||
|
||||
- Controller 使用 `@Sse('stream')` + `@Req()` 获取 `req.user.id`
|
||||
- Service 内部维护 `Map<userId, Subject<Notification>>`
|
||||
- `create()` 方法写入 DB 后 → `subject.next(notification)` 推送给订阅者
|
||||
- 用户断开连接时清理 Subject
|
||||
|
||||
### 5.5 业务模块集成模式
|
||||
|
||||
各业务 Controller 写操作完成后调用:
|
||||
|
||||
```typescript
|
||||
this.notificationsService.create({
|
||||
recipientIds: [studentUserId, financeUserIds],
|
||||
type: 'bill_generated',
|
||||
title: '账单已生成',
|
||||
content: `您的 ${periodLabel} 账单已生成,总额 ¥${totalAmount}`,
|
||||
link: `/bills/${billId}`,
|
||||
});
|
||||
```
|
||||
|
||||
钉钉/企微外发通过 `EventEmitter2` 解耦:
|
||||
|
||||
```typescript
|
||||
this.eventEmitter.emit('notification.created', notification);
|
||||
```
|
||||
|
||||
## 6. 前端
|
||||
|
||||
### 6.1 文件结构
|
||||
|
||||
```
|
||||
apps/admin/src/
|
||||
├── pages/
|
||||
│ └── Notifications/
|
||||
│ └── index.tsx 🆕 通知全屏页
|
||||
├── components/
|
||||
│ └── NotificationBell.tsx 🆕 Header 铃铛组件
|
||||
├── hooks/
|
||||
│ └── useNotifications.ts 🆕 SSE 连接 + 未读计数
|
||||
└── layouts/
|
||||
└── MainLayout.tsx ✏️ 挂载 NotificationBell + SSE hook
|
||||
```
|
||||
|
||||
### 6.2 Header 铃铛
|
||||
|
||||
- `Badge` 组件显示未读数(count > 99 显示 "99+")
|
||||
- 点击展开 `Popover`(宽 380px,高 480px)
|
||||
- Popover 内容:
|
||||
- 头部:"通知中心" + "全部已读" `Button`
|
||||
- 列表:虚拟滚动,未读条目左侧蓝点
|
||||
- 点击条目 → `api.put(/notifications/${id}/read)` + `navigate(link)`
|
||||
- 底部 "查看全部 →" → `/notifications`
|
||||
- 空状态:"暂无通知" 插画
|
||||
|
||||
### 6.3 全屏通知页 `/notifications`
|
||||
|
||||
- 左侧类型筛选 `Menu`(全部/账单/入住/班级/系统)
|
||||
- 右侧通知列表 + `InfiniteScroll`
|
||||
- 列表项:类型图标 + 标题 + 内容摘要 + 时间(相对时间 "3分钟前")
|
||||
- 点击条目 → 标已读 + 跳转 `link`
|
||||
|
||||
### 6.4 SSE Hook (`useNotifications`)
|
||||
|
||||
```typescript
|
||||
function useNotifications() {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const es = new EventSource(`/api/notifications/stream?token=${token}`);
|
||||
|
||||
es.onmessage = (event) => {
|
||||
const notification = JSON.parse(event.data);
|
||||
setUnreadCount((c) => c + 1);
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
// SSE 断开,切换到轮询兜底
|
||||
const interval = setInterval(async () => {
|
||||
const { count } = await api.get('/notifications/unread-count');
|
||||
setUnreadCount(count);
|
||||
}, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
};
|
||||
|
||||
return () => es.close();
|
||||
}, []);
|
||||
|
||||
return { unreadCount };
|
||||
}
|
||||
```
|
||||
|
||||
SSE 认证:URL query 传 JWT token(EventSource 不支持自定义 header)。
|
||||
|
||||
## 7. SSE 认证与 Nginx 配置
|
||||
|
||||
### 7.1 后端 Guard 适配
|
||||
|
||||
`JwtAuthGuard` 需支持从 query string 提取 token(当前仅从 `Authorization` header):
|
||||
|
||||
```typescript
|
||||
// 在 canActivate 中增加 fallback
|
||||
const token = extractFromHeader(request) || request.query?.token;
|
||||
```
|
||||
|
||||
### 7.2 Nginx 配置
|
||||
|
||||
SSE 长连接需关闭对该路径的 proxy buffering:
|
||||
|
||||
```nginx
|
||||
location /api/notifications/stream {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 数据库迁移
|
||||
|
||||
TypeORM `synchronize: true` 自动建表。Entity 注册在 `apps/server/src/entities/index.ts`,`AppModule` 中 `TypeOrmModule.forFeature([Notification])`。
|
||||
|
||||
## 9. 钉钉/企微外发(预留)
|
||||
|
||||
- `NotificationsService.create()` 后 emit `notification.created` 事件
|
||||
- 钉钉模块(`apps/server/src/sync/` 下已有工作通知能力)监听该事件
|
||||
- 根据 `notification.type` 判断是否外发(如 `bill_generated` 发钉钉,`announcement` 仅站内信)
|
||||
- 外发失败不影响站内信记录,日志告警即可
|
||||
|
||||
## 10. 扩展点(学生端未来接入)
|
||||
|
||||
- 学生端前端独立部署时,复用同一套 API(JWT 认证统一)
|
||||
- `link` 字段路径由前端根据当前角色拼接 base path
|
||||
- 通知类型枚举预留 `student_*` 前缀扩展
|
||||
Reference in New Issue
Block a user