- 移除教师端 hero 的英文眉标(TEACHING DAY),日期并入正文描述 - 删除不再使用的 attendance-eyebrow 样式 - Dashboard 的 ══ 装饰注释改为普通注释 kill-ai-slop 扫描: 18 处命中 → 7 处(剩余均为合理用法: 链接 hover 下划线、头像圆形角)
222 lines
7.2 KiB
TypeScript
222 lines
7.2 KiB
TypeScript
import React, { useCallback, useMemo, useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Button, Card, Col, Empty, Row, Spin, Tag, Tooltip } from 'antd';
|
||
import {
|
||
ArrowRightOutlined,
|
||
CheckCircleFilled,
|
||
ClockCircleOutlined,
|
||
ReloadOutlined,
|
||
ScheduleOutlined,
|
||
TeamOutlined,
|
||
} from '@ant-design/icons';
|
||
import dayjs from 'dayjs';
|
||
import api from '../../api';
|
||
import { QueryErrorState } from '../../components/QueryState';
|
||
import {
|
||
canPullAttendance,
|
||
getSchedulePhase,
|
||
type SchedulePhase,
|
||
} from './attendance-workspace';
|
||
import LessonAttendanceDetail from './LessonAttendanceDetail';
|
||
import type { LessonAttendanceSchedule } from './types';
|
||
|
||
type TodaySchedule = LessonAttendanceSchedule;
|
||
|
||
interface AssignedClass {
|
||
classId: number;
|
||
className: string;
|
||
classCode: string;
|
||
roleType: string;
|
||
subject: string;
|
||
}
|
||
|
||
interface TeacherWorkspaceData {
|
||
assignedClasses: AssignedClass[];
|
||
todaySchedules: TodaySchedule[];
|
||
}
|
||
|
||
export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ canCreate }) => {
|
||
const [selectedSchedule, setSelectedSchedule] = useState<TodaySchedule | null>(null);
|
||
|
||
const {
|
||
data: workspace,
|
||
isFetching,
|
||
isPending,
|
||
isError,
|
||
refetch,
|
||
} = useQuery<TeacherWorkspaceData | null>({
|
||
queryKey: ['attendance', 'workspace'],
|
||
queryFn: async () => {
|
||
return await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace');
|
||
},
|
||
});
|
||
// isPending 覆盖自动重试的退避窗口,避免「加载失败/重试中」短暂闪现为空态
|
||
const loading = isPending || isFetching;
|
||
const loadWorkspace = useCallback(() => refetch(), [refetch]);
|
||
|
||
const classNameById = useMemo(
|
||
() => new Map(workspace?.assignedClasses.map((item) => [item.classId, item.className]) ?? []),
|
||
[workspace],
|
||
);
|
||
|
||
const openAttendance = useCallback((schedule: TodaySchedule) => {
|
||
setSelectedSchedule(schedule);
|
||
}, []);
|
||
|
||
const now = new Date();
|
||
const schedules = workspace?.todaySchedules ?? [];
|
||
const startedCount = schedules.filter((item) =>
|
||
canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)),
|
||
).length;
|
||
const nextSchedule = schedules.find(
|
||
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
|
||
);
|
||
|
||
return (
|
||
<div className="attendance-page teacher-attendance">
|
||
<section className="attendance-hero attendance-hero--teacher">
|
||
<div>
|
||
<h1>今天,从课程开始</h1>
|
||
<p>
|
||
{dayjs().format('MM月DD日 dddd')} · 课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。
|
||
</p>
|
||
</div>
|
||
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
|
||
刷新
|
||
</Button>
|
||
</section>
|
||
|
||
<Row gutter={[16, 16]} className="teacher-overview">
|
||
<Col xs={24} md={8}>
|
||
<div className="teacher-kpi">
|
||
<span>今日课程</span>
|
||
<strong>{schedules.length}</strong>
|
||
<small>节</small>
|
||
</div>
|
||
</Col>
|
||
<Col xs={24} md={8}>
|
||
<div className="teacher-kpi">
|
||
<span>已开始</span>
|
||
<strong>{startedCount}</strong>
|
||
<small>节,可查看考勤</small>
|
||
</div>
|
||
</Col>
|
||
<Col xs={24} md={8}>
|
||
<div className="teacher-kpi teacher-kpi--next">
|
||
<span>下一节</span>
|
||
<strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong>
|
||
<small>{nextSchedule?.subject || '今天没有更多课程'}</small>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
|
||
<div className="attendance-section-heading">
|
||
<div>
|
||
<span>今日教学节奏</span>
|
||
<h2>我的课程</h2>
|
||
</div>
|
||
<span className="attendance-section-note">课程开始后可拉取钉钉考勤记录</span>
|
||
</div>
|
||
|
||
<Spin spinning={loading}>
|
||
{isError ? (
|
||
<Card className="attendance-empty-card">
|
||
<QueryErrorState
|
||
title="课程数据加载失败"
|
||
description="请检查网络后点击重试;若持续失败请联系管理员。"
|
||
onRetry={() => void loadWorkspace()}
|
||
/>
|
||
</Card>
|
||
) : schedules.length === 0 ? (
|
||
<Card className="attendance-empty-card">
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description={
|
||
<div>
|
||
<strong>今天还没有课程</strong>
|
||
<p>请联系教务管理员安排课程。</p>
|
||
</div>
|
||
}
|
||
/>
|
||
</Card>
|
||
) : (
|
||
<div className="lesson-timeline">
|
||
{schedules.map((schedule, index) => {
|
||
const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
|
||
return (
|
||
<LessonCard
|
||
key={schedule.id}
|
||
schedule={schedule}
|
||
phase={phase}
|
||
className={classNameById.get(schedule.classId) || `班级 ${schedule.classId}`}
|
||
index={index + 1}
|
||
onOpen={canCreate ? () => openAttendance(schedule) : undefined}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</Spin>
|
||
|
||
{canCreate ? (
|
||
<LessonAttendanceDetail
|
||
key={selectedSchedule?.id ?? 'closed'}
|
||
schedule={selectedSchedule}
|
||
className={
|
||
selectedSchedule
|
||
? classNameById.get(selectedSchedule.classId) || `班级 ${selectedSchedule.classId}`
|
||
: ''
|
||
}
|
||
onClose={() => setSelectedSchedule(null)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const LessonCard: React.FC<{
|
||
schedule: TodaySchedule;
|
||
phase: SchedulePhase;
|
||
className: string;
|
||
index: number;
|
||
onOpen?: () => void;
|
||
}> = ({ schedule, phase, className, index, onOpen }) => {
|
||
const phaseMeta = {
|
||
upcoming: { label: '待上课', icon: <ClockCircleOutlined />, tone: 'upcoming' },
|
||
ongoing: { label: '进行中', icon: <ScheduleOutlined />, tone: 'ongoing' },
|
||
ended: { label: '已结束', icon: <CheckCircleFilled />, tone: 'ended' },
|
||
}[phase];
|
||
|
||
return (
|
||
<article className={`lesson-card lesson-card--${phaseMeta.tone}`}>
|
||
<div className="lesson-sequence">{String(index).padStart(2, '0')}</div>
|
||
<div className="lesson-time">
|
||
<strong>{schedule.startTime}</strong>
|
||
<span />
|
||
<strong>{schedule.endTime}</strong>
|
||
</div>
|
||
<div className="lesson-main">
|
||
<div className="lesson-title-row">
|
||
<h3>{schedule.subject}</h3>
|
||
<Tag icon={phaseMeta.icon}>{phaseMeta.label}</Tag>
|
||
</div>
|
||
<p>
|
||
<TeamOutlined /> {className}
|
||
<span>教室 {schedule.classroomId}</span>
|
||
</p>
|
||
</div>
|
||
<div className="lesson-action">
|
||
{phase === 'upcoming' ? (
|
||
<Tooltip title="课程尚未开始">
|
||
<Button disabled>等待上课</Button>
|
||
</Tooltip>
|
||
) : onOpen ? (
|
||
<Button type="primary" onClick={onOpen}>
|
||
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</article>
|
||
);
|
||
};
|