diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index 725ed03..8840fcd 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -4,7 +4,15 @@ box-sizing: border-box; } +html { + min-width: 320px; + background: #f5f5f7; +} + body { + min-width: 320px; + overflow-x: hidden; + background: #f5f5f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased; @@ -15,10 +23,55 @@ body { min-height: 100vh; } +img, +svg, +canvas { + max-width: 100%; +} + +.app-shell, +.app-main, +.app-content { + min-width: 0; +} + +.app-header { + position: sticky; + top: 0; + z-index: 100; +} + +/* Shared responsive toolbar: add these classes to page filter/action rows. */ +.responsive-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 16px; +} + +.responsive-toolbar__group { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ant-drawer-content-wrapper { + max-width: 100vw !important; +} + +.ant-pagination { + row-gap: 8px; +} + /* === 通用:表格容器横向滚动(防双重滚动条) === */ .ant-table-wrapper { + max-width: 100%; overflow-x: auto; overflow-y: hidden; + overscroll-behavior-inline: contain; + -webkit-overflow-scrolling: touch; } /* ── 表格单元格省略号截断(按需启用) ── @@ -54,6 +107,65 @@ body { /* === 手机 (< 576px) === */ @media (max-width: 575px) { + .app-header { + height: 56px; + padding-inline: 8px !important; + line-height: 56px; + } + + .app-header .ant-btn { + width: 40px; + min-height: 40px; + padding-inline: 0; + } + + .app-content { + min-height: calc(100dvh - 72px); + margin: 8px !important; + padding: 12px !important; + border-radius: 10px !important; + } + + .app-navigation-drawer .ant-drawer-header { + padding-inline: 16px; + } + + .responsive-toolbar { + align-items: stretch; + flex-direction: column; + margin-bottom: 12px; + } + + .responsive-toolbar__group, + .responsive-toolbar > .ant-space { + width: 100%; + } + + .responsive-toolbar__group > .ant-space-item, + .responsive-toolbar > .ant-space > .ant-space-item { + flex: 1 1 140px; + max-width: 100%; + } + + .responsive-toolbar .ant-input, + .responsive-toolbar .ant-input-affix-wrapper, + .responsive-toolbar .ant-input-search, + .responsive-toolbar .ant-picker, + .responsive-toolbar .ant-select, + .responsive-toolbar .ant-upload, + .responsive-toolbar .ant-upload-wrapper, + .responsive-toolbar .ant-btn { + width: 100% !important; + } + + .ant-btn:not(.ant-btn-sm), + .ant-input-affix-wrapper, + .ant-input-search-button, + .ant-picker, + .ant-select-single .ant-select-selector { + min-height: 40px; + } + .ant-table { font-size: 13px; } @@ -65,12 +177,48 @@ body { font-size: 13px; } .ant-modal { - max-width: calc(100vw - 24px) !important; - margin: 12px auto !important; + top: 12px; + max-width: calc(100vw - 16px) !important; + margin: 0 auto !important; + padding-bottom: 12px; + } + .ant-modal .ant-modal-content { + padding: 16px; } .ant-modal .ant-modal-body { - max-height: 60vh; + max-height: calc(100dvh - 180px); overflow-y: auto; + overscroll-behavior: contain; + } + .ant-modal .ant-modal-footer { + display: flex; + } + .ant-modal .ant-modal-footer .ant-btn { + flex: 1; + min-height: 40px; + } + .ant-drawer .ant-drawer-header { + padding: 14px 16px; + } + .ant-drawer .ant-drawer-body { + padding: 16px; + overscroll-behavior: contain; + } + .ant-popover, + .ant-picker-dropdown { + max-width: calc(100vw - 16px); + } + .ant-pagination { + justify-content: center; + } + .ant-pagination .ant-pagination-options { + margin-inline-start: 0; + } + .ant-alert { + padding: 10px 12px; + } + h1 { + font-size: 22px !important; } h2 { font-size: 18px !important; @@ -78,9 +226,8 @@ body { .ant-card { margin-bottom: 8px; } - .ant-space-item .ant-btn { - padding: 2px 6px; - font-size: 12px; + .ant-card .ant-card-body { + padding: 14px; } } @@ -90,3 +237,82 @@ body { max-width: calc(100vw - 48px) !important; } } + +.notifications-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.notifications-filter { + width: 100%; + margin-bottom: 12px; +} + +@media (max-width: 575px) { + .notifications-layout, + .notifications-content { + min-width: 0; + } + + .notifications-header { + margin-bottom: 12px; + } + + .notifications-content .ant-list-item { + align-items: flex-start; + padding: 14px 6px !important; + } + + .notifications-content .ant-list-item-meta-avatar { + margin-inline-end: 10px; + } + + .notifications-content .ant-list-item-meta-title { + margin-bottom: 4px; + } +} + +@media (max-width: 575px) { + .ant-table-wrapper::before { + content: '表格可左右滑动查看'; + display: block; + margin-bottom: 6px; + color: #8c8c8c; + font-size: 11px; + text-align: right; + } + + .ant-table-wrapper .ant-table-content { + scrollbar-width: thin; + } + + .ant-table-wrapper .ant-table-cell-fix-left, + .ant-table-wrapper .ant-table-cell-fix-right { + box-shadow: 2px 0 5px rgb(0 0 0 / 5%); + } +} + +.responsive-toolbar--single { + justify-content: flex-start; +} + +@media (max-width: 575px) { + /* Input.Search is an internal compact group. Keep the text field flexible and + the search icon button fixed instead of applying toolbar full-width rules + to both children. */ + .responsive-toolbar .ant-input-search > .ant-input-affix-wrapper { + flex: 1 1 auto; + width: auto !important; + min-width: 0; + } + + .responsive-toolbar .ant-input-search > .ant-input-search-btn { + flex: 0 0 40px; + width: 40px !important; + min-width: 40px; + padding-inline: 0; + } +} diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index c59a81e..9817c71 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -177,13 +177,14 @@ const MainLayout: React.FC = () => { ), [selectedKeys, openKeys, menuItems, handleMenuClick]); return ( - + {!isMobile && (
{ onClose={() => setDrawerOpen(false)} size={240} styles={{ body: { padding: 0 } }} + className="app-navigation-drawer" title="恭学教育基地" > {menuContent} )} - +
{
= ({ canEdit }) => width: 110, render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'), }, + { + title: '打卡设备', + width: 220, + render: (_: unknown, record: AttendanceRecordItem) => { + const info = getPunchDisplayInfo(record); + if (!info) return ; + return ( +
+ {info.label} + {info.detail && {info.detail}} + {info.time && {dayjs(info.time).format('HH:mm:ss')}} +
+ ); + }, + }, { title: '备注', dataIndex: 'remark', diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index a8ab646..a6bc550 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -257,16 +257,8 @@ const BillsPage: React.FC = () => { return (
-
- +
+ { - + { { return ( - + } diff --git a/apps/admin/src/pages/Classes/teacher-candidate.integration.test.ts b/apps/admin/src/pages/Classes/teacher-candidate.integration.test.ts new file mode 100644 index 0000000..9f561d4 --- /dev/null +++ b/apps/admin/src/pages/Classes/teacher-candidate.integration.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + buildTeacherCandidateLabel, + buildTeacherCandidateOptions, + isTeacherCandidate, + type TeacherCandidateUser, +} from './teacher-candidate'; + +const baseUser = (overrides: Partial = {}): TeacherCandidateUser => ({ + id: 1, + username: 'teacher', + name: '测试老师', + isActive: true, + isArchived: false, + studentStatus: null, + roles: [{ code: 'teacher', name: '任课老师' }], + profile: { subjects: ['数学', '物理'] }, + ...overrides, +}); + +describe('class teacher candidates', () => { + it('keeps non-teacher staff roles because class duty is selected separately', () => { + expect( + isTeacherCandidate(baseUser({ roles: [{ code: 'academic', name: '教务管理员' }] })), + ).toBe(true); + }); + + it('keeps staff-linked accounts so they can serve as head or life teachers', () => { + expect(isTeacherCandidate(baseUser({ studentStatus: 'staff' }))).toBe(true); + }); + + it('excludes active students, disabled, archived, and super-admin accounts', () => { + const users = [ + baseUser({ id: 1, studentStatus: 'active' }), + baseUser({ id: 2, isActive: false }), + baseUser({ id: 3, isArchived: true }), + baseUser({ + id: 4, + roles: [{ code: 'super_admin', name: '超级管理员' }], + }), + ]; + + expect(buildTeacherCandidateOptions(users)).toEqual([]); + }); + + it('shows real name, username, system role, and teaching subjects', () => { + expect(buildTeacherCandidateLabel(baseUser())).toBe( + '测试老师(teacher) · 任课老师 · 数学/物理', + ); + }); +}); diff --git a/apps/admin/src/pages/Classes/teacher-candidate.ts b/apps/admin/src/pages/Classes/teacher-candidate.ts new file mode 100644 index 0000000..823c72f --- /dev/null +++ b/apps/admin/src/pages/Classes/teacher-candidate.ts @@ -0,0 +1,44 @@ +export interface TeacherCandidateRole { + code?: string | null; + name: string; +} + +export interface TeacherCandidateUser { + id: number; + username: string; + name?: string | null; + isActive: boolean; + isArchived: boolean; + studentStatus?: string | null; + roles?: TeacherCandidateRole[]; + profile?: { subjects?: string[] } | null; +} + +const isSuperAdminRole = (role: TeacherCandidateRole) => + role.code === 'super_admin' || role.name === '超级管理员' || role.name === '超管'; + +export const isTeacherCandidate = (user: TeacherCandidateUser) => { + if (!user.isActive || user.isArchived) return false; + if (user.studentStatus && user.studentStatus !== 'staff') return false; + return !(user.roles || []).some(isSuperAdminRole); +}; + +export const buildTeacherCandidateLabel = (user: TeacherCandidateUser) => { + const displayName = user.name?.trim(); + const identity = + displayName && displayName !== user.username + ? `${displayName}(${user.username})` + : user.username; + const roleNames = [...new Set((user.roles || []).map((role) => role.name).filter(Boolean))]; + const subjects = [ + ...new Set((user.profile?.subjects || []).map((subject) => subject.trim()).filter(Boolean)), + ]; + + return [identity, roleNames.join('/'), subjects.join('/')].filter(Boolean).join(' · '); +}; + +export const buildTeacherCandidateOptions = (users: TeacherCandidateUser[]) => + users.filter(isTeacherCandidate).map((user) => ({ + value: user.id, + label: buildTeacherCandidateLabel(user), + })); diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx index 0762ba0..4d1990b 100644 --- a/apps/admin/src/pages/Notifications/index.tsx +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { List, Typography, Menu, Layout, Button, Empty, Spin, Space } from 'antd'; +import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, Grid, Select } from 'antd'; import { BellOutlined, DollarOutlined, @@ -13,6 +13,7 @@ import { message } from '../../ui/app-message'; import { formatNotificationText } from '../../utils/notification-display'; const { Sider, Content } = Layout; +const { useBreakpoint } = Grid; interface NotificationItem { id: number; @@ -49,6 +50,8 @@ function timeAgo(dateStr: string): string { } const NotificationsPage: React.FC = () => { + const screens = useBreakpoint(); + const isMobile = !screens.sm; const [notifications, setNotifications] = useState([]); const [filter, setFilter] = useState('all'); const [loading, setLoading] = useState(false); @@ -101,27 +104,39 @@ const NotificationsPage: React.FC = () => { ? notifications : notifications.filter((n) => n.type === filter); + const filterItems = [ + { key: 'all', icon: , label: '全部' }, + { key: 'bill_generated', icon: , label: '账单' }, + { key: 'check_in', icon: , label: '入住' }, + { key: 'class_change', icon: , label: '班级' }, + { key: 'announcement', icon: , label: '公告' }, + ]; + 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: '公告' }, - ]} - /> - - -
+ + {!isMobile && ( + + setFilter(key)} + items={filterItems} + /> + + )} + +
通知中心
+ {isMobile && ( +