From 6b0a21d2662da630e879adbdf50c464437e64f44 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 6 Jul 2026 01:02:26 +0800 Subject: [PATCH] fix: resolve all admin typecheck errors (typed api wrapper, unused imports, missing hooks, PermissionButton children) --- apps/admin/src/api/index.ts | 28 +- apps/admin/src/hooks/useNotifications.ts | 2 +- apps/admin/src/layouts/MainLayout.tsx | 1 + apps/admin/src/pages/Attendance/index.tsx | 665 ++++++-- apps/admin/src/pages/Bills/index.tsx | 1 - apps/admin/src/pages/Classes/index.tsx | 5 +- .../src/pages/ClassroomSchedule/index.tsx | 26 +- apps/admin/src/pages/Dashboard/index.tsx | 35 +- apps/admin/src/pages/Deposits/index.tsx | 22 +- apps/admin/src/pages/Expenses/index.tsx | 73 +- apps/admin/src/pages/Occupancies/index.tsx | 4 +- apps/admin/src/pages/Roles/index.tsx | 1 - apps/admin/src/pages/RoomVisual/index.tsx | 31 +- apps/admin/src/pages/Rooms/index.tsx | 37 +- apps/admin/src/pages/Schedules/index.tsx | 2 +- apps/admin/src/pages/Students/index.tsx | 91 +- apps/admin/src/pages/Tenants/index.tsx | 2 +- apps/admin/src/pages/Users/index.tsx | 75 +- .../plans/2026-07-05-multi-campus.md | 1377 +++++++++++++++++ .../plans/2026-07-05-notification-center.md | 1273 +++++++++++++++ .../specs/2026-07-05-multi-campus-design.md | 340 ++++ .../2026-07-05-notification-center-design.md | 233 +++ 22 files changed, 4051 insertions(+), 273 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-05-multi-campus.md create mode 100644 docs/superpowers/plans/2026-07-05-notification-center.md create mode 100644 docs/superpowers/specs/2026-07-05-multi-campus-design.md create mode 100644 docs/superpowers/specs/2026-07-05-notification-center-design.md diff --git a/apps/admin/src/api/index.ts b/apps/admin/src/api/index.ts index 6e14396..f67df41 100644 --- a/apps/admin/src/api/index.ts +++ b/apps/admin/src/api/index.ts @@ -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 = ( + method: string, + url: string, + data?: unknown, + config?: AxiosRequestConfig, +): Promise => instance.request({ ...config, method, url, data }) as Promise; + +const api = { + get: (url: string, config?: AxiosRequestConfig): Promise => + request('get', url, undefined, config), + post: (url: string, data?: unknown, config?: AxiosRequestConfig): Promise => + request('post', url, data, config), + put: (url: string, data?: unknown, config?: AxiosRequestConfig): Promise => + request('put', url, data, config), + patch: (url: string, data?: unknown, config?: AxiosRequestConfig): Promise => + request('patch', url, data, config), + delete: (url: string, config?: AxiosRequestConfig): Promise => + request('delete', url, undefined, config), +}; + export default api; diff --git a/apps/admin/src/hooks/useNotifications.ts b/apps/admin/src/hooks/useNotifications.ts index cc2dd7c..34aca03 100644 --- a/apps/admin/src/hooks/useNotifications.ts +++ b/apps/admin/src/hooks/useNotifications.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import api from '../api'; interface Notification { diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 72e09d4..b944de1 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -266,6 +266,7 @@ const MainLayout: React.FC = () => { ) } onClick={() => (isMobile || isTablet ? setDrawerOpen(true) : setCollapsed(!collapsed))} + /> {isDesktop && }
diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index f247eb1..7339870 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -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 = { + 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 = { 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([]); + const [dingLoading, setDingLoading] = useState(false); + const [dingPage, setDingPage] = useState(1); + const [dingPageSize, setDingPageSize] = useState(20); + const [dingTotal, setDingTotal] = useState(0); + const [dingMatchStatus, setDingMatchStatus] = useState(undefined); + + // Match modal + const [matchModalOpen, setMatchModalOpen] = useState(false); + const [matchRecordId, setMatchRecordId] = useState(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([]); @@ -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('/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 = { 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 ? {item.text} : v; + }, + }, + { + title: '操作', + key: 'actions', + width: 80, + render: (_: unknown, record: DingRecord) => { + if (record.matchStatus === 'matched') return -; + return ( + + ); + }, + }, + ], + [openMatchModal], + ); + // ── Table columns ── const columns = useMemo( () => [ @@ -391,172 +577,264 @@ const AttendancePage: React.FC = () => { // ── Render ── return (
- {/* ── Filter bar ── */} - - - - { - setFilterSession(v); - setPage(1); - }} - options={SESSION_OPTIONS} - /> - - - { - setFilterSource(v); - setPage(1); - }} - options={SOURCE_OPTIONS} - /> - - - - - - - - + + {/* ── Filter bar ── */} + + + + { + setFilterSession(v); + setPage(1); + }} + options={SESSION_OPTIONS} + /> + + + { + setFilterSource(v); + setPage(1); + }} + options={SOURCE_OPTIONS} + /> + + + + + + + + - {/* ── Action bar ── */} -
- - } - onClick={() => setBatchModalOpen(true)} - > - 批量录入 - - - -
+ {/* ── Action bar ── */} +
+ + } + onClick={() => setBatchModalOpen(true)} + > + 批量录入 + + + + +
- {/* ── Table view ── */} - {!calendarView && ( - `共 ${t} 条`, - onChange: (p, ps) => { - setPage(p); - setPageSize(ps); - }, - }} - /> - )} + {/* ── Table view ── */} + {!calendarView && ( +
`共 ${t} 条`, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + )} - {/* ── Calendar view ── */} - {calendarView && ( - - {calendarData.length === 0 ? ( -
- 暂无数据,请选择班级后查看 -
- ) : ( -
({ - title: ( -
-
{dayjs(date).format('MM/DD')}
-
{dayjs(date).format('ddd')}
-
- ), - 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 -; - const statusInfo = STATUS_MAP[dayRecord.status]; - return ( - - - {statusInfo?.text || dayRecord.status} - - - ); - }, - })), - ]} - /> - )} - - )} + {/* ── Calendar view ── */} + {calendarView && ( + + {calendarData.length === 0 ? ( +
+ 暂无数据,请选择班级后查看 +
+ ) : ( +
({ + title: ( +
+
{dayjs(date).format('MM/DD')}
+
{dayjs(date).format('ddd')}
+
+ ), + 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 -; + const statusInfo = STATUS_MAP[dayRecord.status]; + return ( + + + {statusInfo?.text || dayRecord.status} + + + ); + }, + })), + ]} + /> + )} + + )} + + ), + }, + { + key: 'dingtalk', + label: '钉钉原始数据', + children: ( + <> + {/* ── DingTalk filter ── */} + + + + { + setDingMatchStatus(v); + setDingPage(1); + }} + options={MATCH_STATUS_OPTIONS} + /> + + + + + {/* ── DingTalk table ── */} +
`共 ${t} 条`, + onChange: (p, ps) => { + setDingPage(p); + setDingPageSize(ps); + }, + }} + /> + + ), + }, + ]} + /> {/* ── Batch add modal ── */} { 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 = () => { + {/* ── Match student modal ── */} + { + const selected = matchStudentResults.find((s) => s.name === matchStudentSearch); + if (selected) handleMatchSubmit(selected.id); + }} + onCancel={() => { + setMatchModalOpen(false); + setMatchRecordId(null); + setMatchStudentSearch(''); + setMatchStudentResults([]); + }} + confirmLoading={matchSubmitting} + okText="确定" + cancelText="取消" + > +
+ +
+ } onClick={() => openCreate()}> + 新增校区 + + } + > + + + + + {selected ? ( + + + + handleDelete(selected.id)}> + + + + } + > +

