feat: improve admin responsive pages and teacher candidates

This commit is contained in:
2026-07-15 13:56:46 +08:00
parent fb9697bd05
commit d582d641c0
14 changed files with 406 additions and 87 deletions

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