Merge pull request 'fix: UI优化 + 钉钉考勤已结算课程刷新 + dev脚本改进' (#38) from fix/ux-improvements-20260722 into main
This commit is contained in:
@@ -245,10 +245,10 @@ const EditableCell = <Value,>({
|
||||
/>
|
||||
);
|
||||
} else if (editor === 'date') {
|
||||
control = <DatePicker {...commonProps} format="YYYY-MM-DD" />;
|
||||
control = <DatePicker {...commonProps} format="YYYY-MM-DD" open />;
|
||||
} else if (editor === 'date-range') {
|
||||
const { placeholder: _placeholder, ...rangeProps } = commonProps;
|
||||
control = <DatePicker.RangePicker {...rangeProps} format="YYYY-MM-DD" />;
|
||||
control = <DatePicker.RangePicker {...rangeProps} format="YYYY-MM-DD" open />;
|
||||
} else if (editor === 'number' || editor === 'money') {
|
||||
control = (
|
||||
<InputNumber {...commonProps} min={min} max={max} precision={editor === 'money' ? 2 : 0} />
|
||||
|
||||
@@ -1470,7 +1470,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
<Space>
|
||||
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
|
||||
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
学员档案 - {student.name} ({student.studentNo})
|
||||
学员档案 - {student.name}{student.studentNo ? ` (${student.studentNo})` : ''}
|
||||
</span>
|
||||
</Space>
|
||||
<Space>
|
||||
|
||||
@@ -157,6 +157,22 @@ canvas {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.student-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 20px;
|
||||
}
|
||||
|
||||
.student-form-grid .ant-form-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.student-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-content-wrapper {
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
@@ -178,6 +194,35 @@ canvas {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Keep wide tables scrollable without showing a heavy bar on every table. */
|
||||
.ant-table-wrapper .ant-table-content {
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-content::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-content::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.ant-table-wrapper:hover .ant-table-content,
|
||||
.ant-table-wrapper:focus-within .ant-table-content {
|
||||
scrollbar-color: #c8c8cc transparent;
|
||||
}
|
||||
|
||||
.ant-table-wrapper:hover .ant-table-content::-webkit-scrollbar-thumb,
|
||||
.ant-table-wrapper:focus-within .ant-table-content::-webkit-scrollbar-thumb {
|
||||
background: #c8c8cc;
|
||||
}
|
||||
|
||||
/* ── 表格单元格省略号截断(按需启用) ──
|
||||
在 .ant-table-wrapper 上添加 .table-cell-ellipsis 类即可生效:
|
||||
<Table className="table-cell-ellipsis" ... /> */
|
||||
|
||||
272
apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx
Normal file
272
apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import {
|
||||
filterLessonAttendanceRecords,
|
||||
getPunchDisplayInfo,
|
||||
summarizeLessonCheckins,
|
||||
type LessonAttendanceFilter,
|
||||
} from './attendance-workspace';
|
||||
import type { LessonAttendanceRecord, LessonAttendanceSchedule } from './types';
|
||||
|
||||
interface LessonAttendanceSession {
|
||||
id: number;
|
||||
scheduleId: number;
|
||||
classId: number;
|
||||
lessonDate: string;
|
||||
status: 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
interface LessonAttendanceResponse {
|
||||
schedule: LessonAttendanceSchedule;
|
||||
session: LessonAttendanceSession | null;
|
||||
records: LessonAttendanceRecord[];
|
||||
}
|
||||
|
||||
interface LessonAttendanceDetailProps {
|
||||
schedule: LessonAttendanceSchedule | null;
|
||||
className: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AttendanceStatus({ status }: { status: string }) {
|
||||
const checkedIn = status === 'present' || status === 'late';
|
||||
return (
|
||||
<span className={`attendance-status ${checkedIn ? 'is-present' : 'is-absent'}`}>
|
||||
<span className="attendance-status__dot" />
|
||||
{checkedIn ? '出勤' : '缺勤'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LessonSummary({ records }: { records: readonly LessonAttendanceRecord[] }) {
|
||||
const summary = summarizeLessonCheckins(records);
|
||||
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
|
||||
return (
|
||||
<div className="attendance-summary-strip lesson-summary-strip">
|
||||
<div className="attendance-rate">
|
||||
<Progress type="circle" percent={rate} size={64} strokeWidth={9} />
|
||||
<div>
|
||||
<span>打卡率</span>
|
||||
<strong>{summary.total} 人</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="attendance-summary-cell">
|
||||
<span className="attendance-summary-icon is-present">勤</span>
|
||||
<div>
|
||||
<strong>{summary.checkedIn}</strong>
|
||||
<span>已打卡</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="attendance-summary-cell">
|
||||
<span className="attendance-summary-icon is-absent">缺</span>
|
||||
<div>
|
||||
<strong>{summary.notCheckedIn}</strong>
|
||||
<span>未打卡</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
||||
schedule,
|
||||
className,
|
||||
onClose,
|
||||
}) => {
|
||||
const [loadedSchedule, setLoadedSchedule] = useState<LessonAttendanceSchedule | null>(null);
|
||||
const [session, setSession] = useState<LessonAttendanceSession | null>(null);
|
||||
const [records, setRecords] = useState<LessonAttendanceRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filter, setFilter] = useState<LessonAttendanceFilter>('all');
|
||||
|
||||
useEffect(() => {
|
||||
if (!schedule) return;
|
||||
let cancelled = false;
|
||||
setLoadedSchedule(schedule);
|
||||
setSession(null);
|
||||
setRecords([]);
|
||||
setKeyword('');
|
||||
setFilter('all');
|
||||
setLoading(true);
|
||||
const date = dayjs().format('YYYY-MM-DD');
|
||||
void api
|
||||
.post<LessonAttendanceResponse>(`/attendance-lessons/schedules/${schedule.id}/pull`, {
|
||||
date,
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setLoadedSchedule(data.schedule);
|
||||
setSession(data.session);
|
||||
setRecords(data.records);
|
||||
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
message.error((error as { message?: string })?.message || '加载本节课考勤失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [schedule]);
|
||||
|
||||
const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => {
|
||||
const previous = record.status;
|
||||
setRecords((items) =>
|
||||
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
|
||||
);
|
||||
try {
|
||||
await api.put(`/attendance-records/${record.id}`, { status });
|
||||
} catch (error: unknown) {
|
||||
setRecords((items) =>
|
||||
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
|
||||
);
|
||||
message.error((error as { message?: string })?.message || '更新考勤失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredRecords = useMemo(
|
||||
() => filterLessonAttendanceRecords(records, keyword, filter),
|
||||
[filter, keyword, records],
|
||||
);
|
||||
const completed = session?.status === 'completed';
|
||||
const displayedSchedule = loadedSchedule ?? schedule;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={schedule !== null}
|
||||
onClose={onClose}
|
||||
size={960}
|
||||
title={null}
|
||||
className="attendance-drawer"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="lesson-record-header">
|
||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||
<h2>{displayedSchedule?.subject || '课程考勤'}</h2>
|
||||
<p>
|
||||
{className} · {displayedSchedule?.startTime}–{displayedSchedule?.endTime} ·{' '}
|
||||
{dayjs().format('YYYY-MM-DD')}
|
||||
</p>
|
||||
</div>
|
||||
{session && (
|
||||
<Alert
|
||||
type={completed ? 'success' : 'info'}
|
||||
showIcon
|
||||
title={completed ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
<LessonSummary records={records} />
|
||||
<div className="lesson-record-filters">
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={keyword}
|
||||
placeholder="搜索学生姓名"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
className="lesson-record-search"
|
||||
/>
|
||||
<Select<LessonAttendanceFilter>
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
options={[
|
||||
{ value: 'all', label: '全部学生' },
|
||||
{ value: 'checked_in', label: '已打卡' },
|
||||
{ value: 'not_checked_in', label: '未打卡' },
|
||||
]}
|
||||
className="lesson-record-filter-select"
|
||||
/>
|
||||
<span className="lesson-record-filter-count">
|
||||
显示 {filteredRecords.length} / {records.length} 人
|
||||
</span>
|
||||
</div>
|
||||
<Table<LessonAttendanceRecord>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={filteredRecords}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={records.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '学生',
|
||||
dataIndex: ['student', 'name'],
|
||||
render: (name: string) => (
|
||||
<div className="student-cell">
|
||||
<Avatar size={32}>{name?.slice(0, 1)}</Avatar>
|
||||
<strong>{name || '-'}</strong>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '考勤结果',
|
||||
dataIndex: 'status',
|
||||
width: 230,
|
||||
render: (value: string, record) => {
|
||||
const checkedIn = value === 'present' || value === 'late';
|
||||
return (
|
||||
<div className="attendance-marking-actions">
|
||||
<Button
|
||||
size="small"
|
||||
type={checkedIn ? 'primary' : 'default'}
|
||||
onClick={() => void updateRecord(record, 'present')}
|
||||
>
|
||||
已打卡
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type={!checkedIn ? 'primary' : 'default'}
|
||||
danger={!checkedIn}
|
||||
onClick={() => void updateRecord(record, 'absent')}
|
||||
>
|
||||
未打卡
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '当前状态',
|
||||
dataIndex: 'status',
|
||||
width: 105,
|
||||
render: (value: string) => <AttendanceStatus status={value} />,
|
||||
},
|
||||
{
|
||||
title: '打卡设备',
|
||||
width: 220,
|
||||
render: (_: unknown, record) => {
|
||||
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',
|
||||
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonAttendanceDetail;
|
||||
@@ -1335,22 +1335,6 @@
|
||||
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: 1280px) {
|
||||
.student-filter-panel {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Progress,
|
||||
Segmented,
|
||||
Row,
|
||||
Select,
|
||||
@@ -41,15 +40,14 @@ import { usePermission } from '../../hooks/usePermission';
|
||||
import { message } from '../../ui/app-message';
|
||||
import {
|
||||
canPullAttendance,
|
||||
filterLessonAttendanceRecords,
|
||||
getAttendanceExperience,
|
||||
getPunchDisplayInfo,
|
||||
getSchedulePhase,
|
||||
summarizeLessonCheckins,
|
||||
getPunchDisplayInfo,
|
||||
type AttendanceSummary,
|
||||
type LessonAttendanceFilter,
|
||||
type SchedulePhase,
|
||||
} from './attendance-workspace';
|
||||
import LessonAttendanceDetail from './LessonAttendanceDetail';
|
||||
import type { LessonAttendanceRecord, LessonAttendanceSchedule } from './types';
|
||||
import './attendance.css';
|
||||
|
||||
const DEFAULT_ATTENDANCE_PERIODS = [
|
||||
@@ -117,25 +115,7 @@ interface ClassOption {
|
||||
teachers?: ClassTeacherOption[];
|
||||
}
|
||||
|
||||
interface AttendanceRecordItem {
|
||||
id: number;
|
||||
studentId: number;
|
||||
classId: number | null;
|
||||
attendanceDate: string;
|
||||
session: string;
|
||||
status: string;
|
||||
source?: string;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
student: { id: number; name: string; studentNo?: string | null };
|
||||
class: { id: number; name: string } | null;
|
||||
scheduleId?: number | null;
|
||||
attendanceSessionId?: number | null;
|
||||
punchTime?: string | null;
|
||||
punchSource?: string | null;
|
||||
punchDeviceName?: string | null;
|
||||
punchDeviceId?: string | null;
|
||||
}
|
||||
type AttendanceRecordItem = LessonAttendanceRecord;
|
||||
|
||||
interface AssignedClass {
|
||||
classId: number;
|
||||
@@ -145,14 +125,7 @@ interface AssignedClass {
|
||||
subject: string;
|
||||
}
|
||||
|
||||
interface TodaySchedule {
|
||||
id: number;
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
subject: string;
|
||||
}
|
||||
type TodaySchedule = LessonAttendanceSchedule;
|
||||
|
||||
interface HistoryScheduleOption {
|
||||
id: number;
|
||||
@@ -169,20 +142,6 @@ interface HistoryScheduleOption {
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LessonAttendanceSession {
|
||||
id: number;
|
||||
scheduleId: number;
|
||||
classId: number;
|
||||
lessonDate: string;
|
||||
status: 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
interface LessonAttendanceResponse {
|
||||
schedule: TodaySchedule;
|
||||
session: LessonAttendanceSession | null;
|
||||
records: AttendanceRecordItem[];
|
||||
}
|
||||
|
||||
interface TeacherWorkspaceData {
|
||||
assignedClasses: AssignedClass[];
|
||||
todaySchedules: TodaySchedule[];
|
||||
@@ -275,36 +234,6 @@ function AttendanceStatusTag({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) {
|
||||
const summary = summarizeLessonCheckins(records);
|
||||
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
|
||||
return (
|
||||
<div className="attendance-summary-strip lesson-summary-strip">
|
||||
<div className="attendance-rate">
|
||||
<Progress type="circle" percent={rate} size={64} strokeWidth={9} />
|
||||
<div>
|
||||
<span>打卡率</span>
|
||||
<strong>{summary.total} 人</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="attendance-summary-cell">
|
||||
<span className="attendance-summary-icon is-present">勤</span>
|
||||
<div>
|
||||
<strong>{summary.checkedIn}</strong>
|
||||
<span>已打卡</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="attendance-summary-cell">
|
||||
<span className="attendance-summary-icon is-absent">缺</span>
|
||||
<div>
|
||||
<strong>{summary.notCheckedIn}</strong>
|
||||
<span>未打卡</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AttendancePage: React.FC = () => {
|
||||
const { permissions, hasPermission } = usePermission();
|
||||
const roles = useMemo(readCurrentRoles, []);
|
||||
@@ -321,12 +250,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [workspace, setWorkspace] = useState<TeacherWorkspaceData | null>(null);
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<TodaySchedule | null>(null);
|
||||
const [lessonSession, setLessonSession] = useState<LessonAttendanceSession | null>(null);
|
||||
const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]);
|
||||
const [recordLoading, setRecordLoading] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [studentKeyword, setStudentKeyword] = useState('');
|
||||
const [checkinFilter, setCheckinFilter] = useState<LessonAttendanceFilter>('all');
|
||||
|
||||
const loadWorkspace = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -348,44 +271,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
[workspace],
|
||||
);
|
||||
|
||||
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
|
||||
setStudentKeyword('');
|
||||
setCheckinFilter('all');
|
||||
const openAttendance = useCallback((schedule: TodaySchedule) => {
|
||||
setSelectedSchedule(schedule);
|
||||
setDrawerOpen(true);
|
||||
setRecordLoading(true);
|
||||
try {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const data = await api.post<LessonAttendanceResponse>(
|
||||
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
||||
{ date: today },
|
||||
);
|
||||
setSelectedSchedule(data.schedule);
|
||||
setLessonSession(data.session);
|
||||
setLessonRecords(data.records);
|
||||
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
||||
} catch (error: unknown) {
|
||||
setLessonSession(null);
|
||||
setLessonRecords([]);
|
||||
message.error(error instanceof Error ? error.message : '加载本节课考勤失败');
|
||||
} finally {
|
||||
setRecordLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateLessonRecord = useCallback(async (record: AttendanceRecordItem, status: string) => {
|
||||
const previous = record.status;
|
||||
setLessonRecords((items) =>
|
||||
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
|
||||
);
|
||||
try {
|
||||
await api.put(`/attendance-records/${record.id}`, { status });
|
||||
} catch (error: unknown) {
|
||||
setLessonRecords((items) =>
|
||||
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
|
||||
);
|
||||
message.error((error as { message?: string })?.message || '更新考勤失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const now = new Date();
|
||||
@@ -396,11 +283,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
const nextSchedule = schedules.find(
|
||||
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
|
||||
);
|
||||
const isAttendanceCompleted = lessonSession?.status === 'completed';
|
||||
const filteredLessonRecords = useMemo(
|
||||
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
|
||||
[lessonRecords, studentKeyword, checkinFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="attendance-page teacher-attendance">
|
||||
@@ -473,7 +355,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
phase={phase}
|
||||
className={classNameById.get(schedule.classId) || `班级 ${schedule.classId}`}
|
||||
index={index + 1}
|
||||
onOpen={() => void openAttendance(schedule)}
|
||||
onOpen={() => openAttendance(schedule)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -481,143 +363,15 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={960}
|
||||
title={null}
|
||||
className="attendance-drawer"
|
||||
>
|
||||
<div className="lesson-record-header">
|
||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
||||
<p>
|
||||
{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} ·{' '}
|
||||
{selectedSchedule?.startTime}–{selectedSchedule?.endTime} ·{' '}
|
||||
{dayjs().format('YYYY-MM-DD')}
|
||||
</p>
|
||||
</div>
|
||||
{lessonSession && (
|
||||
<Alert
|
||||
type={isAttendanceCompleted ? 'success' : 'info'}
|
||||
showIcon
|
||||
title={
|
||||
isAttendanceCompleted
|
||||
? '本节课考勤已结算'
|
||||
: '当前打卡结果;课程截止后将自动做最终结算'
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
<LessonCheckinSummaryStrip records={lessonRecords} />
|
||||
<div className="lesson-record-filters">
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={studentKeyword}
|
||||
placeholder="搜索学生姓名"
|
||||
onChange={(event) => setStudentKeyword(event.target.value)}
|
||||
className="lesson-record-search"
|
||||
/>
|
||||
<Select<LessonAttendanceFilter>
|
||||
value={checkinFilter}
|
||||
onChange={setCheckinFilter}
|
||||
options={[
|
||||
{ value: 'all', label: '全部学生' },
|
||||
{ value: 'checked_in', label: '已打卡' },
|
||||
{ value: 'not_checked_in', label: '未打卡' },
|
||||
]}
|
||||
className="lesson-record-filter-select"
|
||||
/>
|
||||
<span className="lesson-record-filter-count">
|
||||
显示 {filteredLessonRecords.length} / {lessonRecords.length} 人
|
||||
</span>
|
||||
</div>
|
||||
<Table<AttendanceRecordItem>
|
||||
rowKey="id"
|
||||
loading={recordLoading}
|
||||
dataSource={filteredLessonRecords}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '学生',
|
||||
dataIndex: ['student', 'name'],
|
||||
render: (name: string) => (
|
||||
<div className="student-cell">
|
||||
<Avatar size={32}>{name?.slice(0, 1)}</Avatar>
|
||||
<strong>{name || '-'}</strong>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '考勤结果',
|
||||
dataIndex: 'status',
|
||||
width: 230,
|
||||
render: (value: string, record: AttendanceRecordItem) => {
|
||||
const checkedIn = value === 'present' || value === 'late';
|
||||
return (
|
||||
<div className="attendance-marking-actions">
|
||||
<Button
|
||||
size="small"
|
||||
type={checkedIn ? 'primary' : 'default'}
|
||||
onClick={() => void updateLessonRecord(record, 'present')}
|
||||
>
|
||||
已打卡
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type={!checkedIn ? 'primary' : 'default'}
|
||||
danger={!checkedIn}
|
||||
onClick={() => void updateLessonRecord(record, 'absent')}
|
||||
>
|
||||
未打卡
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '当前状态',
|
||||
dataIndex: 'status',
|
||||
width: 105,
|
||||
render: (value: string) => (
|
||||
<AttendanceStatusTag
|
||||
status={value === 'present' || value === 'late' ? 'present' : 'absent'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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',
|
||||
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Drawer>
|
||||
<LessonAttendanceDetail
|
||||
schedule={selectedSchedule}
|
||||
className={
|
||||
selectedSchedule
|
||||
? classNameById.get(selectedSchedule.classId) || `班级 ${selectedSchedule.classId}`
|
||||
: ''
|
||||
}
|
||||
onClose={() => setSelectedSchedule(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1539,15 +1293,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
))}
|
||||
</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>
|
||||
|
||||
28
apps/admin/src/pages/Attendance/types.ts
Normal file
28
apps/admin/src/pages/Attendance/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface LessonAttendanceSchedule {
|
||||
id: number;
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface LessonAttendanceRecord {
|
||||
id: number;
|
||||
studentId: number;
|
||||
classId: number | null;
|
||||
attendanceDate: string;
|
||||
session: string;
|
||||
status: string;
|
||||
source?: string;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
student: { id: number; name: string; studentNo?: string | null };
|
||||
class: { id: number; name: string } | null;
|
||||
scheduleId?: number | null;
|
||||
attendanceSessionId?: number | null;
|
||||
punchTime?: string | null;
|
||||
punchSource?: string | null;
|
||||
punchDeviceName?: string | null;
|
||||
punchDeviceId?: string | null;
|
||||
}
|
||||
@@ -892,6 +892,8 @@ const StudentsPage: React.FC = () => {
|
||||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||||
<Modal
|
||||
title={editing ? '编辑学生' : '添加学生'}
|
||||
className="student-form-modal"
|
||||
width={720}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => {
|
||||
@@ -901,7 +903,7 @@ const StudentsPage: React.FC = () => {
|
||||
okText="保存"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" className="student-form-grid">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
@@ -252,8 +252,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
expect(result.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a completed session as-is without refreshing', async () => {
|
||||
const { service, attendanceRepo, scheduleRepo, sessionRepo } = createService();
|
||||
it('refreshes a completed session when DingTalk punches arrive late', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
@@ -262,13 +263,27 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'completed',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([{ id: 1, attendanceSessionId: 90, status: 'present' }]);
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 1, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
|
||||
]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{
|
||||
matchedStudentId: 1,
|
||||
attendanceType: 'OnDuty',
|
||||
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'present', remark: null }),
|
||||
]);
|
||||
expect(sessionRepo.save).not.toHaveBeenCalled();
|
||||
expect(attendanceRepo.save).not.toHaveBeenCalled();
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.session.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('refreshes an in_progress session from latest DingTalk data', async () => {
|
||||
@@ -616,13 +631,15 @@ describe('AttendanceService — attendance window boundaries', () => {
|
||||
process.env.TZ = 'UTC';
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
|
||||
try {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceRepo } = createService();
|
||||
const { service, scheduleRepo, sessionRepo, attendanceRepo, dingRawRepo, classStudentRepo } = createService();
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
...endedSchedule, weekDay: 1, startTime: '08:30', endTime: '10:00',
|
||||
startDate: '2026-07-13', endDate: '2026-07-13',
|
||||
});
|
||||
sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
|
||||
attendanceRepo.find.mockResolvedValue([]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
classStudentRepo.find.mockResolvedValue([]);
|
||||
await expect(service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21))
|
||||
.resolves.toMatchObject({ records: [] });
|
||||
} finally {
|
||||
|
||||
@@ -391,23 +391,21 @@ export class AttendanceService {
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'completed') {
|
||||
const records = await this.attendanceRepo.find({
|
||||
where: { attendanceSessionId: existing.id },
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
|
||||
}
|
||||
if (existing.status !== 'in_progress' && !(finalize && existing.status === 'settling')) {
|
||||
if (
|
||||
existing.status !== 'in_progress' &&
|
||||
existing.status !== 'completed' &&
|
||||
!(finalize && existing.status === 'settling')
|
||||
) {
|
||||
throw new BadRequestException('课程考勤正在结算');
|
||||
}
|
||||
|
||||
// Refresh in_progress session from latest DingTalk data
|
||||
// Refresh latest DingTalk data even after automatic settlement; late-arriving punches
|
||||
// may legitimately change a DingTalk-generated absence to present.
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
|
||||
const effectiveFinalize = finalize || existing.status === 'completed';
|
||||
const existingRecords = await recordRepo.find({
|
||||
where: { attendanceSessionId: existing.id },
|
||||
order: { studentId: 'ASC' },
|
||||
@@ -433,7 +431,7 @@ export class AttendanceService {
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
record.status = this.mapDingTalkStatus(raw, finalize);
|
||||
record.status = this.mapDingTalkStatus(raw, effectiveFinalize);
|
||||
Object.assign(record, this.getLessonPunchMetadata(
|
||||
raw,
|
||||
lessonDate,
|
||||
@@ -441,7 +439,7 @@ export class AttendanceService {
|
||||
));
|
||||
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? null
|
||||
: finalize
|
||||
: effectiveFinalize
|
||||
? '课程截止仍未打卡'
|
||||
: '未获取到钉钉打卡结果';
|
||||
return record;
|
||||
@@ -462,7 +460,7 @@ export class AttendanceService {
|
||||
attendanceSessionId: existing.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: lessonSessionKey,
|
||||
status: this.mapDingTalkStatus(raw, finalize),
|
||||
status: this.mapDingTalkStatus(raw, effectiveFinalize),
|
||||
source: 'dingtalk',
|
||||
...this.getLessonPunchMetadata(
|
||||
raw,
|
||||
@@ -471,7 +469,7 @@ export class AttendanceService {
|
||||
),
|
||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? undefined
|
||||
: finalize
|
||||
: effectiveFinalize
|
||||
? '课程截止仍未打卡'
|
||||
: '未获取到钉钉打卡结果',
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { LessonAttendanceSyncService } from './lesson-attendance-sync.service';
|
||||
|
||||
const lesson = {
|
||||
schedule: {
|
||||
id: 4,
|
||||
classId: 8,
|
||||
startTime: '22:00',
|
||||
endTime: '01:00',
|
||||
attendanceAdvanceMinutes: 30,
|
||||
},
|
||||
session: null,
|
||||
records: [],
|
||||
};
|
||||
|
||||
const createService = () => {
|
||||
const attendanceService = {
|
||||
getLessonAttendance: jest.fn().mockResolvedValue(lesson),
|
||||
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1', 'ding-2']),
|
||||
getLessonAttendanceImportDateRange: jest.fn().mockReturnValue({
|
||||
startDate: '2026-07-11',
|
||||
endDate: '2026-07-12',
|
||||
}),
|
||||
createLessonAttendanceFromDingTalk: jest.fn().mockResolvedValue({
|
||||
...lesson,
|
||||
session: { id: 90, status: 'in_progress' },
|
||||
}),
|
||||
};
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
imported: 3,
|
||||
skipped: 0,
|
||||
matched: 2,
|
||||
errors: [],
|
||||
duration: 10,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
service: new LessonAttendanceSyncService(attendanceService as never, importService as never),
|
||||
attendanceService,
|
||||
importService,
|
||||
};
|
||||
};
|
||||
|
||||
describe('LessonAttendanceSyncService', () => {
|
||||
it('owns the complete refresh recipe and returns attendance with import statistics', async () => {
|
||||
const { service, attendanceService, importService } = createService();
|
||||
|
||||
const result = await service.syncLesson({
|
||||
scheduleId: 4,
|
||||
lessonDate: '2026-07-11',
|
||||
actorId: 21,
|
||||
canManageAll: false,
|
||||
mode: 'refresh',
|
||||
});
|
||||
|
||||
expect(attendanceService.getTeacherClassDingUserIds).toHaveBeenCalledWith(
|
||||
21,
|
||||
8,
|
||||
false,
|
||||
'2026-07-11',
|
||||
);
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
|
||||
startDate: '2026-07-11',
|
||||
endDate: '2026-07-12',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: true,
|
||||
userId: 21,
|
||||
});
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
|
||||
4,
|
||||
'2026-07-11',
|
||||
21,
|
||||
false,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
attendance: { session: { id: 90 } },
|
||||
imported: 3,
|
||||
matched: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('finalizes only after a complete import', async () => {
|
||||
const { service, attendanceService } = createService();
|
||||
|
||||
await service.syncLesson({
|
||||
scheduleId: 4,
|
||||
lessonDate: '2026-07-11',
|
||||
actorId: 21,
|
||||
canManageAll: true,
|
||||
mode: 'finalize',
|
||||
});
|
||||
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
|
||||
4,
|
||||
'2026-07-11',
|
||||
21,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects partial imports before changing lesson attendance', async () => {
|
||||
const { service, attendanceService, importService } = createService();
|
||||
importService.importFromDingTalk.mockResolvedValue({
|
||||
success: true,
|
||||
imported: 1,
|
||||
matched: 1,
|
||||
errors: ['one batch failed'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.syncLesson({
|
||||
scheduleId: 4,
|
||||
lessonDate: '2026-07-11',
|
||||
actorId: 21,
|
||||
canManageAll: false,
|
||||
mode: 'refresh',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
53
apps/server/src/attendance/lesson-attendance-sync.service.ts
Normal file
53
apps/server/src/attendance/lesson-attendance-sync.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
|
||||
export type LessonAttendanceSyncMode = 'refresh' | 'finalize';
|
||||
|
||||
export interface LessonAttendanceSyncOptions {
|
||||
scheduleId: number;
|
||||
lessonDate: string;
|
||||
actorId: number;
|
||||
canManageAll: boolean;
|
||||
mode: LessonAttendanceSyncMode;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LessonAttendanceSyncService {
|
||||
constructor(
|
||||
private readonly attendanceService: AttendanceService,
|
||||
private readonly importService: AttendanceImportService,
|
||||
) {}
|
||||
|
||||
async syncLesson(options: LessonAttendanceSyncOptions) {
|
||||
const { scheduleId, lessonDate, actorId, canManageAll, mode } = options;
|
||||
const { schedule } = await this.attendanceService.getLessonAttendance(scheduleId, lessonDate);
|
||||
const userIds = await this.attendanceService.getTeacherClassDingUserIds(
|
||||
actorId,
|
||||
schedule.classId!,
|
||||
canManageAll,
|
||||
lessonDate,
|
||||
);
|
||||
const dateRange = this.attendanceService.getLessonAttendanceImportDateRange(
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
const imported = await this.importService.importFromDingTalk({
|
||||
...dateRange,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
userId: actorId,
|
||||
});
|
||||
if (!imported.success || imported.errors.length > 0) {
|
||||
throw new BadRequestException(imported.errors.join('; ') || '钉钉考勤拉取失败');
|
||||
}
|
||||
|
||||
const attendance = await this.attendanceService.createLessonAttendanceFromDingTalk(
|
||||
scheduleId,
|
||||
lessonDate,
|
||||
actorId,
|
||||
mode === 'finalize',
|
||||
);
|
||||
return { attendance, imported: imported.imported, matched: imported.matched };
|
||||
}
|
||||
}
|
||||
232
package-lock.json
generated
232
package-lock.json
generated
@@ -13,12 +13,10 @@
|
||||
"@fission-ai/openspec": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.3",
|
||||
"oxfmt": "^0.57.0",
|
||||
"oxlint": "^1.72.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"turbo": "^2.0.0",
|
||||
"wait-on": "^9.0.10"
|
||||
"turbo": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"apps/admin": {
|
||||
@@ -1433,60 +1431,6 @@
|
||||
"resolved": "packages/typescript-config",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hapi/address": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@hapi/address/-/address-5.1.1.tgz",
|
||||
"integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/formula": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@hapi/formula/-/formula-3.0.2.tgz",
|
||||
"integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@hapi/hoek/-/hoek-11.0.7.tgz",
|
||||
"integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/pinpoint": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/@hapi/pinpoint/-/pinpoint-2.0.1.tgz",
|
||||
"integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/tlds": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/@hapi/tlds/-/tlds-1.1.7.tgz",
|
||||
"integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/topo": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@hapi/topo/-/topo-6.0.2.tgz",
|
||||
"integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -8517,118 +8461,6 @@
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/concurrently/-/concurrently-10.0.3.tgz",
|
||||
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.4",
|
||||
"supports-color": "10.2.2",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/index.js",
|
||||
"concurrently": "dist/bin/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
@@ -11991,25 +11823,6 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/joi/-/joi-18.2.3.tgz",
|
||||
"integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/address": "^5.1.1",
|
||||
"@hapi/formula": "^3.0.2",
|
||||
"@hapi/hoek": "^11.0.7",
|
||||
"@hapi/pinpoint": "^2.0.1",
|
||||
"@hapi/tlds": "^1.1.1",
|
||||
"@hapi/topo": "^6.0.2",
|
||||
"@standard-schema/spec": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/js-md5": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/js-md5/-/js-md5-0.8.3.tgz",
|
||||
@@ -15039,19 +14852,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz",
|
||||
@@ -16105,16 +15905,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
@@ -17234,26 +17024,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmmirror.com/wait-on/-/wait-on-9.0.10.tgz",
|
||||
"integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"joi": "^18.2.1",
|
||||
"lodash": "^4.18.1",
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"bin": {
|
||||
"wait-on": "bin/wait-on"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/walker": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/walker/-/walker-1.0.8.tgz",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,admin -c blue,green \"npm run dev -w apps/server\" \"wait-on tcp:3000 && npm run dev -w apps/admin\"",
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test",
|
||||
@@ -16,12 +16,10 @@
|
||||
"clean": "rimraf apps/server/dist apps/admin/dist apps/server/dorm_billing.db node_modules apps/*/node_modules packages/*/node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.3",
|
||||
"oxfmt": "^0.57.0",
|
||||
"oxlint": "^1.72.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"turbo": "^2.0.0",
|
||||
"wait-on": "^9.0.10"
|
||||
"turbo": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fission-ai/openspec": "^1.5.0"
|
||||
|
||||
Reference in New Issue
Block a user