类型:{selected.type === 'campus' ? '校区' : '部门'}

+

排序:{selected.sortOrder}

+

部门成员

+
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 && ( - {cell.hasContract ? '📄' : ''} + {isInternal ? '📖' : cell.hasContract ? '📄' : ''} )} diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index bdd5041..3f7952b 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -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 { 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([]); const [ganttData, setGanttData] = useState([]); - const [roomRanking, setRoomRanking] = useState>([]); + const [roomRanking] = useState>([]); 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('/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('/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('/dashboard/classroom-occupancy'); + setClassroomOccupancy(co); } catch (e) { console.error(e); } @@ -108,7 +106,7 @@ const DashboardPage: React.FC = () => { const [expenseTypeMap, setExpenseTypeMap] = useState>({}); useEffect(() => { - api.get('/expense-types').then((types: any[]) => { + api.get>('/expense-types').then((types) => { const map: Record = {}; 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 }, diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 59735ff..4ae3bcf 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useState, useMemo } from 'react'; import { Table, - Button, Modal, Form, Select, @@ -40,6 +39,15 @@ const installmentStatusMap: Record = { 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([]); const [students, setStudents] = useState([]); @@ -48,7 +56,7 @@ const DepositsPage: React.FC = () => { const [refundModal, setRefundModal] = useState(null); const [detailModal, setDetailModal] = useState(null); const [installmentModal, setInstallmentModal] = useState(null); - const [pendingRefunds, setPendingRefunds] = useState([]); + const [pendingRefunds, setPendingRefunds] = useState([]); 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('/deposits/pending-refunds'); setPendingRefunds(res || []); } catch (e) { console.error(e); @@ -273,7 +281,9 @@ const DepositsPage: React.FC = () => { size="small" danger icon={} - /> + > + 删除 + ), @@ -536,7 +546,9 @@ const DepositsPage: React.FC = () => { title="确定删除?" onConfirm={() => handleDeleteInstallment(item.id)} > - } /> + }> + 删除 + ].filter(Boolean)} > diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 1bce84b..5cd14c7 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -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 = { - water: '水费', - electricity: '电费', - cleaning: '保洁费', - damage: '损坏赔偿', - penalty: '罚款', - key: '钥匙费', - remote: '空调遥控器', - deposit_deduction: '押金扣除', - other: '其他', -}; const ExpensesPage: React.FC = () => { const [roomExpenses, setRoomExpenses] = useState([]); @@ -78,6 +50,31 @@ const ExpensesPage: React.FC = () => { const [selectedRoomKeys, setSelectedRoomKeys] = useState([]); const [selectedPersonalKeys, setSelectedPersonalKeys] = useState([]); + // 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>({}); + + useEffect(() => { + api.get>('/expense-types').then((types) => { + const roomTypes: { value: string; label: string }[] = []; + const personalTypes: { value: string; label: string }[] = []; + const map: Record = {}; + 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); }} > - {''} + 编辑 { size="small" danger icon={} - /> + > + 删除 + ), @@ -291,7 +290,7 @@ const ExpensesPage: React.FC = () => { setPersonalModal(true); }} > - {''} + 编辑 { size="small" danger icon={} - /> + > + 删除 + ), @@ -347,7 +348,7 @@ const ExpensesPage: React.FC = () => { style={{ width: 120 }} value={roomTypeFilter} onChange={(v) => setRoomTypeFilter(v)} - options={expenseTypeOptions} + options={typeOptions} /> { style={{ width: 120 }} value={personalTypeFilter} onChange={(v) => setPersonalTypeFilter(v)} - options={personalExpenseTypeOptions} + options={personalTypeOptions} /> { /> - @@ -670,7 +671,7 @@ const ExpensesPage: React.FC = () => { /> - diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 6bbd2c9..ecc7e9e 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -227,7 +227,9 @@ const OccupanciesPage: React.FC = () => { } }} > - } /> + }> + 删除 + ), diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index 6b87983..d2f7743 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from 'react'; import { Table, - Button, Modal, Form, Input, diff --git a/apps/admin/src/pages/RoomVisual/index.tsx b/apps/admin/src/pages/RoomVisual/index.tsx index 02bc294..32f0249 100644 --- a/apps/admin/src/pages/RoomVisual/index.tsx +++ b/apps/admin/src/pages/RoomVisual/index.tsx @@ -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, }} > - + + {room.tenantColor && ( + + )} {room.roomNumber} {getStatusLabel(room)} @@ -205,6 +216,14 @@ const RoomVisualPage: React.FC = () => {
位置:{detailRoom.building || '-'} {detailRoom.floor ? `${detailRoom.floor}F` : ''}
+ + {detailRoom.tenantColor && ( +
+ + {detailRoom.occupants[0]?.tenantName || '租户'} + +
+ )}
{getStatusLabel(detailRoom)}
{detailRoom.occupants.length > 0 ? (
diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index e35a3c0..c7b6cf6 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -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 长租; + if (v === 'short') return 短租; + return '-'; + }, + }, + { + title: '月租金', + dataIndex: 'monthlyRate', + width: 80, + render: (v: number) => (v ? `¥${v}` : '-'), + }, { title: '额定人数', dataIndex: 'capacity' }, { title: '当前入住', @@ -333,17 +350,25 @@ const RoomsPage: React.FC = () => { { + 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); } }} > diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index f7ce8c9..86606da 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -348,7 +348,7 @@ const SchedulesPage: React.FC = () => { > 教室 - {WEEKDAYS.map((day, idx) => ( + {WEEKDAYS.map((day) => (
{ + 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 = { active: { text: '在读', color: 'green' }, graduated: { text: '已毕业', color: 'blue' }, @@ -32,9 +44,11 @@ const statusMap: Record = { }; const StudentsPage: React.FC = () => { + const { modal } = App.useApp(); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); + const [tenants, setTenants] = useState([]); const [editing, setEditing] = useState(null); const [searchName, setSearchName] = useState(''); const [showArchived, setShowArchived] = useState(false); @@ -42,6 +56,29 @@ const StudentsPage: React.FC = () => { const [selectedRowKeys, setSelectedRowKeys] = useState([]); 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 ( + + {maskPhone(v)} + handleViewSensitive(record.id, '电话', v)} title="点击查看完整号码"> + + + + ); + }, + }, { 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 ( + + {maskIdNumber(v)} + handleViewSensitive(record.id, '身份证号', v)} title="点击查看完整号码"> + + + + ); + }, }, { title: '民族', dataIndex: 'ethnicity', width: 80 }, { title: '紧急联系人', dataIndex: 'emergencyContact', ellipsis: true }, { title: '紧急联系人电话', dataIndex: 'emergencyPhone', ellipsis: true }, { title: '所属机构', - dataIndex: 'organization', - render: (v: string) => (v ? {v} : '-'), + dataIndex: 'tenant', + render: (tenant: { name?: string } | null) => + tenant?.name ? {tenant.name} : '-', }, { title: '负责人', dataIndex: 'supervisor', ellipsis: true }, { @@ -369,11 +439,18 @@ const StudentsPage: React.FC = () => { - + diff --git a/apps/admin/src/pages/Tenants/index.tsx b/apps/admin/src/pages/Tenants/index.tsx index d44e173..6887cf5 100644 --- a/apps/admin/src/pages/Tenants/index.tsx +++ b/apps/admin/src/pages/Tenants/index.tsx @@ -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'; diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx index 08c4dec..191bb98 100644 --- a/apps/admin/src/pages/Users/index.tsx +++ b/apps/admin/src/pages/Users/index.tsx @@ -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(null); const [resetTarget, setResetTarget] = useState(null); + const [profileModalOpen, setProfileModalOpen] = useState(false); + const [profileUser, setProfileUser] = useState(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) => ( + } + onClick={() => handleOpenProfile(record)} + > + 档案 + { - + + setProfileModalOpen(false)} + destroyOnHidden + > +
+ + + + + + + + + ); +}; + +export default CampusSwitcher; +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin/src/hooks/useCampus.ts apps/admin/src/components/CampusSwitcher.tsx +git commit -m "feat: add useCampus hook and CampusSwitcher component" +``` + +--- + +### Task 11: 前端 — MainLayout 集成校区选择器 + API interceptor + +**Files:** +- Modify: `apps/admin/src/layouts/MainLayout.tsx` +- Modify: `apps/admin/src/api/index.ts` + +- [ ] **Step 1: MainLayout 添加 CampusSwitcher** + +在 `MainLayout.tsx` 的 Header 中,Logo 旁边添加: + +```tsx +import CampusSwitcher from '../components/CampusSwitcher'; + +// 在 Header 内 Logo 区域后: + +``` + +- [ ] **Step 2: API interceptor 注入 X-Campus-Id** + +```typescript +// apps/admin/src/api/index.ts +// 在已有的 request interceptor 中添加: +api.interceptors.request.use((config) => { + // ... 已有的 token 注入 ... + + const campusId = localStorage.getItem('currentCampusId'); + if (campusId) { + config.headers['X-Campus-Id'] = campusId; + } + return config; +}); +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin/src/layouts/MainLayout.tsx apps/admin/src/api/index.ts +git commit -m "feat: integrate CampusSwitcher into header and API interceptor" +``` + +--- + +### Task 12: 前端 — 部门管理页 + +**Files:** +- Create: `apps/admin/src/pages/Departments/index.tsx` +- Modify: `apps/admin/src/App.tsx` — 注册路由 + +- [ ] **Step 1: 创建 Departments 管理页** + +```tsx +// apps/admin/src/pages/Departments/index.tsx +import React, { useState, useEffect } from 'react'; +import { + Tree, Card, Button, Modal, Form, Input, Select, InputNumber, + Space, Table, Popconfirm, message, Row, Col, +} from 'antd'; +import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'; +import api from '../../api'; + +interface Department { + id: number; + name: string; + parentId: number | null; + type: string; + sortOrder: number; + children?: Department[]; +} + +const DepartmentsPage: React.FC = () => { + const [tree, setTree] = useState([]); + const [selected, setSelected] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [users, setUsers] = useState([]); + const [form] = Form.useForm(); + + const fetchTree = async () => { + try { + const data = await api.get('/departments/tree') as unknown as Department[]; + setTree(data); + } catch { /* ignore */ } + }; + + useEffect(() => { fetchTree(); }, []); + + const handleSelect = async (keys: React.Key[]) => { + if (keys.length === 0) return; + try { + const dept = await api.get(`/departments/${keys[0]}`) as unknown as Department; + setSelected(dept); + const userData = await api.get(`/departments/${keys[0]}/users`) as unknown as any[]; + setUsers(userData); + } catch { /* ignore */ } + }; + + const handleSave = async () => { + const values = await form.validateFields(); + try { + if (editing) { + await api.put(`/departments/${editing.id}`, values); + message.success('更新成功'); + } else { + await api.post('/departments', values); + message.success('创建成功'); + } + setModalOpen(false); + fetchTree(); + } catch (e: any) { + message.error(e?.message || '操作失败'); + } + }; + + const handleDelete = async (id: number) => { + try { + await api.delete(`/departments/${id}`); + message.success('已删除'); + setSelected(null); + fetchTree(); + } catch (e: any) { + message.error(e?.message || '删除失败'); + } + }; + + const openCreate = (parentId?: number) => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ parentId: parentId ?? null, type: 'department', sortOrder: 0 }); + setModalOpen(true); + }; + + const openEdit = () => { + if (!selected) return; + setEditing(selected); + form.setFieldsValue(selected); + setModalOpen(true); + }; + + const treeData = tree.map((node) => ({ + title: `${node.name} (${node.type === 'campus' ? '校区' : '部门'})`, + key: node.id, + children: node.children?.map((child) => ({ + title: `${child.name} (${child.type === 'campus' ? '校区' : '部门'})`, + key: child.id, + })), + })); + + return ( + +
v ? '是' : '否' }, + ]} + size="small" + /> + + ) : ( + +
+ 请从左侧选择一个部门 +
+
+ )} + + + setModalOpen(false)} + > + + + + + + + + + + + + + + ); +}; + +export default DepartmentsPage; +``` + +- [ ] **Step 2: 在 App.tsx 注册路由** + +```tsx +import DepartmentsPage from './pages/Departments'; + +// 在 Routes 内添加: + + + +}> + } /> + +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin/src/pages/Departments/index.tsx apps/admin/src/App.tsx +git commit -m "feat: add Departments management page with tree + user list" +``` + +--- + +### Task 13: 验证 + 端到端测试 + +- [ ] **Step 1: 启动完整环境** + +```bash +cd apps/server && npm run start:dev & +cd apps/admin && npm run dev & +``` + +- [ ] **Step 2: 测试流程** + +1. 打开 `http://localhost:5173`,用超管登录 +2. 访问 `/departments` → 确认默认「主校区」存在 +3. 创建第二个校区「江宁校区」 +4. 进入「账号管理」→ 编辑某个教职工,将其分配到「江宁校区」 +5. 用该教职工登录 → Header 显示校区选择器,可切换 +6. 切换到「江宁校区」→ 学生列表/宿舍列表只显示江宁数据 +7. 切换到「全部校区」→ 显示两个校区数据 +8. 超管不选校区 → 显示全部数据(无隔离) + +- [ ] **Step 3: 数据隔离验证** + +SQL 验证: +```sql +-- 确认历史数据已回填 +SELECT COUNT(*) FROM students WHERE department_id IS NULL; -- 期望 0 +SELECT COUNT(*) FROM rooms WHERE department_id IS NULL; -- 期望 0 + +-- 确认新创建实体自动填充 +INSERT INTO students (...) VALUES (...); +-- 应自动填 department_id +``` + +- [ ] **Step 4: Commit (如有调整)** + +```bash +git add -A +git commit -m "fix: campus isolation tweaks and seed adjustments" +``` diff --git a/docs/superpowers/plans/2026-07-05-notification-center.md b/docs/superpowers/plans/2026-07-05-notification-center.md new file mode 100644 index 0000000..d0647fc --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-notification-center.md @@ -0,0 +1,1273 @@ +# 站内信通知中心 — 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为系统全部角色提供站内信通知中心,支持 SSE 实时推送 + 轮询兜底,预留钉钉/企微外发扩展点。 + +**Architecture:** 新增 `notifications` 表 + `NotificationsModule`,NestJS `@Sse()` 实现 SSE 推送,前端 `EventSource` + `useNotifications` hook,Header 铃铛 Badge + Popover + 全屏通知页。 + +**Tech Stack:** NestJS 11, TypeORM 0.3, RxJS, React 19, Ant Design 6, EventSource API + +## Global Constraints + +- 表名使用复数形式 `notifications` +- Entity 使用 `@Entity('notifications')` + `@Column({ name: 'snake_case' })` 模式 +- 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出 +- Module 必须 `imports: [TypeOrmModule.forFeature([Notification])]` +- Controller 所有方法 `@UseGuards(JwtAuthGuard)` +- DTO 使用 class-validator 装饰器 +- 前端 axios 实例从 `api/` 导入 +- 前端新增路由在 `App.tsx` 注册 +- SSE 端点需支持从 query string 提取 JWT token(EventSource 不支持自定义 header) + +--- + +### Task 1: Notification Entity + +**Files:** +- Create: `apps/server/src/entities/notification.entity.ts` +- Modify: `apps/server/src/entities/index.ts` + +**Interfaces:** +- Produces: `Notification` entity class — exports for TypeORM `@Entity('notifications')` + +- [ ] **Step 1: 创建 notification.entity.ts** + +```typescript +// apps/server/src/entities/notification.entity.ts +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity'; + +export enum NotificationType { + BILL_GENERATED = 'bill_generated', + BILL_PAID = 'bill_paid', + CHECK_IN = 'check_in', + CHECK_OUT = 'check_out', + DEPOSIT_DUE = 'deposit_due', + DEPOSIT_REFUNDED = 'deposit_refunded', + CLASS_CHANGE = 'class_change', + SCHEDULE_CONFLICT = 'schedule_conflict', + ANNOUNCEMENT = 'announcement', +} + +@Entity('notifications') +export class Notification { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'recipient_id', type: 'integer' }) + recipientId: number; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'recipient_id' }) + recipient: User; + + @Column({ name: 'type', length: 30 }) + type: string; + + @Column({ name: 'title', length: 200 }) + title: string; + + @Column({ name: 'content', type: 'text', nullable: true }) + content: string; + + @Column({ name: 'link', length: 500, nullable: true }) + link: string; + + @Column({ name: 'is_read', type: 'boolean', default: false }) + isRead: boolean; + + @Column({ name: 'read_at', type: 'datetime', nullable: true }) + readAt: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} +``` + +- [ ] **Step 2: 在 entities/index.ts 中注册导出** + +在 `apps/server/src/entities/index.ts` 末尾添加: +```typescript +export { Notification, NotificationType } from './notification.entity'; +``` + +- [ ] **Step 3: 验证 — 启动后端检查 TypeORM 自动建表** + +```bash +cd apps/server && npm run start:dev +``` + +Expected: 启动成功,`notifications` 表自动创建。 + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/entities/notification.entity.ts apps/server/src/entities/index.ts +git commit -m "feat: add Notification entity" +``` + +--- + +### Task 2: Notifications Module — DTO + Service + +**Files:** +- Create: `apps/server/src/notifications/dto/notification.dto.ts` +- Create: `apps/server/src/notifications/notifications.service.ts` +- Create: `apps/server/src/notifications/notifications.module.ts` + +**Interfaces:** +- Consumes: `Notification` entity from Task 1 +- Produces: `NotificationsService` with methods: `create`, `findByUser`, `getUnreadCount`, `markRead`, `markAllRead`, `subscribe` + +- [ ] **Step 1: 创建 DTO** + +```typescript +// apps/server/src/notifications/dto/notification.dto.ts +import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt, IsEnum } from 'class-validator'; +import { NotificationType } from '../../entities/notification.entity'; + +export class CreateNotificationDto { + @IsArray() + @IsInt({ each: true }) + recipientIds: number[]; + + @IsString() + @IsNotEmpty() + type: string; + + @IsString() + @IsNotEmpty() + title: string; + + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsString() + link?: string; +} + +export class NotificationQueryDto { + @IsOptional() + @IsInt() + after?: number; + + @IsOptional() + @IsInt() + limit?: number; +} +``` + +- [ ] **Step 2: 创建 Service** + +```typescript +// apps/server/src/notifications/notifications.service.ts +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { Subject, Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Notification } from '../entities/notification.entity'; +import { CreateNotificationDto } from './dto/notification.dto'; + +@Injectable() +export class NotificationsService { + private subjects = new Map>(); + + constructor( + @InjectRepository(Notification) + private repo: Repository, + private eventEmitter: EventEmitter2, + ) {} + + async create(dto: CreateNotificationDto): Promise { + const notifications = dto.recipientIds.map((recipientId) => + this.repo.create({ + recipientId, + type: dto.type, + title: dto.title, + content: dto.content ?? '', + link: dto.link ?? null, + }), + ); + const saved = await this.repo.save(notifications); + + // 推送 SSE + emit 事件 + for (const n of saved) { + this.subjects.get(n.recipientId)?.next(n); + this.eventEmitter.emit('notification.created', n); + } + + return saved; + } + + async findByUser( + userId: number, + after?: number, + limit: number = 20, + ): Promise { + const qb = this.repo + .createQueryBuilder('n') + .where('n.recipientId = :userId', { userId }) + .orderBy('n.createdAt', 'DESC') + .take(limit); + + if (after) { + qb.andWhere('n.id < :after', { after }); + } + + return qb.getMany(); + } + + async getUnreadCount(userId: number): Promise { + return this.repo.count({ + where: { recipientId: userId, isRead: false }, + }); + } + + async markRead(id: number, userId: number): Promise { + await this.repo.update( + { id, recipientId: userId }, + { isRead: true, readAt: new Date() }, + ); + } + + async markAllRead(userId: number): Promise { + await this.repo.update( + { recipientId: userId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + } + + subscribe(userId: number): Observable { + if (!this.subjects.has(userId)) { + this.subjects.set(userId, new Subject()); + } + return this.subjects.get(userId)!.asObservable(); + } + + unsubscribe(userId: number): void { + const subj = this.subjects.get(userId); + if (subj) { + subj.complete(); + this.subjects.delete(userId); + } + } +} +``` + +- [ ] **Step 3: 创建 Module** + +```typescript +// apps/server/src/notifications/notifications.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Notification } from '../entities/notification.entity'; +import { NotificationsService } from './notifications.service'; +import { NotificationsController } from './notifications.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Notification])], + controllers: [NotificationsController], + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/notifications/ +git commit -m "feat: add NotificationsService with SSE subject pool" +``` + +--- + +### Task 3: Notifications Controller + SSE Endpoint + +**Files:** +- Create: `apps/server/src/notifications/notifications.controller.ts` +- Modify: `apps/server/src/app.module.ts` — 注册 NotificationsModule + +**Interfaces:** +- Consumes: `NotificationsService` from Task 2 +- Produces: REST API + SSE stream endpoint + +- [ ] **Step 1: 创建 Controller** + +```typescript +// apps/server/src/notifications/notifications.controller.ts +import { + Controller, + Get, + Put, + Param, + Query, + Req, + Sse, + UseGuards, +} from '@nestjs/common'; +import { Request } from 'express'; +import { Observable, map } from 'rxjs'; +import { NotificationsService } from './notifications.service'; +import { NotificationQueryDto } from './dto/notification.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@UseGuards(JwtAuthGuard) +@Controller('notifications') +export class NotificationsController { + constructor(private readonly service: NotificationsService) {} + + @Get() + async findAll(@Req() req: any, @Query() query: NotificationQueryDto) { + const userId = req.user.id; + return this.service.findByUser(userId, query.after, query.limit ?? 20); + } + + @Get('unread-count') + async unreadCount(@Req() req: any) { + const userId = req.user.id; + const count = await this.service.getUnreadCount(userId); + return { count }; + } + + @Sse('stream') + stream(@Req() req: any): Observable { + const userId = req.user.id; + return this.service.subscribe(userId).pipe( + map((notification) => ({ + data: JSON.stringify({ + id: notification.id, + type: notification.type, + title: notification.title, + content: notification.content, + link: notification.link, + createdAt: notification.createdAt, + }), + } as MessageEvent)), + ); + } + + @Put(':id/read') + async markRead(@Param('id') id: string, @Req() req: any) { + await this.service.markRead(+id, req.user.id); + return { success: true }; + } + + @Put('read-all') + async markAllRead(@Req() req: any) { + await this.service.markAllRead(req.user.id); + return { success: true }; + } +} +``` + +- [ ] **Step 2: 在 AppModule 中注册 NotificationsModule** + +在 `apps/server/src/app.module.ts` 中: +1. 在 imports 数组中添加 `NotificationsModule,` +2. 在 entities 数组中添加 `Notification,` + +```typescript +// 在 TypeOrmModule.forRootAsync 的 allEntities 数组中添加: +Notification, + +// 在 @Module imports 中添加: +NotificationsModule, +``` + +- [ ] **Step 3: 验证 — 启动后端测试 API** + +```bash +cd apps/server && npm run start:dev +``` + +Expected: 启动成功。用 curl 测试(先登录获取 token): +```bash +# 获取未读数 +curl -H "Authorization: Bearer " http://localhost:3000/api/notifications/unread-count +# Expected: {"count":0} +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/src/notifications/notifications.controller.ts apps/server/src/app.module.ts +git commit -m "feat: add NotificationsController with SSE stream endpoint" +``` + +--- + +### Task 4: JWT SSE 认证适配 + +**Files:** +- Modify: `apps/server/src/auth/guards/jwt-auth.guard.ts` + +**Interfaces:** +- Modifies: `JwtAuthGuard` — SSE 请求从 query string 提取 token 作为 fallback + +- [ ] **Step 1: 修改 JwtAuthGuard 支持 query token** + +```typescript +// apps/server/src/auth/guards/jwt-auth.guard.ts +// 在已有的 JwtAuthGuard 类中,重写 getRequest 或在 canActivate 前增加 extractor + +// 方案:创建自定义 guard 扩展 +``` + +实际上 `passport-jwt` 的 `ExtractJwt.fromAuthHeaderAsBearerToken()` 不支持 query。需要在 strategy 层面处理。 + +修改 `apps/server/src/auth/strategies/jwt.strategy.ts`: + +```typescript +// apps/server/src/auth/strategies/jwt.strategy.ts +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + // 1. 标准 Bearer header + ExtractJwt.fromAuthHeaderAsBearerToken(), + // 2. SSE 场景:query string ?token= + (req: Request) => { + const token = req?.query?.token; + if (typeof token === 'string' && token.length > 0) { + return token; + } + return null; + }, + ]), +``` + +保留 `ignoreExpiration` 和 `secretOrKey` 不变。 + +- [ ] **Step 2: 验证 — SSE 端点认证** + +```bash +cd apps/server && npm run start:dev +# 用浏览器或 curl 测试 SSE: +curl -N "http://localhost:3000/api/notifications/stream?token=" +# Expected: 连接保持,无错误 +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/auth/strategies/jwt.strategy.ts +git commit -m "feat: support JWT from query string for SSE endpoints" +``` + +--- + +### Task 5: 事件监听 + 钉钉/企微外发(预留) + +**Files:** +- Modify: `apps/server/src/notifications/notifications.module.ts` — 注册 EventEmitter + +**Interfaces:** +- Produces: `notification.created` 事件发射,供钉钉/企微模块异步监听 + +- [ ] **Step 1: 安装依赖** + +```bash +cd apps/server && npm install @nestjs/event-emitter +``` + +- [ ] **Step 2: 注册 EventEmitterModule** + +确保 `apps/server/src/app.module.ts` 中已引入 `EventEmitterModule.forRoot()`。检查是否已存在: + +```bash +grep -r "EventEmitter" apps/server/src/app.module.ts +``` + +如果不存在,添加: +```typescript +import { EventEmitterModule } from '@nestjs/event-emitter'; + +// 在 @Module imports 中添加: +EventEmitterModule.forRoot(), +``` + +- [ ] **Step 3: 验证 — 事件发射不报错** + +Service 中的 `this.eventEmitter.emit('notification.created', n)` 应在 EventEmitter 注册后正常工作。启动后端确认无报错。 + +- [ ] **Step 4: Commit** +``` + +--- + +### Task 6: 前端 — useNotifications Hook + NotificationBell 组件 + +**Files:** +- Create: `apps/admin/src/hooks/useNotifications.ts` +- Create: `apps/admin/src/components/NotificationBell.tsx` + +**Interfaces:** +- Consumes: `/api/notifications/unread-count` and `/api/notifications/stream` +- Produces: `useNotifications` hook, `NotificationBell` component + +- [ ] **Step 1: 创建 useNotifications hook** + +```typescript +// apps/admin/src/hooks/useNotifications.ts +import { useState, useEffect, useCallback } from 'react'; +import api from '../api'; + +interface Notification { + id: number; + type: string; + title: string; + content: string; + link: string | null; + createdAt: string; +} + +export function useNotifications() { + const [unreadCount, setUnreadCount] = useState(0); + const [latestNotification, setLatestNotification] = useState(null); + + const fetchUnreadCount = useCallback(async () => { + try { + const data = await api.get('/notifications/unread-count') as unknown as { count: number }; + setUnreadCount(data.count); + } catch { + // 静默失败 + } + }, []); + + useEffect(() => { + // 初始加载 + fetchUnreadCount(); + + const token = localStorage.getItem('token'); + if (!token) return; + + // SSE 连接 + const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); + + es.onmessage = (event) => { + try { + const notification = JSON.parse(event.data) as Notification; + setUnreadCount((c) => c + 1); + setLatestNotification(notification); + } catch { + // 解析失败忽略 + } + }; + + es.onerror = () => { + // SSE 断开 → 切换到轮询兜底 + es.close(); + const interval = setInterval(() => { + fetchUnreadCount(); + }, 60_000); + return () => clearInterval(interval); + }; + + return () => { + es.close(); + }; + }, [fetchUnreadCount]); + + const markAsRead = useCallback(async (id: number) => { + try { + await api.put(`/notifications/${id}/read`); + setUnreadCount((c) => Math.max(0, c - 1)); + } catch { + // 静默失败 + } + }, []); + + const markAllAsRead = useCallback(async () => { + try { + await api.put('/notifications/read-all'); + setUnreadCount(0); + } catch { + // 静默失败 + } + }, []); + + return { unreadCount, latestNotification, markAsRead, markAllAsRead }; +} +``` + +- [ ] **Step 2: 创建 NotificationBell 组件** + +```tsx +// apps/admin/src/components/NotificationBell.tsx +import React, { useState, useEffect } from 'react'; +import { Badge, Popover, Button, List, Typography, Empty, Space } from 'antd'; +import { BellOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import api from '../api'; + +interface NotificationItem { + id: number; + type: string; + title: string; + content: string; + link: string | null; + isRead: boolean; + createdAt: string; +} + +const typeLabels: Record = { + bill_generated: '账单', + bill_paid: '账单', + check_in: '入住', + check_out: '退宿', + deposit_due: '押金', + deposit_refunded: '押金', + class_change: '班级', + schedule_conflict: '排课', + announcement: '公告', +}; + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return '刚刚'; + if (mins < 60) return `${mins}分钟前`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}小时前`; + const days = Math.floor(hours / 24); + return `${days}天前`; +} + +const NotificationBell: React.FC = () => { + const [unreadCount, setUnreadCount] = useState(0); + const [notifications, setNotifications] = useState([]); + const [open, setOpen] = useState(false); + const navigate = useNavigate(); + + const fetchNotifications = async () => { + try { + const data = await api.get('/notifications?limit=20') as unknown as NotificationItem[]; + setNotifications(data); + } catch { /* ignore */ } + }; + + const fetchUnread = async () => { + try { + const data = await api.get('/notifications/unread-count') as unknown as { count: number }; + setUnreadCount(data.count); + } catch { /* ignore */ } + }; + + useEffect(() => { + fetchUnread(); + // SSE + const token = localStorage.getItem('token'); + if (!token) return; + const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); + es.onmessage = (event) => { + try { + JSON.parse(event.data); + setUnreadCount((c) => c + 1); + if (open) fetchNotifications(); + } catch { /* ignore */ } + }; + es.onerror = () => { + es.close(); + const interval = setInterval(fetchUnread, 60_000); + return () => clearInterval(interval); + }; + return () => es.close(); + }, [open]); + + const handleOpen = (visible: boolean) => { + setOpen(visible); + if (visible) fetchNotifications(); + }; + + const handleClick = async (item: NotificationItem) => { + if (!item.isRead) { + try { + await api.put(`/notifications/${item.id}/read`); + setUnreadCount((c) => Math.max(0, c - 1)); + } catch { /* ignore */ } + } + setOpen(false); + if (item.link) navigate(item.link); + }; + + const handleMarkAll = async () => { + try { + await api.put('/notifications/read-all'); + setUnreadCount(0); + setNotifications((prev) => + prev.map((n) => ({ ...n, isRead: true })), + ); + } catch { /* ignore */ } + }; + + const content = ( +
+
+ 通知中心 + +
+ {notifications.length === 0 ? ( +
+ +
+ ) : ( + ( + handleClick(item)} + style={{ + padding: '12px 16px', + cursor: 'pointer', + backgroundColor: item.isRead ? 'transparent' : '#f0f7ff', + }} + > + + ) + } + title={ + + [{typeLabels[item.type] || item.type}] {item.title} + + } + description={ + + {timeAgo(item.createdAt)} + + } + /> + + )} + /> + )} +
+ +
+
+ ); + + return ( + + + + + + ); +}; + +export default NotificationBell; +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin/src/hooks/useNotifications.ts apps/admin/src/components/NotificationBell.tsx +git commit -m "feat: add useNotifications hook and NotificationBell component" +``` + +--- + +### Task 7: 前端 — MainLayout 集成铃铛 + +**Files:** +- Modify: `apps/admin/src/layouts/MainLayout.tsx` + +- [ ] **Step 1: 在 Header 中添加 NotificationBell** + +在 `MainLayout.tsx` 的 Header 右侧区域(用户头像/下拉菜单旁边)添加 `NotificationBell`: + +```tsx +// apps/admin/src/layouts/MainLayout.tsx +// 在 imports 中添加: +import NotificationBell from '../components/NotificationBell'; + +// 在 Header 右侧区域(通常在用户 Dropdown 之前): + +``` + +具体定位:找到 Header 中 `Dropdown` / `Avatar` 相关的 JSX,在其前面插入 ``。 + +- [ ] **Step 2: Commit** + +```bash +git add apps/admin/src/layouts/MainLayout.tsx +git commit -m "feat: integrate NotificationBell into MainLayout header" +``` + +--- + +### Task 8: 前端 — 全屏通知页 + +**Files:** +- Create: `apps/admin/src/pages/Notifications/index.tsx` +- Modify: `apps/admin/src/App.tsx` — 注册路由 + +- [ ] **Step 1: 创建 Notifications 页面** + +```tsx +// apps/admin/src/pages/Notifications/index.tsx +import React, { useState, useEffect } from 'react'; +import { List, Typography, Menu, Layout, Button, Empty, Spin } from 'antd'; +import { + BellOutlined, + DollarOutlined, + HomeOutlined, + TeamOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import api from '../../api'; + +const { Sider, Content } = Layout; + +interface NotificationItem { + id: number; + type: string; + title: string; + content: string; + link: string | null; + isRead: boolean; + createdAt: string; +} + +const typeMap: Record = { + bill_generated: { label: '账单', icon: }, + bill_paid: { label: '账单', icon: }, + check_in: { label: '入住', icon: }, + check_out: { label: '退宿', icon: }, + deposit_due: { label: '押金', icon: }, + deposit_refunded: { label: '押金', icon: }, + class_change: { label: '班级', icon: }, + schedule_conflict: { label: '排课', icon: }, + announcement: { label: '公告', icon: }, +}; + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return '刚刚'; + if (mins < 60) return `${mins}分钟前`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}小时前`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}天前`; + return new Date(dateStr).toLocaleDateString('zh-CN'); +} + +const NotificationsPage: React.FC = () => { + const [notifications, setNotifications] = useState([]); + const [filter, setFilter] = useState('all'); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const fetchData = async () => { + setLoading(true); + try { + const data = await api.get('/notifications?limit=50') as unknown as NotificationItem[]; + setNotifications(data); + } catch { /* ignore */ } + setLoading(false); + }; + + useEffect(() => { + fetchData(); + }, []); + + const handleClick = async (item: NotificationItem) => { + if (!item.isRead) { + try { + await api.put(`/notifications/${item.id}/read`); + setNotifications((prev) => + prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)), + ); + } catch { /* ignore */ } + } + if (item.link) navigate(item.link); + }; + + const handleMarkAll = async () => { + try { + await api.put('/notifications/read-all'); + setNotifications((prev) => + prev.map((n) => ({ ...n, isRead: true })), + ); + } catch { /* ignore */ } + }; + + const filtered = filter === 'all' + ? notifications + : notifications.filter((n) => n.type === filter); + + return ( + + + setFilter(key)} + items={[ + { key: 'all', icon: , label: '全部' }, + { key: 'bill_generated', icon: , label: '账单' }, + { key: 'check_in', icon: , label: '入住' }, + { key: 'class_change', icon: , label: '班级' }, + { key: 'announcement', icon: , label: '公告' }, + ]} + /> + + +
+ 通知中心 + +
+ + {filtered.length === 0 ? ( + + ) : ( + { + const meta = typeMap[item.type] || { label: item.type, icon: }; + return ( + handleClick(item)} + style={{ + cursor: 'pointer', + padding: '16px 0', + backgroundColor: item.isRead ? 'transparent' : '#f0f7ff', + }} + > + + {meta.icon} + + } + title={ + + + {item.title} + + + {timeAgo(item.createdAt)} + + + } + description={ + item.content && ( + + {item.content} + + ) + } + /> + + ); + }} + /> + )} + +
+ + ); +}; + +export default NotificationsPage; +``` + +- [ ] **Step 2: 在 App.tsx 注册路由** + +在 `apps/admin/src/App.tsx` 中添加 import 和路由: + +```tsx +import NotificationsPage from './pages/Notifications'; + +// 在 Routes 内添加: + + + +}> + } /> + +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin/src/pages/Notifications/index.tsx apps/admin/src/App.tsx +git commit -m "feat: add Notifications full page with sidebar filter" +``` + +--- + +### Task 9: 业务模块集成 — 通知创建点 + +**Files:** +- Modify: `apps/server/src/bills/bills.controller.ts` +- Modify: `apps/server/src/occupancies/occupancies.controller.ts` +- Modify: `apps/server/src/deposits/deposits.controller.ts` +- Modify: `apps/server/src/classes/classes.controller.ts` +- Modify: `apps/server/src/schedules/schedules.controller.ts` +- 各 module 文件注入 NotificationsModule + +**Interfaces:** +- Consumes: `NotificationsService.create()` from Task 2 + +- [ ] **Step 1: 各 Module 注入 NotificationsModule** + +在每个需要发送通知的 module 的 `imports` 中添加 `NotificationsModule`: + +```typescript +// bills.module.ts, occupancies.module.ts, deposits.module.ts, classes.module.ts, schedules.module.ts +import { NotificationsModule } from '../notifications/notifications.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([...]), + NotificationsModule, // 新增 + ], +}) +``` + +- [ ] **Step 2: 账单模块 — 生成/状态变更通知** + +```typescript +// bills.controller.ts +import { NotificationsService } from '../notifications/notifications.service'; + +// constructor 注入: +constructor( + private notificationsService: NotificationsService, +) {} + +// POST /bills/generate 方法末尾: +const notificationInfo = await this.billsService.getNotificationInfo(result); +await this.notificationsService.create({ + recipientIds: notificationInfo.studentUserIds, + type: 'bill_generated', + title: '账单已生成', + content: `您的 ${notificationInfo.periodLabel} 账单已生成,总额 ¥${notificationInfo.totalAmount}`, + link: `/bills/${result.id}`, +}); + +// PUT /bills/:id/status (确认/已付) 末尾: +await this.notificationsService.create({ + recipientIds: notificationInfo.studentUserIds, + type: 'bill_paid', + title: '账单状态更新', + content: `您的账单已被标记为${newStatus === 'confirmed' ? '已确认' : '已支付'}`, + link: `/bills/${id}`, +}); +``` + +- [ ] **Step 3: 入住模块 — 入住/退宿通知** + +```typescript +// occupancies.controller.ts +// POST check-in: +await this.notificationsService.create({ + recipientIds: [studentUserId], + type: 'check_in', + title: '入住登记', + content: `您已成功入住 ${roomLabel}`, + link: `/occupancies`, +}); + +// PUT check-out: +await this.notificationsService.create({ + recipientIds: [studentUserId], + type: 'check_out', + title: '退宿确认', + content: `您已从 ${roomLabel} 退宿`, + link: `/occupancies`, +}); +``` + +- [ ] **Step 4: 押金模块 — 催缴/退还通知** + +```typescript +// deposits.controller.ts +// 收取押金: +await this.notificationsService.create({ + recipientIds: [studentUserId], + type: 'deposit_due', + title: '押金催缴', + content: `请缴纳 ${amount} 元押金`, + link: `/deposits`, +}); + +// 退还押金: +await this.notificationsService.create({ + recipientIds: [studentUserId], + type: 'deposit_refunded', + title: '押金退还', + content: `押金 ${amount} 元已退还`, + link: `/deposits`, +}); +``` + +- [ ] **Step 5: 班级模块 — 学员/教师变更通知** + +```typescript +// classes.controller.ts +// 添加学员: +await this.notificationsService.create({ + recipientIds: [headTeacherUserId], + type: 'class_change', + title: '学员变动', + content: `${studentNames.join('、')} 已加入 ${className}`, + link: `/classes/${classId}`, +}); + +// 添加教师: +await this.notificationsService.create({ + recipientIds: [teacherUserId], + type: 'class_change', + title: '班级分配', + content: `您已被分配为 ${className} 的 ${roleLabel}`, + link: `/classes/${classId}`, +}); +``` + +- [ ] **Step 6: 排课模块 — 冲突通知** + +```typescript +// schedules.controller.ts +// 创建/编辑排课,冲突检测后: +if (conflict) { + // 通知相关教务人员 + await this.notificationsService.create({ + recipientIds: staffUserIds, + type: 'schedule_conflict', + title: '排课冲突', + content: `${classroomName} ${weekDayLabel} ${timeRange} 与已有排课冲突`, + link: `/schedules`, + }); +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/src/bills/ apps/server/src/occupancies/ apps/server/src/deposits/ apps/server/src/classes/ apps/server/src/schedules/ +git commit -m "feat: integrate notification creation into business modules" +``` + +--- + +### Task 10: 验证 + 端到端测试 + +- [ ] **Step 1: 启动完整环境** + +```bash +cd apps/server && npm run start:dev & +cd apps/admin && npm run dev & +``` + +- [ ] **Step 2: 浏览器测试流程** + +1. 打开 `http://localhost:5173`,登录 +2. 确认 Header 铃铛图标可见,未读数显示正确 +3. 点击铃铛 → Popover 展开,显示通知列表 +4. 执行一个业务操作(如生成账单)→ 对应学生用户的铃铛出现新通知 +5. 点击通知 → 标已读 + 跳转 +6. "全部已读" → 所有未读标记清除 +7. "查看全部" → 进入 `/notifications` 全屏页 +8. 左侧筛选 Tab 切换正常 + +- [ ] **Step 3: SSE 验证** + +打开两个浏览器窗口(不同用户),一个执行操作,另一个实时看到通知推送。 + +- [ ] **Step 4: Commit (如有调整)** + +```bash +git add -A +git commit -m "fix: notification integration tweaks" +``` diff --git a/docs/superpowers/specs/2026-07-05-multi-campus-design.md b/docs/superpowers/specs/2026-07-05-multi-campus-design.md new file mode 100644 index 0000000..d04fe6a --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-multi-campus-design.md @@ -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 { + 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>(where: T): Promise { + if (this.isSuperAdmin && !this.currentDepartmentId) return where; + const ids = await this.getEffectiveScopeIds(); + return { ...where, departmentId: In(ids) } as any; + } + + private async getEffectiveScopeIds(): Promise { + // 选择了具体校区 → 该校区 + 所有子部门 + // 未选 → 用户所有可访问部门 + 子部门 + 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([]); + const [currentId, setCurrentId] = useState( + () => 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` 树结构 diff --git a/docs/superpowers/specs/2026-07-05-notification-center-design.md b/docs/superpowers/specs/2026-07-05-notification-center-design.md new file mode 100644 index 0000000..96b74de --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-notification-center-design.md @@ -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=&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; + findByUser(userId: number, after?: number, limit?: number): Promise; + getUnreadCount(userId: number): Promise; + markRead(id: number, userId: number): Promise; + markAllRead(userId: number): Promise; + subscribe(userId: number): Observable; // SSE +} +``` + +### 5.4 SSE 实现要点 + +- Controller 使用 `@Sse('stream')` + `@Req()` 获取 `req.user.id` +- Service 内部维护 `Map>` +- `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_*` 前缀扩展