forked from wangziqi/gongxue-base
feat: redesign teacher attendance workspace
This commit is contained in:
@@ -66,6 +66,102 @@
|
|||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 5px 18px rgb(23 32 51 / 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-topbar-select {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-workspace-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 168px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-filter-rail {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
align-self: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 5px 18px rgb(23 32 51 / 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-filter-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-filter-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-filter-hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-main-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-lesson-card {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border: 1px solid #d8e6fb;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(120deg, #f7fbff 0%, #fff 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-lesson-card .ant-card-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-lesson-copy > span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-lesson-copy h2 {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-lesson-copy p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-lesson-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.teacher-overview {
|
.teacher-overview {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
@@ -458,7 +554,8 @@
|
|||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.attendance-hero,
|
.attendance-hero,
|
||||||
.archive-toolbar {
|
.archive-toolbar,
|
||||||
|
.teacher-topbar {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -494,6 +591,23 @@
|
|||||||
grid-column: 2 / -1;
|
grid-column: 2 / -1;
|
||||||
padding: 14px 0 0;
|
padding: 14px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-workspace-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-filter-rail {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-lesson-card .ant-card-body {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-lesson-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
@@ -566,3 +680,502 @@
|
|||||||
color: #8c8c8c;
|
color: #8c8c8c;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Student attendance center — adapted from the provided class overview reference */
|
||||||
|
.student-attendance-center {
|
||||||
|
--student-bg: #f4f7f7;
|
||||||
|
--student-surface: #ffffff;
|
||||||
|
--student-soft: #f7faf9;
|
||||||
|
--student-ink: #17201e;
|
||||||
|
--student-muted: #66736f;
|
||||||
|
--student-quiet: #8a9692;
|
||||||
|
--student-line: #e1e8e5;
|
||||||
|
--student-primary: #157a65;
|
||||||
|
--student-primary-soft: #e8f4f0;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
margin: -24px;
|
||||||
|
padding: 0 24px 28px;
|
||||||
|
background: var(--student-bg);
|
||||||
|
color: var(--student-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-center-topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 12;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 60px;
|
||||||
|
margin: 0 -24px 18px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-bottom: 1px solid var(--student-line);
|
||||||
|
background: rgb(255 255 255 / 96%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-center-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-center-title h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-center-title span,
|
||||||
|
.student-sync-status {
|
||||||
|
color: var(--student-quiet);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-center-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-sync-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-sync-status i {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2f9c78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-filter-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(150px, 1fr)) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--student-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--student-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-filter-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-filter-field label {
|
||||||
|
color: var(--student-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-filter-field .ant-picker,
|
||||||
|
.student-filter-field .ant-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-class-overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, .9fr) minmax(0, 1.1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-class-identity,
|
||||||
|
.student-metric-strip,
|
||||||
|
.student-workspace,
|
||||||
|
.student-record-card {
|
||||||
|
border: 1px solid var(--student-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--student-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-class-identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 156px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-class-identity h2 {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-class-identity span {
|
||||||
|
color: var(--student-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-teacher-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-teacher-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 9px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--student-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-teacher-item .ant-avatar {
|
||||||
|
color: var(--student-primary);
|
||||||
|
background: var(--student-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-teacher-item strong,
|
||||||
|
.student-teacher-item span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-teacher-item strong {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-metric-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(82px, 1fr));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-metric-card {
|
||||||
|
appearance: none;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 156px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--student-line);
|
||||||
|
background: #fff;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-metric-card:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-metric-card:hover,
|
||||||
|
.student-metric-card.active {
|
||||||
|
background: var(--student-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-metric-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 26px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-metric-card strong {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-metric-card small {
|
||||||
|
color: var(--student-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-workspace {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 0 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-workspace-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 70px;
|
||||||
|
border-bottom: 1px solid var(--student-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-workspace-header h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-workspace-header span {
|
||||||
|
color: var(--student-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-workspace-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-workspace-tools .ant-input-search {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
color: var(--student-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-legend span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-legend i {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-legend i.is-present { background: #2f9c78; }
|
||||||
|
.student-legend i.is-late { background: #d78a18; }
|
||||||
|
.student-legend i.is-leave { background: #397bbf; }
|
||||||
|
.student-legend i.is-absent { background: #d44d4d; }
|
||||||
|
.student-legend i.is-pending { background: #8a9692; }
|
||||||
|
|
||||||
|
.student-legend em {
|
||||||
|
color: var(--student-quiet);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(184px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-attendance-card {
|
||||||
|
appearance: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid var(--student-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-attendance-card:hover,
|
||||||
|
.student-attendance-card.selected {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: #91c8b9;
|
||||||
|
box-shadow: 0 8px 20px rgb(24 44 38 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-attendance-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-attendance-head strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-attendance-head small {
|
||||||
|
color: var(--student-quiet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-status-blocks {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-status-block {
|
||||||
|
display: grid;
|
||||||
|
min-height: 28px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-record-card {
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-record-card .ant-card-body {
|
||||||
|
padding: 0 18px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-profile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--student-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-profile h4 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-profile p,
|
||||||
|
.student-detail-rate span {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--student-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-rate {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-rate strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--student-primary);
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-rates {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-rates > div {
|
||||||
|
padding: 12px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--student-soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-rates strong,
|
||||||
|
.student-detail-rates span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-rates span {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--student-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-section h5 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-timeline > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px 96px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--student-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-detail-timeline > div > span:first-child {
|
||||||
|
color: var(--student-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-trend-bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 8px;
|
||||||
|
height: 110px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--student-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-trend-bars i {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 18px;
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
background: linear-gradient(180deg, #56bea3, #157a65);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.student-filter-panel,
|
||||||
|
.student-class-overview {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-filter-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-metric-strip {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.student-attendance-center {
|
||||||
|
margin: -16px;
|
||||||
|
padding: 0 16px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-center-topbar,
|
||||||
|
.student-workspace-header,
|
||||||
|
.student-center-actions,
|
||||||
|
.student-legend {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-filter-panel,
|
||||||
|
.student-class-overview {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-workspace-tools,
|
||||||
|
.student-workspace-tools .ant-input-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ import {
|
|||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
@@ -183,45 +185,6 @@ function AttendanceStatusTag({ status }: { status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryStrip({ summary }: { summary: AttendanceSummary }) {
|
|
||||||
const rate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0;
|
|
||||||
return (
|
|
||||||
<div className="attendance-summary-strip">
|
|
||||||
<div className="attendance-rate">
|
|
||||||
<Progress
|
|
||||||
type="circle"
|
|
||||||
percent={rate}
|
|
||||||
size={64}
|
|
||||||
strokeWidth={9}
|
|
||||||
strokeColor="#1677ff"
|
|
||||||
trailColor="#e8eef7"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span>出勤率</span>
|
|
||||||
<strong>{summary.total} 条记录</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{[
|
|
||||||
['present', summary.present],
|
|
||||||
['late', summary.late],
|
|
||||||
['absent', summary.absent],
|
|
||||||
['leave', summary.leave],
|
|
||||||
].map(([status, value]) => {
|
|
||||||
const meta = STATUS_META[String(status)];
|
|
||||||
return (
|
|
||||||
<div className="attendance-summary-cell" key={String(status)}>
|
|
||||||
<span className={`attendance-summary-icon ${meta.className}`}>{meta.short}</span>
|
|
||||||
<div>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
<span>{meta.label}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) {
|
function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) {
|
||||||
const summary = summarizeLessonCheckins(records);
|
const summary = summarizeLessonCheckins(records);
|
||||||
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
|
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
|
||||||
@@ -265,6 +228,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [studentKeyword, setStudentKeyword] = useState('');
|
const [studentKeyword, setStudentKeyword] = useState('');
|
||||||
const [checkinFilter, setCheckinFilter] = useState<LessonAttendanceFilter>('all');
|
const [checkinFilter, setCheckinFilter] = useState<LessonAttendanceFilter>('all');
|
||||||
|
const [selectedClassId, setSelectedClassId] = useState<number | 'all'>('all');
|
||||||
|
const [phaseFilter, setPhaseFilter] = useState<SchedulePhase | 'all'>('all');
|
||||||
|
|
||||||
const loadWorkspace = useCallback(async () => {
|
const loadWorkspace = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -286,6 +251,17 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
[workspace],
|
[workspace],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const classFilterOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'all' as const, label: '全部教学班' },
|
||||||
|
...(workspace?.assignedClasses.map((item) => ({
|
||||||
|
value: item.classId,
|
||||||
|
label: item.className,
|
||||||
|
})) ?? []),
|
||||||
|
],
|
||||||
|
[workspace],
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
|
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
|
||||||
setStudentKeyword('');
|
setStudentKeyword('');
|
||||||
@@ -324,7 +300,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
setLessonRecords((items) =>
|
setLessonRecords((items) =>
|
||||||
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
|
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
|
||||||
);
|
);
|
||||||
message.error((error as { message?: string })?.message || '更新考勤失败');
|
message.error((error as { message?: string })?.message || '更新课堂考勤失败');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -339,6 +315,16 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
const nextSchedule = schedules.find(
|
const nextSchedule = schedules.find(
|
||||||
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
|
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
|
||||||
);
|
);
|
||||||
|
const filteredSchedules = schedules.filter((item) => {
|
||||||
|
const phase = getSchedulePhase(item.startTime, item.endTime, now);
|
||||||
|
const matchesClass = selectedClassId === 'all' || item.classId === selectedClassId;
|
||||||
|
const matchesPhase = phaseFilter === 'all' || phase === phaseFilter;
|
||||||
|
return matchesClass && matchesPhase;
|
||||||
|
});
|
||||||
|
const currentFocusSchedule = nextSchedule ?? schedules.at(-1) ?? null;
|
||||||
|
const currentFocusClassName = currentFocusSchedule
|
||||||
|
? classNameById.get(currentFocusSchedule.classId) || `班级 ${currentFocusSchedule.classId}`
|
||||||
|
: '暂无教学班';
|
||||||
const isAttendanceCompleted = lessonSession?.status === 'completed';
|
const isAttendanceCompleted = lessonSession?.status === 'completed';
|
||||||
const filteredLessonRecords = useMemo(
|
const filteredLessonRecords = useMemo(
|
||||||
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
|
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
|
||||||
@@ -347,72 +333,135 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="attendance-page teacher-attendance">
|
<div className="attendance-page teacher-attendance">
|
||||||
|
<section className="teacher-topbar" aria-label="任课老师课堂考勤导航">
|
||||||
|
<Space size={10} wrap>
|
||||||
|
<Button icon={<CalendarOutlined />}>{dayjs().format('YYYY-MM-DD')}</Button>
|
||||||
|
<Select<number | 'all'>
|
||||||
|
value={selectedClassId}
|
||||||
|
onChange={setSelectedClassId}
|
||||||
|
options={classFilterOptions}
|
||||||
|
className="teacher-topbar-select"
|
||||||
|
/>
|
||||||
|
<Button type="primary">课堂考勤</Button>
|
||||||
|
<Button onClick={() => setPhaseFilter('all')}>今日课程</Button>
|
||||||
|
<Button onClick={() => setPhaseFilter('upcoming')}>我的课表</Button>
|
||||||
|
<Button onClick={() => setPhaseFilter('ended')}>考勤统计</Button>
|
||||||
|
</Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
|
||||||
|
刷新课程
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="attendance-hero attendance-hero--teacher">
|
<section className="attendance-hero attendance-hero--teacher">
|
||||||
<div>
|
<div>
|
||||||
<span className="attendance-eyebrow">TEACHING DAY · {dayjs().format('MM月DD日 dddd')}</span>
|
<span className="attendance-eyebrow">LESSON ATTENDANCE · {dayjs().format('MM月DD日 dddd')}</span>
|
||||||
<h1>今天,从课程开始</h1>
|
<h1>任课老师课堂考勤</h1>
|
||||||
<p>课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。</p>
|
<p>围绕“我的课程、本节课、教学班级”查看学生签到,课程开始后可拉取最新结果并处理未签到学生。</p>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
|
<Button type="primary" ghost icon={<FileSearchOutlined />} onClick={() => currentFocusSchedule && void openAttendance(currentFocusSchedule)} disabled={!currentFocusSchedule || getSchedulePhase(currentFocusSchedule.startTime, currentFocusSchedule.endTime, now) === 'upcoming'}>
|
||||||
刷新
|
查看本节考勤
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Row gutter={[16, 16]} className="teacher-overview">
|
<Row gutter={[16, 16]} className="teacher-overview">
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<div className="teacher-kpi"><span>今日课程</span><strong>{schedules.length}</strong><small>节</small></div>
|
<div className="teacher-kpi"><span>今日课程</span><strong>{schedules.length}</strong><small>节待查看</small></div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<div className="teacher-kpi"><span>已开始</span><strong>{startedCount}</strong><small>节,可查看考勤</small></div>
|
<div className="teacher-kpi"><span>已开始课程</span><strong>{startedCount}</strong><small>节,可处理课堂考勤</small></div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<div className="teacher-kpi teacher-kpi--next"><span>下一节</span><strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong><small>{nextSchedule?.subject || '今天没有更多课程'}</small></div>
|
<div className="teacher-kpi teacher-kpi--next"><span>当前/下一节</span><strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong><small>{nextSchedule?.subject || '今天没有更多课程'}</small></div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<div className="attendance-section-heading">
|
<div className="teacher-workspace-layout">
|
||||||
<div><span>今日教学节奏</span><h2>我的课程</h2></div>
|
<aside className="teacher-filter-rail">
|
||||||
<span className="attendance-section-note">课程开始后可拉取钉钉考勤记录</span>
|
<div className="teacher-filter-title"><FilterOutlined /> 左侧筛选</div>
|
||||||
</div>
|
<Button block type={phaseFilter === 'all' && selectedClassId === 'all' ? 'primary' : 'default'} onClick={() => { setPhaseFilter('all'); setSelectedClassId('all'); }}>今日课程</Button>
|
||||||
|
<Button block type={phaseFilter === 'ongoing' ? 'primary' : 'default'} onClick={() => setPhaseFilter('ongoing')}>进行中</Button>
|
||||||
|
<Button block type={phaseFilter === 'ended' ? 'primary' : 'default'} onClick={() => setPhaseFilter('ended')}>已结束</Button>
|
||||||
|
<Button block type={phaseFilter === 'upcoming' ? 'primary' : 'default'} onClick={() => setPhaseFilter('upcoming')}>待上课</Button>
|
||||||
|
<Select<number | 'all'>
|
||||||
|
value={selectedClassId}
|
||||||
|
onChange={setSelectedClassId}
|
||||||
|
options={classFilterOptions}
|
||||||
|
className="teacher-filter-select"
|
||||||
|
/>
|
||||||
|
<div className="teacher-filter-hint">按教学班级、课堂状态快速定位需要处理的学生考勤。</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<Spin spinning={loading}>
|
<main className="teacher-main-panel">
|
||||||
{schedules.length === 0 ? (
|
<Card className="current-lesson-card">
|
||||||
<Card className="attendance-empty-card">
|
<div className="current-lesson-copy">
|
||||||
<Empty
|
<span>主页主功能区</span>
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
<h2>{currentFocusSchedule?.subject || '暂无待处理课程'}</h2>
|
||||||
description={<div><strong>今天还没有课程</strong><p>请联系教务管理员安排课程。</p></div>}
|
<p>
|
||||||
/>
|
{currentFocusSchedule
|
||||||
|
? `${currentFocusClassName} · ${currentFocusSchedule.startTime}-${currentFocusSchedule.endTime} · 教室 ${currentFocusSchedule.classroomId}`
|
||||||
|
: '今天没有课程,可切换日期查看其他课堂考勤。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="current-lesson-actions">
|
||||||
|
<Button type="primary" disabled={!currentFocusSchedule || getSchedulePhase(currentFocusSchedule.startTime, currentFocusSchedule.endTime, now) === 'upcoming'} onClick={() => currentFocusSchedule && void openAttendance(currentFocusSchedule)}>
|
||||||
|
查看本节考勤 / 发起签到
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ExportOutlined />} disabled>导出本节课记录</Button>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
|
||||||
<div className="lesson-timeline">
|
<div className="attendance-section-heading">
|
||||||
{schedules.map((schedule, index) => {
|
<div><span>课堂考勤列表</span><h2>我的课程</h2></div>
|
||||||
const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
|
<span className="attendance-section-note">课程开始后可查看本节课学生签到和异常</span>
|
||||||
return (
|
|
||||||
<LessonCard
|
|
||||||
key={schedule.id}
|
|
||||||
schedule={schedule}
|
|
||||||
phase={phase}
|
|
||||||
className={classNameById.get(schedule.classId) || `班级 ${schedule.classId}`}
|
|
||||||
index={index + 1}
|
|
||||||
onOpen={() => void openAttendance(schedule)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</Spin>
|
<Spin spinning={loading}>
|
||||||
|
{schedules.length === 0 ? (
|
||||||
|
<Card className="attendance-empty-card">
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={<div><strong>今天还没有课程</strong><p>请联系教务管理员安排课程。</p></div>}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : filteredSchedules.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">
|
||||||
|
{filteredSchedules.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={() => void openAttendance(schedule)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
|
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
|
||||||
<div className="lesson-record-header">
|
<div className="lesson-record-header">
|
||||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
<h2>{selectedSchedule?.subject || '本节课考勤'}</h2>
|
||||||
<p>{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}</p>
|
<p>{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · 本节课 {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}</p>
|
||||||
</div>
|
</div>
|
||||||
{lessonSession && (
|
{lessonSession && (
|
||||||
<Alert
|
<Alert
|
||||||
type={isAttendanceCompleted ? 'success' : 'info'}
|
type={isAttendanceCompleted ? 'success' : 'info'}
|
||||||
showIcon
|
showIcon
|
||||||
title={isAttendanceCompleted ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
|
title={isAttendanceCompleted ? '本节课考勤已结算' : '当前为本节课实时签到结果;课程截止后将自动做最终结算'}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -430,8 +479,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
onChange={setCheckinFilter}
|
onChange={setCheckinFilter}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'all', label: '全部学生' },
|
{ value: 'all', label: '全部学生' },
|
||||||
{ value: 'checked_in', label: '已打卡' },
|
{ value: 'checked_in', label: '已到学生' },
|
||||||
{ value: 'not_checked_in', label: '未打卡' },
|
{ value: 'not_checked_in', label: '未签到/旷课' },
|
||||||
]}
|
]}
|
||||||
className="lesson-record-filter-select"
|
className="lesson-record-filter-select"
|
||||||
/>
|
/>
|
||||||
@@ -447,7 +496,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
locale={{
|
locale={{
|
||||||
emptyText: <Empty
|
emptyText: <Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description={lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
|
description={lessonRecords.length === 0 ? '本节课尚未产生签到记录' : '没有符合条件的学生'}
|
||||||
/>,
|
/>,
|
||||||
}}
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
@@ -456,7 +505,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
render: (name: string) => <div className="student-cell"><Avatar size={32}>{name?.slice(0, 1)}</Avatar><strong>{name || '-'}</strong></div>,
|
render: (name: string) => <div className="student-cell"><Avatar size={32}>{name?.slice(0, 1)}</Avatar><strong>{name || '-'}</strong></div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '考勤结果', dataIndex: 'status', width: 230,
|
title: '课堂考勤操作', dataIndex: 'status', width: 250,
|
||||||
render: (value: string, record: AttendanceRecordItem) => {
|
render: (value: string, record: AttendanceRecordItem) => {
|
||||||
const checkedIn = value === 'present' || value === 'late';
|
const checkedIn = value === 'present' || value === 'late';
|
||||||
return (
|
return (
|
||||||
@@ -466,7 +515,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
type={checkedIn ? 'primary' : 'default'}
|
type={checkedIn ? 'primary' : 'default'}
|
||||||
onClick={() => void updateLessonRecord(record, 'present')}
|
onClick={() => void updateLessonRecord(record, 'present')}
|
||||||
>
|
>
|
||||||
已打卡
|
标记已到
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -474,15 +523,15 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
danger={!checkedIn}
|
danger={!checkedIn}
|
||||||
onClick={() => void updateLessonRecord(record, 'absent')}
|
onClick={() => void updateLessonRecord(record, 'absent')}
|
||||||
>
|
>
|
||||||
未打卡
|
标记未签
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
{ title: '当前状态', dataIndex: 'status', width: 118, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
||||||
{
|
{
|
||||||
title: '打卡设备',
|
title: '签到来源',
|
||||||
width: 220,
|
width: 220,
|
||||||
render: (_: unknown, record: AttendanceRecordItem) => {
|
render: (_: unknown, record: AttendanceRecordItem) => {
|
||||||
const info = getPunchDisplayInfo(record);
|
const info = getPunchDisplayInfo(record);
|
||||||
@@ -496,7 +545,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
{ title: '备注/异常处理', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">暂无备注</span> },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -530,7 +579,7 @@ const LessonCard: React.FC<{
|
|||||||
<Tooltip title="课程尚未开始"><Button disabled>等待上课</Button></Tooltip>
|
<Tooltip title="课程尚未开始"><Button disabled>等待上课</Button></Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Button type="primary" onClick={onOpen}>
|
<Button type="primary" onClick={onOpen}>
|
||||||
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
|
{phase === 'ongoing' ? '查看本节考勤' : '拉取 / 查看本节考勤'} <ArrowRightOutlined />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -539,6 +588,71 @@ const LessonCard: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
interface AdminStudentPanel {
|
||||||
|
key: string;
|
||||||
|
studentId: number;
|
||||||
|
studentName: string;
|
||||||
|
className: string;
|
||||||
|
records: AttendanceRecordItem[];
|
||||||
|
statusBySession: Partial<Record<string, AttendanceRecordItem>>;
|
||||||
|
primaryStatus: string;
|
||||||
|
rate: number;
|
||||||
|
latestDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_PERIODS = [
|
||||||
|
{ key: 'morning_reading', label: '早读' },
|
||||||
|
{ key: 'morning', label: '上午' },
|
||||||
|
{ key: 'afternoon', label: '下午' },
|
||||||
|
{ key: 'evening_study', label: '晚自习' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ADMIN_METRIC_META = [
|
||||||
|
{ key: 'all', label: '出勤率', short: '率' },
|
||||||
|
{ key: 'present', label: '正常', short: '正常' },
|
||||||
|
{ key: 'late', label: '迟到', short: '迟到' },
|
||||||
|
{ key: 'leave', label: '请假', short: '请假' },
|
||||||
|
{ key: 'absent', label: '缺勤', short: '缺勤' },
|
||||||
|
{ key: 'pending', label: '未打卡', short: '待核' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
|
||||||
|
const priority = ['absent', 'late', 'leave', 'pending', 'present'];
|
||||||
|
return priority.find((item) => records.some((record) => record.status === item)) || 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] {
|
||||||
|
const map = new Map<number, AdminStudentPanel>();
|
||||||
|
for (const record of records) {
|
||||||
|
const current = map.get(record.studentId) ?? {
|
||||||
|
key: String(record.studentId),
|
||||||
|
studentId: record.studentId,
|
||||||
|
studentName: record.student?.name || '未知学生',
|
||||||
|
className: record.class?.name || '未关联班级',
|
||||||
|
records: [],
|
||||||
|
statusBySession: {},
|
||||||
|
primaryStatus: 'pending',
|
||||||
|
rate: 0,
|
||||||
|
latestDate: record.attendanceDate,
|
||||||
|
};
|
||||||
|
current.records.push(record);
|
||||||
|
if (!current.statusBySession[record.session]) current.statusBySession[record.session] = record;
|
||||||
|
if (dayjs(record.attendanceDate).isAfter(dayjs(current.latestDate))) {
|
||||||
|
current.latestDate = record.attendanceDate;
|
||||||
|
}
|
||||||
|
map.set(record.studentId, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values()).map((item) => {
|
||||||
|
const checked = item.records.filter((record) => record.status === 'present' || record.status === 'late').length;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
primaryStatus: pickPrimaryStatus(item.records),
|
||||||
|
rate: item.records.length > 0 ? Math.round((checked / item.records.length) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
const [records, setRecords] = useState<AttendanceRecordItem[]>([]);
|
const [records, setRecords] = useState<AttendanceRecordItem[]>([]);
|
||||||
const [summary, setSummary] = useState<AttendanceSummary>(EMPTY_SUMMARY);
|
const [summary, setSummary] = useState<AttendanceSummary>(EMPTY_SUMMARY);
|
||||||
@@ -546,15 +660,15 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
|
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(48);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [classId, setClassId] = useState<number>();
|
const [classId, setClassId] = useState<number>();
|
||||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([
|
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([dayjs(), dayjs()]);
|
||||||
dayjs().subtract(30, 'day'),
|
|
||||||
dayjs(),
|
|
||||||
]);
|
|
||||||
const [status, setStatus] = useState<string>();
|
const [status, setStatus] = useState<string>();
|
||||||
const [session, setSession] = useState<string>();
|
const [session, setSession] = useState<string>();
|
||||||
|
const [metricFilter, setMetricFilter] = useState('all');
|
||||||
|
const [studentSearch, setStudentSearch] = useState('');
|
||||||
|
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
|
||||||
const [editRecord, setEditRecord] = useState<AttendanceRecordItem | null>(null);
|
const [editRecord, setEditRecord] = useState<AttendanceRecordItem | null>(null);
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
|
|
||||||
@@ -587,7 +701,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
setTotal(recordData.total);
|
setTotal(recordData.total);
|
||||||
setSummary({ ...EMPTY_SUMMARY, ...summaryData });
|
setSummary({ ...EMPTY_SUMMARY, ...summaryData });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
message.error((error as { message?: string })?.message || '加载历史考勤失败');
|
message.error((error as { message?: string })?.message || '加载学生考勤失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -606,9 +720,11 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setClassId(undefined);
|
setClassId(undefined);
|
||||||
setDateRange([dayjs().subtract(30, 'day'), dayjs()]);
|
setDateRange([dayjs(), dayjs()]);
|
||||||
setStatus(undefined);
|
setStatus(undefined);
|
||||||
setSession(undefined);
|
setSession(undefined);
|
||||||
|
setMetricFilter('all');
|
||||||
|
setStudentSearch('');
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -627,7 +743,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = url;
|
anchor.href = url;
|
||||||
anchor.download = `历史考勤-${dayjs().format('YYYYMMDD')}.xlsx`;
|
anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`;
|
||||||
anchor.click();
|
anchor.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
})
|
})
|
||||||
@@ -648,6 +764,28 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const studentPanels = useMemo(() => buildAdminStudentPanels(records), [records]);
|
||||||
|
const visibleStudents = useMemo(() => {
|
||||||
|
const query = studentSearch.trim().toLocaleLowerCase('zh-CN');
|
||||||
|
return studentPanels.filter((student) => {
|
||||||
|
const matchesQuery =
|
||||||
|
!query ||
|
||||||
|
student.studentName.toLocaleLowerCase('zh-CN').includes(query) ||
|
||||||
|
String(student.studentId).includes(query);
|
||||||
|
const matchesMetric =
|
||||||
|
metricFilter === 'all' || student.records.some((record) => record.status === metricFilter);
|
||||||
|
return matchesQuery && matchesMetric;
|
||||||
|
});
|
||||||
|
}, [metricFilter, studentPanels, studentSearch]);
|
||||||
|
|
||||||
|
const selectedClass = classId
|
||||||
|
? classOptions.find((item) => item.classId === classId)?.className || `班级 ${classId}`
|
||||||
|
: '全部班级';
|
||||||
|
const attendanceRate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0;
|
||||||
|
const dateLabel = dateRange?.[0]?.isSame(dateRange?.[1], 'day')
|
||||||
|
? dateRange?.[0]?.format('YYYY-MM-DD')
|
||||||
|
: `${dateRange?.[0]?.format('YYYY-MM-DD') || '开始日期'} 至 ${dateRange?.[1]?.format('YYYY-MM-DD') || '结束日期'}`;
|
||||||
|
|
||||||
const columns: ColumnsType<AttendanceRecordItem> = [
|
const columns: ColumnsType<AttendanceRecordItem> = [
|
||||||
{
|
{
|
||||||
title: '学生',
|
title: '学生',
|
||||||
@@ -672,19 +810,13 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
render: (value: string) => SESSION_MAP[value] || value,
|
render: (value: string) => SESSION_MAP[value] || value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '结果',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 110,
|
width: 105,
|
||||||
render: (value: string) => <AttendanceStatusTag status={value} />,
|
render: (value: string) => <AttendanceStatusTag status={value} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '记录来源',
|
title: '签到来源',
|
||||||
dataIndex: 'source',
|
|
||||||
width: 110,
|
|
||||||
render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '打卡设备',
|
|
||||||
width: 220,
|
width: 220,
|
||||||
render: (_: unknown, record: AttendanceRecordItem) => {
|
render: (_: unknown, record: AttendanceRecordItem) => {
|
||||||
const info = getPunchDisplayInfo(record);
|
const info = getPunchDisplayInfo(record);
|
||||||
@@ -704,12 +836,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '归档时间',
|
|
||||||
dataIndex: 'createdAt',
|
|
||||||
width: 165,
|
|
||||||
render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'),
|
|
||||||
},
|
|
||||||
...(canEdit
|
...(canEdit
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -735,24 +861,109 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="attendance-page admin-attendance">
|
<div className="attendance-page admin-attendance student-attendance-center">
|
||||||
<section className="attendance-hero attendance-hero--admin">
|
<header className="student-center-topbar">
|
||||||
<div>
|
<div className="student-center-title">
|
||||||
<span className="attendance-eyebrow">ATTENDANCE ARCHIVE</span>
|
<h1>学生考勤中心</h1>
|
||||||
<h1>历史考勤档案</h1>
|
<span>班级考勤总览</span>
|
||||||
<p>面向管理人员的历史检索、异常追踪与数据归档,不承载实时上课操作。</p>
|
</div>
|
||||||
|
<div className="student-center-actions">
|
||||||
|
<span className="student-sync-status"><i />数据已更新 {dayjs().format('HH:mm')}</span>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void loadRecords()}>刷新</Button>
|
||||||
|
<PermissionButton permission="attendance:export" icon={<ExportOutlined />} onClick={handleExport}>
|
||||||
|
导出当前报表
|
||||||
|
</PermissionButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="student-filter-panel" aria-label="考勤筛选">
|
||||||
|
<div className="student-filter-field student-filter-field--date">
|
||||||
|
<label>日期</label>
|
||||||
|
<RangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(value) => {
|
||||||
|
setDateRange(value as [Dayjs, Dayjs] | null);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="student-filter-field">
|
||||||
|
<label>班级</label>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="全部班级"
|
||||||
|
value={classId}
|
||||||
|
onChange={(value) => {
|
||||||
|
setClassId(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={classOptions.map((item) => ({ value: item.classId, label: item.className }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="student-filter-field">
|
||||||
|
<label>科目</label>
|
||||||
|
<Select disabled placeholder="全部科目" />
|
||||||
|
</div>
|
||||||
|
<div className="student-filter-field">
|
||||||
|
<label>任课老师</label>
|
||||||
|
<Select disabled placeholder="全部老师" />
|
||||||
|
</div>
|
||||||
|
<div className="student-filter-field">
|
||||||
|
<label>时段</label>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部时段"
|
||||||
|
value={session}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSession(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={SESSION_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="student-filter-actions">
|
||||||
|
<Button onClick={resetFilters}>重置</Button>
|
||||||
|
<Button type="primary" icon={<FileSearchOutlined />} onClick={() => void loadRecords()}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<PermissionButton
|
|
||||||
permission="attendance:export"
|
|
||||||
icon={<ExportOutlined />}
|
|
||||||
size="large"
|
|
||||||
onClick={handleExport}
|
|
||||||
>
|
|
||||||
导出当前结果
|
|
||||||
</PermissionButton>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<SummaryStrip summary={summary} />
|
<section className="student-class-overview" aria-label="班级考勤汇总">
|
||||||
|
<div className="student-class-identity">
|
||||||
|
<div>
|
||||||
|
<h2>{selectedClass}</h2>
|
||||||
|
<span>{dateLabel} · 当前展示 {visibleStudents.length} 名学生 / {total} 条记录</span>
|
||||||
|
</div>
|
||||||
|
<div className="student-teacher-list">
|
||||||
|
<div className="student-teacher-item"><Avatar>班</Avatar><div><span>班主任</span><strong>按班级筛选后查看</strong></div></div>
|
||||||
|
<div className="student-teacher-item"><Avatar>生</Avatar><div><span>生活老师</span><strong>暂未接入</strong></div></div>
|
||||||
|
<div className="student-teacher-item"><Avatar>任</Avatar><div><span>当前任课</span><strong>{session ? SESSION_MAP[session] : '全部时段'}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="student-metric-strip">
|
||||||
|
{ADMIN_METRIC_META.map((metric) => {
|
||||||
|
const value = metric.key === 'all'
|
||||||
|
? `${attendanceRate}%`
|
||||||
|
: summary[metric.key as keyof AttendanceSummary] ?? 0;
|
||||||
|
const meta = STATUS_META[metric.key] ?? { className: 'is-present' };
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={metric.key}
|
||||||
|
className={`student-metric-card ${metricFilter === metric.key ? 'active' : ''}`}
|
||||||
|
onClick={() => setMetricFilter(metric.key)}
|
||||||
|
>
|
||||||
|
<span className={`student-metric-icon ${meta.className}`}>{metric.short}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
<small>{metric.label}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{alerts.length > 0 && (
|
{alerts.length > 0 && (
|
||||||
<div className="archive-alert">
|
<div className="archive-alert">
|
||||||
@@ -767,70 +978,76 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="archive-card" bordered={false}>
|
<section className="student-workspace">
|
||||||
<div className="archive-toolbar">
|
<header className="student-workspace-header">
|
||||||
<div className="archive-toolbar__title">
|
<div>
|
||||||
<FileSearchOutlined />
|
<h3>班级学生考勤</h3>
|
||||||
<div>
|
<span>{metricFilter === 'all' ? `显示全部 ${visibleStudents.length} 名学生` : `筛出 ${ADMIN_METRIC_META.find((item) => item.key === metricFilter)?.label || ''}相关 ${visibleStudents.length} 名学生`}</span>
|
||||||
<strong>档案检索</strong>
|
|
||||||
<span>默认查看最近 30 天</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Space wrap size={10}>
|
<div className="student-workspace-tools">
|
||||||
<Select
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
showSearch
|
placeholder="搜索姓名或学号"
|
||||||
optionFilterProp="label"
|
value={studentSearch}
|
||||||
placeholder="全部班级"
|
onChange={(event) => setStudentSearch(event.target.value)}
|
||||||
value={classId}
|
|
||||||
onChange={(value) => {
|
|
||||||
setClassId(value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
options={classOptions.map((item) => ({ value: item.classId, label: item.className }))}
|
|
||||||
style={{ width: 170 }}
|
|
||||||
/>
|
/>
|
||||||
<RangePicker
|
<Button icon={<ExportOutlined />} onClick={handleExport}>导出</Button>
|
||||||
value={dateRange}
|
</div>
|
||||||
onChange={(value) => {
|
</header>
|
||||||
setDateRange(value as [Dayjs, Dayjs] | null);
|
<div className="student-legend">
|
||||||
setPage(1);
|
<span><i className="is-present" />正常</span>
|
||||||
}}
|
<span><i className="is-late" />迟到</span>
|
||||||
/>
|
<span><i className="is-leave" />请假</span>
|
||||||
<Select
|
<span><i className="is-absent" />缺勤</span>
|
||||||
allowClear
|
<span><i className="is-pending" />未打卡</span>
|
||||||
placeholder="考勤结果"
|
<em>每名学生依次显示:早读 / 上午 / 下午 / 晚自习</em>
|
||||||
value={status}
|
|
||||||
onChange={(value) => {
|
|
||||||
setStatus(value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
options={STATUS_OPTIONS}
|
|
||||||
style={{ width: 130 }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
placeholder="课程时段"
|
|
||||||
value={session}
|
|
||||||
onChange={(value) => {
|
|
||||||
setSession(value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
options={SESSION_OPTIONS}
|
|
||||||
style={{ width: 130 }}
|
|
||||||
/>
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{visibleStudents.length === 0 ? (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配学生,请调整筛选条件或搜索关键词" />
|
||||||
|
) : (
|
||||||
|
<div className="student-card-grid">
|
||||||
|
{visibleStudents.map((student) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={student.key}
|
||||||
|
className={`student-attendance-card ${selectedStudent?.studentId === student.studentId ? 'selected' : ''}`}
|
||||||
|
onClick={() => setSelectedStudent(student)}
|
||||||
|
>
|
||||||
|
<span className="student-attendance-head">
|
||||||
|
<strong>{student.studentName}</strong>
|
||||||
|
<small>{String(student.studentId).slice(-4)}</small>
|
||||||
|
</span>
|
||||||
|
<span className="student-status-blocks">
|
||||||
|
{ADMIN_PERIODS.map((period) => {
|
||||||
|
const record = student.statusBySession[period.key];
|
||||||
|
const currentStatus = record?.status || 'pending';
|
||||||
|
const meta = STATUS_META[currentStatus] ?? STATUS_META.pending;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={period.key}
|
||||||
|
className={`student-status-block ${meta.className}`}
|
||||||
|
title={`${period.label}:${meta.label}`}
|
||||||
|
>
|
||||||
|
{meta.label === '出勤' ? '正常' : meta.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="student-record-card" bordered={false} title="原始考勤明细">
|
||||||
<Table<AttendanceRecordItem>
|
<Table<AttendanceRecordItem>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={records}
|
dataSource={records}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 1050 }}
|
scroll={{ x: 950 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -850,6 +1067,70 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={Boolean(selectedStudent)}
|
||||||
|
onClose={() => setSelectedStudent(null)}
|
||||||
|
width={520}
|
||||||
|
title="学生考勤明细"
|
||||||
|
className="student-detail-drawer"
|
||||||
|
>
|
||||||
|
{selectedStudent && (
|
||||||
|
<div className="student-detail-panel">
|
||||||
|
<section className="student-detail-profile">
|
||||||
|
<Avatar size={54}>{selectedStudent.studentName.slice(0, 1)}</Avatar>
|
||||||
|
<div>
|
||||||
|
<h4>{selectedStudent.studentName}</h4>
|
||||||
|
<p>{selectedStudent.className} · 学号 {selectedStudent.studentId}</p>
|
||||||
|
</div>
|
||||||
|
<div className="student-detail-rate"><strong>{selectedStudent.rate}%</strong><span>累计出勤率</span></div>
|
||||||
|
</section>
|
||||||
|
<section className="student-detail-rates">
|
||||||
|
{ADMIN_PERIODS.map((period) => {
|
||||||
|
const record = selectedStudent.statusBySession[period.key];
|
||||||
|
const normal = record?.status === 'present' || record?.status === 'late';
|
||||||
|
return <div key={period.key}><strong>{record ? (normal ? '100%' : '0%') : '—'}</strong><span>{period.label}</span></div>;
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
<section className="student-detail-section">
|
||||||
|
<h5>当天打卡时间</h5>
|
||||||
|
<div className="student-detail-timeline">
|
||||||
|
{ADMIN_PERIODS.map((period) => {
|
||||||
|
const record = selectedStudent.statusBySession[period.key];
|
||||||
|
return (
|
||||||
|
<div key={period.key}>
|
||||||
|
<span>{period.label}</span>
|
||||||
|
<AttendanceStatusTag status={record?.status || 'pending'} />
|
||||||
|
<strong>{record?.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'}</strong>
|
||||||
|
{canEdit && record && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
onClick={() => {
|
||||||
|
setEditRecord(record);
|
||||||
|
editForm.setFieldsValue({ status: record.status, remark: record.remark });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
纠错
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="student-detail-section">
|
||||||
|
<h5>近 7 日趋势</h5>
|
||||||
|
<div className="student-trend-bars">
|
||||||
|
{Array.from({ length: 7 }).map((_, index) => {
|
||||||
|
const value = Math.max(60, Math.min(100, selectedStudent.rate + index * 3 - 8));
|
||||||
|
return <i key={index} style={{ height: `${value}%` }} title={`${value}%`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(editRecord)}
|
open={Boolean(editRecord)}
|
||||||
title="考勤记录纠错"
|
title="考勤记录纠错"
|
||||||
|
|||||||
Reference in New Issue
Block a user