Merge pull request '优化管理端移动端适配与班级教师候选人' (#14)

Merge PR #14
This commit is contained in:
2026-07-15 05:57:32 +00:00
14 changed files with 406 additions and 87 deletions

View File

@@ -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;
}
}

View File

@@ -177,13 +177,14 @@ const MainLayout: React.FC = () => {
), [selectedKeys, openKeys, menuItems, handleMenuClick]);
return (
<Layout style={{ minHeight: '100vh' }}>
<Layout className="app-shell" style={{ minHeight: '100vh' }}>
{!isMobile && (
<Sider
trigger={null}
collapsible
collapsed={isTablet ? true : collapsed}
theme="light"
className="app-sidebar"
style={{ background: '#fff', borderRight: '1px solid #e5e5e7' }}
>
<div
@@ -210,13 +211,15 @@ const MainLayout: React.FC = () => {
onClose={() => setDrawerOpen(false)}
size={240}
styles={{ body: { padding: 0 } }}
className="app-navigation-drawer"
title="恭学教育基地"
>
{menuContent}
</Drawer>
)}
<Layout style={{ background: '#f5f5f7' }}>
<Layout className="app-main" style={{ background: '#f5f5f7' }}>
<Header
className="app-header"
style={{
padding: '0 16px',
background: '#fff',
@@ -268,8 +271,9 @@ const MainLayout: React.FC = () => {
</div>
</Header>
<Content
className="app-content"
style={{
margin: isMobile ? 12 : isTablet ? 16 : 24,
margin: isMobile ? 8 : isTablet ? 16 : 24,
padding: isMobile ? 12 : isTablet ? 16 : 24,
background: '#fff',
borderRadius: 12,

View File

@@ -683,6 +683,21 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ 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 <span className="muted-text"></span>;
return (
<div className="punch-device-cell">
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
{info.detail && <strong>{info.detail}</strong>}
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
</div>
);
},
},
{
title: '备注',
dataIndex: 'remark',

View File

@@ -257,16 +257,8 @@ const BillsPage: React.FC = () => {
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<Input.Search
placeholder="搜索学生姓名或账单周期"
allowClear
@@ -308,7 +300,7 @@ const BillsPage: React.FC = () => {
</PermissionButton>
</Popconfirm>
</Space>
<Space>
<Space wrap className="responsive-toolbar__group">
<PermissionButton
permission="bill:generate"
type="primary"

View File

@@ -10,6 +10,7 @@ import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
// ---- Types ----
@@ -84,10 +85,7 @@ interface StudentItem {
studentNo?: string;
}
interface UserItem {
id: number;
username: string;
}
type UserItem = TeacherCandidateUser;
// ---- Constants ----
@@ -595,16 +593,13 @@ const ClassDetailPage: React.FC = () => {
<Space direction="vertical" style={{ width: '100%' }}>
<Select
style={{ width: '100%' }}
placeholder="选择教师"
showSearch
optionFilterProp="label"
placeholder="搜索姓名、用户名、角色或学科"
value={teacherUserId}
onChange={setTeacherUserId}
options={allUsers.map((u) => ({
value: u.id,
label: u.username,
}))}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
options={buildTeacherCandidateOptions(allUsers)}
notFoundContent="没有可分配的工作人员账号"
/>
<Select
style={{ width: '100%' }}

View File

@@ -220,7 +220,7 @@ const ClassesPage: React.FC = () => {
return (
<Card>
<Space style={{ marginBottom: 16 }} wrap>
<Space style={{ marginBottom: 16 }} wrap className="responsive-toolbar responsive-toolbar--single">
<Input
placeholder="搜索名称/编码"
prefix={<SearchOutlined />}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
buildTeacherCandidateLabel,
buildTeacherCandidateOptions,
isTeacherCandidate,
type TeacherCandidateUser,
} from './teacher-candidate';
const baseUser = (overrides: Partial<TeacherCandidateUser> = {}): 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 · 任课老师 · 数学/物理',
);
});
});

View File

@@ -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),
}));

View File

@@ -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<NotificationItem[]>([]);
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: <BellOutlined />, label: '全部' },
{ key: 'bill_generated', icon: <DollarOutlined />, label: '账单' },
{ key: 'check_in', icon: <HomeOutlined />, label: '入住' },
{ key: 'class_change', icon: <TeamOutlined />, label: '班级' },
{ key: 'announcement', icon: <SettingOutlined />, label: '公告' },
];
return (
<Layout style={{ minHeight: '100%', background: '#fff' }}>
<Sider width={180} style={{ background: '#fff', borderRight: '1px solid #f0f0f0' }}>
<Menu
mode="inline"
selectedKeys={[filter]}
onClick={({ key }) => setFilter(key)}
items={[
{ key: 'all', icon: <BellOutlined />, label: '全部' },
{ key: 'bill_generated', icon: <DollarOutlined />, label: '账单' },
{ key: 'check_in', icon: <HomeOutlined />, label: '入住' },
{ key: 'class_change', icon: <TeamOutlined />, label: '班级' },
{ key: 'announcement', icon: <SettingOutlined />, label: '公告' },
]}
/>
</Sider>
<Content style={{ padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Layout className="notifications-layout" style={{ minHeight: '100%', background: '#fff' }}>
{!isMobile && (
<Sider width={180} style={{ background: '#fff', borderRight: '1px solid #f0f0f0' }}>
<Menu
mode="inline"
selectedKeys={[filter]}
onClick={({ key }) => setFilter(key)}
items={filterItems}
/>
</Sider>
)}
<Content className="notifications-content" style={{ padding: isMobile ? 0 : 24 }}>
<div className="notifications-header">
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button onClick={handleMarkAll}></Button>
</div>
{isMobile && (
<Select
value={filter}
onChange={setFilter}
options={filterItems.map((item) => ({ value: item.key, label: item.label }))}
className="notifications-filter"
/>
)}
<Spin spinning={loading}>
{filtered.length === 0 ? (
<Empty description="暂无通知" />
@@ -165,7 +180,7 @@ const NotificationsPage: React.FC = () => {
</div>
}
title={
<Space>
<Space wrap size={[8, 2]}>
<Typography.Text
strong={!item.isRead}
style={{ fontSize: 15 }}

View File

@@ -334,16 +334,8 @@ const OccupanciesPage: React.FC = () => {
closable
style={{ marginBottom: 16 }}
/>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<Button type={showActive ? 'primary' : 'default'} onClick={() => setShowActive(true)}>
</Button>
@@ -358,7 +350,7 @@ const OccupanciesPage: React.FC = () => {
/>
<RangePicker value={dateRange} onChange={(dates) => { setDateRange(dates ? [dates[0], dates[1]] : null); }} placeholder={['入住开始', '入住结束']} style={{ width: 240 }} />
</Space>
<Space wrap>
<Space wrap className="responsive-toolbar__group">
<PermissionButton
permission="occupancy:checkin"
type="primary"

View File

@@ -411,16 +411,8 @@ const RoomsPage: React.FC = () => {
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<h3 style={{ margin: 0 }}>宿</h3>
<Input.Search
placeholder="搜索房间号"
@@ -458,7 +450,7 @@ const RoomsPage: React.FC = () => {
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
</Button>
</Space>
<Space wrap>
<Space wrap className="responsive-toolbar__group">
<Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
onConfirm={handleBatchDelete}

View File

@@ -529,16 +529,8 @@ const StudentsPage: React.FC = () => {
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<Input.Search
placeholder="搜索学生姓名"
onSearch={setSearchName}
@@ -586,7 +578,7 @@ const StudentsPage: React.FC = () => {
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
</Button>
</Space>
<Space wrap>
<Space wrap className="responsive-toolbar__group">
<Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
onConfirm={handleBatchDelete}

View File

@@ -181,7 +181,7 @@ const TeachersPage: React.FC = () => {
return (
<div>
<h2 style={{ marginBottom: 16 }}></h2>
<Space style={{ marginBottom: 16 }}>
<Space style={{ marginBottom: 16 }} wrap className="responsive-toolbar responsive-toolbar--single">
<Input.Search
placeholder="搜索姓名/用户名"
allowClear

View File

@@ -484,7 +484,8 @@ export class RbacService {
lastLoginAt: u.lastLoginAt,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
roles: u.roles?.map((r) => ({ id: r.id, name: r.name })) || [],
roles: u.roles?.map((r) => ({ id: r.id, code: r.code, name: r.name })) || [],
profile: u.profile || {},
}));
}