|
@@ -1,30 +1,120 @@
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
/**
|
|
/**
|
|
|
- * 排班管理页(views/academic/schedule/index.vue)—— 课程/排班列表(菜单「排班管理」,端点 44)。
|
|
|
|
|
- * useListPage + ListPage;作用域从 useScope 取。
|
|
|
|
|
|
|
+ * 排班管理页(views/academic/schedule/index.vue)—— 课程域操作面(菜单「排班管理」,端点 44~56)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 结构(自上而下,选中课程后 ②③④⑤ 随动):
|
|
|
|
|
+ * ① 课程列表(端点 44,ListPage;行点击 = 选中课程);
|
|
|
|
|
+ * ② 排班规则区(端点 46 列表 / 47 新建;week 字符串:-1 全周 / 0..6 周日..周六 / 多值逗号);
|
|
|
|
|
+ * ③ 课表区(端点 48 列表,日期闭区间 to 含当日 / 49 生成课表幂等 / 50 开始上课 / 51 结束上课重操作);
|
|
|
|
|
+ * ④ 打卡对话框(端点 52 逐学生 type/pay/cost/giveCost;初值取端点 53 课程级学生);
|
|
|
|
|
+ * ⑤ 报课学生区(端点 53 列表 / 54 报名,不写资金流水、重复 → 3143);
|
|
|
|
|
+ * ⑥ 欠费列表(端点 55 课程域全局 / 56 核销,单事务)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 纪律(契约 v1.8/v1.9 §2.4 + §3.C):
|
|
|
|
|
+ * - 端点 51 是**重操作**(单事务:扣课时+余额变动+分账+课酬)→ 二次确认 + loading 锁 + 失败/成功后**刷新整页**(不局部推断);
|
|
|
|
|
+ * - 46/48 无 room(v1.8,源列只有 address);48 时间窗 `to` 含当日(闭区间,显式例外);
|
|
|
|
|
+ * - 49 幂等:changed=false 提示"无新增";
|
|
|
|
|
+ * - 52 已开课未结束才可打卡;50 仅未开始可开始;状态非法 → 3121 读 code 直显;
|
|
|
|
|
+ * - 47 排班冲突 → 3122;54 重复报名 → 3143(专用码,非 3140);
|
|
|
|
|
+ * - 欠费字段一律 `remainderReceivable`(unpaid 是 2018-05 停更僵尸列,禁展示);53 realName 可空(null ≠ 空串);
|
|
|
|
|
+ * - 金额字符串两位小数;时间 RFC3339;作用域只从 useScope() 取。
|
|
|
*/
|
|
*/
|
|
|
-import { listCourses, type CourseDTO } from '@/api/academic/course';
|
|
|
|
|
|
|
+import {
|
|
|
|
|
+ listCourses,
|
|
|
|
|
+ listClasses,
|
|
|
|
|
+ createClass,
|
|
|
|
|
+ listRecords,
|
|
|
|
|
+ generateRecords,
|
|
|
|
|
+ startRecord,
|
|
|
|
|
+ finishRecord,
|
|
|
|
|
+ checkAttendance,
|
|
|
|
|
+ listCourseStudents,
|
|
|
|
|
+ enrollStudent,
|
|
|
|
|
+ listArrears,
|
|
|
|
|
+ counteractArrears,
|
|
|
|
|
+ type CourseDTO,
|
|
|
|
|
+ type CourseClassDTO,
|
|
|
|
|
+ type CourseRecordDTO,
|
|
|
|
|
+ type CourseStudentDTO,
|
|
|
|
|
+ type ArrearsDTO,
|
|
|
|
|
+ type AttendanceItem,
|
|
|
|
|
+} from '@/api/academic/course';
|
|
|
|
|
+import { listMembers } from '@/api/org/member';
|
|
|
|
|
+import type { MemberDTO } from '@/api/org/member';
|
|
|
|
|
+import { listCampuses } from '@/api/org/campus';
|
|
|
|
|
+import type { CampusDTO } from '@/api/org/campus';
|
|
|
import ListPage from '@/components/ListPage.vue';
|
|
import ListPage from '@/components/ListPage.vue';
|
|
|
import type { ListColumn } from '@/components/list-column';
|
|
import type { ListColumn } from '@/components/list-column';
|
|
|
import { useListPage } from '@/composables/use-list-page';
|
|
import { useListPage } from '@/composables/use-list-page';
|
|
|
import { useScope } from '@/store/scope';
|
|
import { useScope } from '@/store/scope';
|
|
|
|
|
+import { formatDateTime, formatMoney } from '@/utils/format';
|
|
|
|
|
|
|
|
const scope = useScope();
|
|
const scope = useScope();
|
|
|
-const filters = reactive<{ keyword: string; status: number | undefined }>({ keyword: '', status: undefined });
|
|
|
|
|
|
|
|
|
|
-const { rows, total, page, pageSize, loading, error, fetchList } = useListPage<CourseDTO>({
|
|
|
|
|
- fetcher: (p, ps) => listCourses({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
|
|
|
|
|
|
|
+/** 命令式 EP(UMD 全局,http.ts 同款读法) */
|
|
|
|
|
+function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
|
|
|
|
|
+ const M = (globalThis.ElementPlus ?? {}).ElMessage;
|
|
|
|
|
+ if (M) M({ type, message: text });
|
|
|
|
|
+ else console.warn(`[${type}]`, text);
|
|
|
|
|
+}
|
|
|
|
|
+function ElConfirm(text: string): Promise<void> {
|
|
|
|
|
+ const B = (globalThis.ElementPlus ?? {}).ElMessageBox;
|
|
|
|
|
+ if (!B) return Promise.resolve();
|
|
|
|
|
+ return B.confirm(text, '操作确认', { confirmButtonText: '执行', cancelButtonText: '取消', type: 'warning' })
|
|
|
|
|
+ .then(() => undefined)
|
|
|
|
|
+ .catch(() => Promise.reject(new Error('cancel')));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= 选项数据(老师/学生/校区;机构级全量) ================= */
|
|
|
|
|
+const staffOpts = ref<MemberDTO[]>([]);
|
|
|
|
|
+const studentOpts = ref<MemberDTO[]>([]);
|
|
|
|
|
+const campusOpts = ref<CampusDTO[]>([]);
|
|
|
|
|
+const teacherName = (id: string): string => staffOpts.value.find((m) => m.userId === id)?.realName ?? id;
|
|
|
|
|
+const studentName = (id: string | null): string => (id ? studentOpts.value.find((m) => m.userId === id)?.realName ?? id : '—');
|
|
|
|
|
+const campusName = (id: number): string => (id === 0 ? '机构级' : campusOpts.value.find((c) => c.campusId === id)?.campusName ?? String(id));
|
|
|
|
|
+onMounted(async () => {
|
|
|
|
|
+ const sc = scope.current;
|
|
|
|
|
+ const [staff, students, campuses] = await Promise.all([
|
|
|
|
|
+ listMembers({ institutionId: sc.institutionId, personType: 'staff', pageSize: 200 }),
|
|
|
|
|
+ listMembers({ institutionId: sc.institutionId, personType: 'student', pageSize: 200 }),
|
|
|
|
|
+ listCampuses({ institutionId: sc.institutionId, pageSize: 200 }),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ if (staff.code === 200) staffOpts.value = staff.data?.list ?? [];
|
|
|
|
|
+ if (students.code === 200) studentOpts.value = students.data?.list ?? [];
|
|
|
|
|
+ if (campuses.code === 200) campusOpts.value = campuses.data?.list ?? [];
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-const statusText: Record<number, string> = { 0: '未开始', 1: '进行中', 2: '已结束' };
|
|
|
|
|
|
|
+/* ================= 文案映射 ================= */
|
|
|
|
|
+const WEEK_NAMES: Record<string, string> = { '0': '周日', '1': '周一', '2': '周二', '3': '周三', '4': '周四', '5': '周五', '6': '周六' };
|
|
|
|
|
+/** week 原值字符串 → 中文(v1.8:"-1"=全周 / "0".."6" / "1,3,5" 多值) */
|
|
|
|
|
+function weekText(w: string): string {
|
|
|
|
|
+ if (w === '-1') return '全周';
|
|
|
|
|
+ if (!w) return '-';
|
|
|
|
|
+ return w
|
|
|
|
|
+ .split(',')
|
|
|
|
|
+ .map((x) => WEEK_NAMES[x.trim()] ?? x.trim())
|
|
|
|
|
+ .join('、');
|
|
|
|
|
+}
|
|
|
|
|
+/** 本地当天日期(YYYY-MM-DD;拼 RFC3339 用) */
|
|
|
|
|
+function todayStr(): string {
|
|
|
|
|
+ const d = new Date();
|
|
|
|
|
+ const p = (n: number) => String(n).padStart(2, '0');
|
|
|
|
|
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= ① 课程列表(端点 44) ================= */
|
|
|
|
|
+const filters = reactive<{ keyword: string; status: number | undefined }>({ keyword: '', status: undefined });
|
|
|
|
|
+const { rows: courses, total, page, pageSize, loading, error, fetchList } = useListPage<CourseDTO>({
|
|
|
|
|
+ fetcher: (p, ps) => listCourses({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
|
|
|
|
|
+});
|
|
|
|
|
+const courseStatusText: Record<number, string> = { 0: '未开始', 1: '进行中' };
|
|
|
const columns: ListColumn<CourseDTO>[] = [
|
|
const columns: ListColumn<CourseDTO>[] = [
|
|
|
{ prop: 'id', label: '课程 id', minWidth: 90 },
|
|
{ prop: 'id', label: '课程 id', minWidth: 90 },
|
|
|
{ prop: 'name', label: '课程名称', minWidth: 160 },
|
|
{ prop: 'name', label: '课程名称', minWidth: 160 },
|
|
|
{ prop: 'classify', label: '学科', minWidth: 100 },
|
|
{ prop: 'classify', label: '学科', minWidth: 100 },
|
|
|
- { prop: 'status', label: '状态', minWidth: 90, formatter: (r) => statusText[r.status] ?? String(r.status) },
|
|
|
|
|
|
|
+ { prop: 'status', label: '状态', minWidth: 90, formatter: (r) => courseStatusText[r.status] ?? String(r.status) },
|
|
|
{ prop: 'recordCount', label: '排班数', minWidth: 90 },
|
|
{ prop: 'recordCount', label: '排班数', minWidth: 90 },
|
|
|
];
|
|
];
|
|
|
-
|
|
|
|
|
function onSearch(): void {
|
|
function onSearch(): void {
|
|
|
page.value = 1;
|
|
page.value = 1;
|
|
|
void fetchList();
|
|
void fetchList();
|
|
@@ -32,27 +122,753 @@ function onSearch(): void {
|
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
|
void fetchList();
|
|
void fetchList();
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
|
|
+/** 选中课程(① 行点击 / ② 区下拉框) */
|
|
|
|
|
+const selectedCourseId = ref<number | undefined>(undefined);
|
|
|
|
|
+const selectedCourse = computed(() => courses.value.find((c) => c.id === selectedCourseId.value) ?? null);
|
|
|
|
|
+function onSelectCourse(id: number): void {
|
|
|
|
|
+ selectedCourseId.value = id;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= ② 排班规则区(端点 46/47) ================= */
|
|
|
|
|
+const classRows = ref<CourseClassDTO[]>([]);
|
|
|
|
|
+const classLoading = ref(false);
|
|
|
|
|
+async function loadClasses(): Promise<void> {
|
|
|
|
|
+ if (!selectedCourseId.value) {
|
|
|
|
|
+ classRows.value = [];
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ classLoading.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await listClasses(selectedCourseId.value, { pageSize: 200 });
|
|
|
|
|
+ if (r.code === 200) classRows.value = r.data?.list ?? [];
|
|
|
|
|
+ else ElMessage_('error', r.message || '排班规则加载失败');
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ classLoading.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+watch(selectedCourseId, () => {
|
|
|
|
|
+ void loadClasses();
|
|
|
|
|
+ void fetchRecords();
|
|
|
|
|
+ void loadStudents();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+/** 新建排班规则对话框(端点 47;冲突 → 3122) */
|
|
|
|
|
+const classVisible = ref(false);
|
|
|
|
|
+const classBusy = ref(false);
|
|
|
|
|
+const classForm = reactive({
|
|
|
|
|
+ teacher: '',
|
|
|
|
|
+ /** 周多选(-1 全周 或 0..6 子集;提交时拼逗号字符串;-1 与具体周互斥) */
|
|
|
|
|
+ weeks: [] as number[],
|
|
|
|
|
+ start: '',
|
|
|
|
|
+ end: '',
|
|
|
|
|
+ campusId: 0,
|
|
|
|
|
+});
|
|
|
|
|
+function openClass(): void {
|
|
|
|
|
+ classForm.teacher = staffOpts.value[0]?.userId ?? '';
|
|
|
|
|
+ classForm.weeks = [];
|
|
|
|
|
+ classForm.start = '';
|
|
|
|
|
+ classForm.end = '';
|
|
|
|
|
+ classForm.campusId = scope.current.campusId;
|
|
|
|
|
+ classVisible.value = true;
|
|
|
|
|
+}
|
|
|
|
|
+/** weeks 数组 → week 原值字符串(v1.8) */
|
|
|
|
|
+function weeksToString(): string {
|
|
|
|
|
+ const ws = classForm.weeks;
|
|
|
|
|
+ if (ws.length === 0) return '';
|
|
|
|
|
+ if (ws.includes(-1)) return '-1';
|
|
|
|
|
+ return [...ws].sort((a, b) => a - b).map(String).join(',');
|
|
|
|
|
+}
|
|
|
|
|
+async function doCreateClass(): Promise<void> {
|
|
|
|
|
+ if (!selectedCourseId.value) return;
|
|
|
|
|
+ if (!classForm.teacher || !weeksToString() || !classForm.start || !classForm.end) {
|
|
|
|
|
+ ElMessage_('warning', '请填全老师/周/上课起止时间');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (Number(classForm.end) < Number(classForm.start)) {
|
|
|
|
|
+ ElMessage_('warning', '下课时间需晚于上课时间');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ classBusy.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await createClass(selectedCourseId.value, {
|
|
|
|
|
+ teacher: classForm.teacher,
|
|
|
|
|
+ week: weeksToString(),
|
|
|
|
|
+ // 本地日期 + 时分秒 → RFC3339(无时区后缀 = 本地时间,与后端 dev 环境口径一致)
|
|
|
|
|
+ start: `${todayStr()}T${classForm.start}`,
|
|
|
|
|
+ end: `${todayStr()}T${classForm.end}`,
|
|
|
|
|
+ campusId: classForm.campusId,
|
|
|
|
|
+ });
|
|
|
|
|
+ if (r.code === 200) {
|
|
|
|
|
+ ElMessage_('success', '排班规则已创建');
|
|
|
|
|
+ classVisible.value = false;
|
|
|
|
|
+ void loadClasses();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 3122 排班冲突 / 2001 参数缺失:读 code 直显
|
|
|
|
|
+ ElMessage_('warning', r.message || `创建失败(code ${r.code})`);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ classBusy.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= ③ 课表区(端点 48/49/50/51) ================= */
|
|
|
|
|
+const recFilters = reactive<{ range: [string, string] | []; status: number | undefined }>({ range: [], status: undefined });
|
|
|
|
|
+const { rows: records, total: recTotal, page: recPage, pageSize: recPageSize, loading: recLoading, error: recError, fetchList: fetchRecords } =
|
|
|
|
|
+ useListPage<CourseRecordDTO>({
|
|
|
|
|
+ fetcher: (p, ps) =>
|
|
|
|
|
+ listRecords(selectedCourseId.value ?? 0, {
|
|
|
|
|
+ // ⚠ 时间窗闭区间:to 含当日(契约 §3.C 显式例外)——daterange 原样传,不做 +1 天
|
|
|
|
|
+ from: recFilters.range[0] || undefined,
|
|
|
|
|
+ to: recFilters.range[1] || undefined,
|
|
|
|
|
+ status: recFilters.status,
|
|
|
|
|
+ page: p,
|
|
|
|
|
+ pageSize: ps,
|
|
|
|
|
+ }),
|
|
|
|
|
+ });
|
|
|
|
|
+const recStatusText: Record<number, string> = { 0: '未开始', 1: '上课中', 2: '已结束' };
|
|
|
|
|
+const recColumns: ListColumn<CourseRecordDTO>[] = [
|
|
|
|
|
+ { prop: 'recordId', label: '课表 id', minWidth: 90 },
|
|
|
|
|
+ { prop: 'teacherId', label: '老师', minWidth: 120, formatter: (r) => teacherName(r.teacherId) },
|
|
|
|
|
+ { prop: 'campusId', label: '校区', minWidth: 110, formatter: (r) => campusName(r.campusId) },
|
|
|
|
|
+ { prop: 'startTime', label: '上课时间', minWidth: 150, formatter: (r) => formatDateTime(r.startTime) },
|
|
|
|
|
+ { prop: 'endTime', label: '下课时间', minWidth: 150, formatter: (r) => formatDateTime(r.endTime) },
|
|
|
|
|
+ { prop: 'status', label: '状态', minWidth: 90, formatter: (r) => recStatusText[r.status] ?? String(r.status) },
|
|
|
|
|
+ { prop: 'studentCount', label: '学生数', minWidth: 80 },
|
|
|
|
|
+];
|
|
|
|
|
+function onRecSearch(): void {
|
|
|
|
|
+ recPage.value = 1;
|
|
|
|
|
+ void fetchRecords();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 生成课表对话框(端点 49;幂等:changed=false 提示无新增) */
|
|
|
|
|
+const genVisible = ref(false);
|
|
|
|
|
+const genBusy = ref(false);
|
|
|
|
|
+const genDays = ref(14);
|
|
|
|
|
+function openGenerate(): void {
|
|
|
|
|
+ genDays.value = 14;
|
|
|
|
|
+ genVisible.value = true;
|
|
|
|
|
+}
|
|
|
|
|
+async function doGenerate(): Promise<void> {
|
|
|
|
|
+ if (!selectedCourseId.value) return;
|
|
|
|
|
+ genBusy.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await generateRecords(selectedCourseId.value, { days: genDays.value });
|
|
|
|
|
+ if (r.code === 200 && r.data) {
|
|
|
|
|
+ if (r.data.changed) ElMessage_('success', `课表已更新:当前 ${r.data.generated} 节未开始课表(本次有新增)`);
|
|
|
|
|
+ else ElMessage_('warning', `无新增(幂等命中):当前 ${r.data.generated} 节未开始课表,请求 ${r.data.days} 天已覆盖`);
|
|
|
|
|
+ genVisible.value = false;
|
|
|
|
|
+ recPage.value = 1;
|
|
|
|
|
+ void fetchRecords();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ElMessage_('warning', r.message || `生成失败(code ${r.code})`);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ genBusy.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 开始上课(端点 50;仅未开始可开始,3121 读 code 直显) */
|
|
|
|
|
+const startBusy = ref(false);
|
|
|
|
|
+async function doStart(rec: CourseRecordDTO): Promise<void> {
|
|
|
|
|
+ startBusy.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await startRecord(rec.recordId);
|
|
|
|
|
+ if (r.code === 200) {
|
|
|
|
|
+ ElMessage_('success', '已开始上课');
|
|
|
|
|
+ void fetchRecords();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 3121 状态非法:读 code 直显 + 刷新课表(状态可能已被他人变更)
|
|
|
|
|
+ ElMessage_('warning', r.message || `开始失败(code ${r.code})`);
|
|
|
|
|
+ void fetchRecords();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ startBusy.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 结束上课(端点 51 · 重操作:扣课时+余额变动+分账+课酬)。纪律:二次确认 + loading 锁 + 失败/成功后刷新整页。 */
|
|
|
|
|
+const finishBusy = ref(false);
|
|
|
|
|
+async function doFinish(rec: CourseRecordDTO): Promise<void> {
|
|
|
|
|
+ if (finishBusy.value) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await ElConfirm(
|
|
|
|
|
+ `确认结束课表 ${rec.recordId}(${formatDateTime(rec.startTime)})?\n本操作为单事务重操作:将同时产生 扣课时 + 余额变动 + 分账明细 + 课酬记录,不可撤销。`,
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ finishBusy.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await finishRecord(rec.recordId);
|
|
|
|
|
+ if (r.code === 200 && r.data) {
|
|
|
|
|
+ ElMessage_('success', `下课结算完成:消耗 ${r.data.totalSpend},分账 ${r.data.divide.length} 条,课酬 ${r.data.payroll.reward}`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 3121 状态非法 / 其它:读 code 直显
|
|
|
|
|
+ ElMessage_('warning', r.message || `结束失败(code ${r.code})`);
|
|
|
|
|
+ }
|
|
|
|
|
+ // 契约明令:失败/成功后均刷新整页(课表 + 报课学生 + 欠费),不局部推断
|
|
|
|
|
+ recPage.value = 1;
|
|
|
|
|
+ void fetchRecords();
|
|
|
|
|
+ void loadStudents();
|
|
|
|
|
+ void fetchArrears();
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
|
|
+ recPage.value = 1;
|
|
|
|
|
+ void fetchRecords();
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ finishBusy.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= ④ 打卡对话框(端点 52;初值取端点 53 课程级学生) ================= */
|
|
|
|
|
+const attVisible = ref(false);
|
|
|
|
|
+const attBusy = ref(false);
|
|
|
|
|
+const attRecord = ref<CourseRecordDTO | null>(null);
|
|
|
|
|
+interface AttRow extends AttendanceItem {
|
|
|
|
|
+ /** 展示名(53 realName 可空 → 兜底 userId) */
|
|
|
|
|
+ name: string;
|
|
|
|
|
+}
|
|
|
|
|
+const attRows = ref<AttRow[]>([]);
|
|
|
|
|
+const attTypeText: Record<number, string> = { 1: '正常', 2: '插班', 3: '赠送' };
|
|
|
|
|
+async function openAttendance(rec: CourseRecordDTO): Promise<void> {
|
|
|
|
|
+ attRecord.value = rec;
|
|
|
|
|
+ attRows.value = [];
|
|
|
|
|
+ attVisible.value = true;
|
|
|
|
|
+ if (!selectedCourseId.value) return;
|
|
|
|
|
+ // 初值 = 端点 53 课程级报课学生(risk-1 免后端方案:52 提交时后端按该课实际学生匹配,不在课内的 userId 不生效)
|
|
|
|
|
+ const r = await listCourseStudents(selectedCourseId.value, { pageSize: 200 });
|
|
|
|
|
+ if (r.code !== 200) {
|
|
|
|
|
+ ElMessage_('error', r.message || '学生初值加载失败');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ attRows.value = (r.data?.list ?? []).map((s) => ({
|
|
|
|
|
+ userId: s.userId,
|
|
|
|
|
+ name: s.realName ?? s.userId,
|
|
|
|
|
+ type: 1,
|
|
|
|
|
+ pay: s.pay,
|
|
|
|
|
+ cost: s.cost,
|
|
|
|
|
+ giveCost: s.giveCost,
|
|
|
|
|
+ }));
|
|
|
|
|
+}
|
|
|
|
|
+async function doAttendance(): Promise<void> {
|
|
|
|
|
+ const rec = attRecord.value;
|
|
|
|
|
+ if (!rec) return;
|
|
|
|
|
+ const list: AttendanceItem[] = attRows.value.map(({ userId, type, pay, cost, giveCost }) => ({
|
|
|
|
|
+ userId,
|
|
|
|
|
+ type,
|
|
|
|
|
+ ...(pay ? { pay } : {}),
|
|
|
|
|
+ ...(cost ? { cost } : {}),
|
|
|
|
|
+ ...(giveCost ? { giveCost } : {}),
|
|
|
|
|
+ }));
|
|
|
|
|
+ attBusy.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await checkAttendance(rec.recordId, { list });
|
|
|
|
|
+ if (r.code === 200 && r.data) {
|
|
|
|
|
+ ElMessage_('success', `打卡完成:${r.data.checked} 人`);
|
|
|
|
|
+ attVisible.value = false;
|
|
|
|
|
+ void fetchRecords();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 3121 已结束不可再打卡:读 code 直显 + 刷新课表
|
|
|
|
|
+ ElMessage_('warning', r.message || `打卡失败(code ${r.code})`);
|
|
|
|
|
+ void fetchRecords();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ attBusy.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= ⑤ 报课学生区(端点 53/54) ================= */
|
|
|
|
|
+const stuRows = ref<CourseStudentDTO[]>([]);
|
|
|
|
|
+const stuLoading = ref(false);
|
|
|
|
|
+async function loadStudents(): Promise<void> {
|
|
|
|
|
+ if (!selectedCourseId.value) {
|
|
|
|
|
+ stuRows.value = [];
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ stuLoading.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await listCourseStudents(selectedCourseId.value, { pageSize: 200 });
|
|
|
|
|
+ if (r.code === 200) stuRows.value = r.data?.list ?? [];
|
|
|
|
|
+ else ElMessage_('error', r.message || '报课学生加载失败');
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ stuLoading.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+const stuStatusText: Record<number, string> = { 0: '未确认', 1: '在读', 2: '结课' };
|
|
|
|
|
+
|
|
|
|
|
+/** 报名对话框(端点 54;不写资金流水、不动余额;重复 → 3143) */
|
|
|
|
|
+const enrollVisible = ref(false);
|
|
|
|
|
+const enrollBusy = ref(false);
|
|
|
|
|
+const enrollForm = reactive({
|
|
|
|
|
+ userId: '',
|
|
|
|
|
+ pay: '',
|
|
|
|
|
+ lessonCost: '',
|
|
|
|
|
+ number: 1,
|
|
|
|
|
+ giveNumber: '',
|
|
|
|
|
+ discountCash: '',
|
|
|
|
|
+ giveCost: '',
|
|
|
|
|
+ campusId: 0,
|
|
|
|
|
+ recommenderUserId: '',
|
|
|
|
|
+ remark: '',
|
|
|
|
|
+});
|
|
|
|
|
+function openEnroll(): void {
|
|
|
|
|
+ enrollForm.userId = studentOpts.value[0]?.userId ?? '';
|
|
|
|
|
+ enrollForm.pay = '';
|
|
|
|
|
+ enrollForm.lessonCost = '';
|
|
|
|
|
+ enrollForm.number = 1;
|
|
|
|
|
+ enrollForm.giveNumber = '';
|
|
|
|
|
+ enrollForm.discountCash = '';
|
|
|
|
|
+ enrollForm.giveCost = '';
|
|
|
|
|
+ enrollForm.campusId = scope.current.campusId;
|
|
|
|
|
+ enrollForm.recommenderUserId = '';
|
|
|
|
|
+ enrollForm.remark = '';
|
|
|
|
|
+ enrollVisible.value = true;
|
|
|
|
|
+}
|
|
|
|
|
+async function doEnroll(): Promise<void> {
|
|
|
|
|
+ if (!selectedCourseId.value) return;
|
|
|
|
|
+ if (!enrollForm.userId || !enrollForm.pay || !enrollForm.lessonCost || enrollForm.number <= 0) {
|
|
|
|
|
+ ElMessage_('warning', '请填全学生/课程费用/单节课时费用/课次');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ enrollBusy.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await enrollStudent(selectedCourseId.value, {
|
|
|
|
|
+ userId: enrollForm.userId,
|
|
|
|
|
+ pay: enrollForm.pay,
|
|
|
|
|
+ lessonCost: enrollForm.lessonCost,
|
|
|
|
|
+ number: enrollForm.number,
|
|
|
|
|
+ ...(enrollForm.giveNumber ? { giveNumber: Number(enrollForm.giveNumber) } : {}),
|
|
|
|
|
+ ...(enrollForm.discountCash ? { discountCash: enrollForm.discountCash } : {}),
|
|
|
|
|
+ ...(enrollForm.giveCost ? { giveCost: enrollForm.giveCost } : {}),
|
|
|
|
|
+ ...(enrollForm.campusId ? { campusId: enrollForm.campusId } : {}),
|
|
|
|
|
+ ...(enrollForm.recommenderUserId ? { recommenderUserId: enrollForm.recommenderUserId } : {}),
|
|
|
|
|
+ ...(enrollForm.remark ? { remark: enrollForm.remark } : {}),
|
|
|
|
|
+ });
|
|
|
|
|
+ if (r.code === 200) {
|
|
|
|
|
+ ElMessage_('success', '报名成功(仅记应收/实收快照,不写资金流水、不动余额;真实收款走财务域)');
|
|
|
|
|
+ enrollVisible.value = false;
|
|
|
|
|
+ void loadStudents();
|
|
|
|
|
+ void fetchRecords(); // 学生数变化
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 3143 重复报名(专用码)/ 2001 参数缺失:读 code 直显
|
|
|
|
|
+ ElMessage_('warning', r.message || `报名失败(code ${r.code})`);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ enrollBusy.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= ⑥ 欠费列表(端点 55/56;课程域全局,不随选中课程过滤) ================= */
|
|
|
|
|
+const { rows: arrears, total: arrTotal, page: arrPage, pageSize: arrPageSize, loading: arrLoading, error: arrError, fetchList: fetchArrears } =
|
|
|
|
|
+ useListPage<ArrearsDTO>({
|
|
|
|
|
+ fetcher: (p, ps) => listArrears({ ...scope.toQuery(), page: p, pageSize: ps }),
|
|
|
|
|
+ });
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ void fetchArrears();
|
|
|
|
|
+});
|
|
|
|
|
+const arrColumns: ListColumn<ArrearsDTO>[] = [
|
|
|
|
|
+ { prop: 'studentId', label: '学生', minWidth: 140, formatter: (r) => studentName(r.studentId) },
|
|
|
|
|
+ { prop: 'courseId', label: '课程 id', minWidth: 90 },
|
|
|
|
|
+ { prop: 'remainderReceivable', label: '还需缴费', minWidth: 110 },
|
|
|
|
|
+ {
|
|
|
|
|
+ prop: 'counteracted',
|
|
|
|
|
+ label: '核销状态',
|
|
|
|
|
+ minWidth: 100,
|
|
|
|
|
+ formatter: (r) => (r.counteracted ? '有实收/核销' : '未核销'),
|
|
|
|
|
+ },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+/** 欠费核销对话框(端点 56;单事务) */
|
|
|
|
|
+const actVisible = ref(false);
|
|
|
|
|
+const actBusy = ref(false);
|
|
|
|
|
+const actTarget = ref<ArrearsDTO | null>(null);
|
|
|
|
|
+const actMoney = ref('');
|
|
|
|
|
+function openCounteract(row: ArrearsDTO): void {
|
|
|
|
|
+ actTarget.value = row;
|
|
|
|
|
+ actMoney.value = row.remainderReceivable;
|
|
|
|
|
+ actVisible.value = true;
|
|
|
|
|
+}
|
|
|
|
|
+async function doCounteract(): Promise<void> {
|
|
|
|
|
+ const t = actTarget.value;
|
|
|
|
|
+ if (!t || !actMoney.value || Number(actMoney.value) <= 0) {
|
|
|
|
|
+ ElMessage_('warning', '请输入有效核销金额(>0)');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ actBusy.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await counteractArrears({
|
|
|
|
|
+ studentId: t.studentId,
|
|
|
|
|
+ institutionId: scope.current.institutionId,
|
|
|
|
|
+ campusId: scope.current.campusId,
|
|
|
|
|
+ money: actMoney.value,
|
|
|
|
|
+ });
|
|
|
|
|
+ if (r.code === 200 && r.data) {
|
|
|
|
|
+ ElMessage_('success', `核销完成:${studentName(t.studentId)} 核销 ${r.data.amount}`);
|
|
|
|
|
+ actVisible.value = false;
|
|
|
|
|
+ arrPage.value = 1;
|
|
|
|
|
+ void fetchArrears();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ElMessage_('warning', r.message || `核销失败(code ${r.code})`);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ ElMessage_('error', '网络连接异常');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ actBusy.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<template>
|
|
|
<div class="schedule-page">
|
|
<div class="schedule-page">
|
|
|
|
|
+ <!-- ① 课程列表(端点 44;行点击 = 选中课程,驱动 ②③④⑤) -->
|
|
|
<ListPage
|
|
<ListPage
|
|
|
v-model:page="page"
|
|
v-model:page="page"
|
|
|
v-model:page-size="pageSize"
|
|
v-model:page-size="pageSize"
|
|
|
- :rows="rows"
|
|
|
|
|
|
|
+ :rows="courses"
|
|
|
:total="total"
|
|
:total="total"
|
|
|
:loading="loading"
|
|
:loading="loading"
|
|
|
:error="error"
|
|
:error="error"
|
|
|
:columns="columns"
|
|
:columns="columns"
|
|
|
@search="onSearch"
|
|
@search="onSearch"
|
|
|
|
|
+ @row-click="(row: CourseDTO) => onSelectCourse(row.id)"
|
|
|
>
|
|
>
|
|
|
<template #toolbar>
|
|
<template #toolbar>
|
|
|
<el-input v-model="filters.keyword" placeholder="搜索课程名称" clearable class="toolbar-input" />
|
|
<el-input v-model="filters.keyword" placeholder="搜索课程名称" clearable class="toolbar-input" />
|
|
|
<el-select v-model="filters.status" placeholder="状态" clearable class="toolbar-select">
|
|
<el-select v-model="filters.status" placeholder="状态" clearable class="toolbar-select">
|
|
|
- <el-option v-for="(t, k) in statusText" :key="k" :value="Number(k)" :label="t" />
|
|
|
|
|
|
|
+ <el-option v-for="(t, k) in courseStatusText" :key="k" :value="Number(k)" :label="t" />
|
|
|
</el-select>
|
|
</el-select>
|
|
|
</template>
|
|
</template>
|
|
|
</ListPage>
|
|
</ListPage>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ② 排班规则区(端点 46/47) -->
|
|
|
|
|
+ <div class="schedule-page__section">
|
|
|
|
|
+ <div class="schedule-page__head">
|
|
|
|
|
+ <span class="schedule-page__title">排班规则</span>
|
|
|
|
|
+ <el-select v-model="selectedCourseId" filterable placeholder="选择课程" class="schedule-page__course-select">
|
|
|
|
|
+ <el-option v-for="c in courses" :key="c.id" :value="c.id" :label="c.name" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ <el-button type="primary" :disabled="!selectedCourseId" @click="openClass">新建排班规则</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-table v-if="selectedCourse" :data="classRows" :loading="classLoading" size="small" border>
|
|
|
|
|
+ <el-table-column prop="classId" label="规则 id" width="90" />
|
|
|
|
|
+ <el-table-column prop="teacherId" label="老师" min-width="120">
|
|
|
|
|
+ <template #default="{ row }">{{ teacherName(row.teacherId) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="week" label="周" min-width="120">
|
|
|
|
|
+ <template #default="{ row }">{{ weekText(row.week) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="start" label="上课时间" width="150">
|
|
|
|
|
+ <template #default="{ row }">{{ formatDateTime(row.start) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="end" label="下课时间" width="150">
|
|
|
|
|
+ <template #default="{ row }">{{ formatDateTime(row.end) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="campusId" label="校区" min-width="110">
|
|
|
|
|
+ <template #default="{ row }">{{ campusName(row.campusId) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ <el-empty v-else description="选择课程后查看/管理排班规则" :image-size="60" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ③ 课表区(端点 48/49/50/51) -->
|
|
|
|
|
+ <div class="schedule-page__section">
|
|
|
|
|
+ <div class="schedule-page__head">
|
|
|
|
|
+ <span class="schedule-page__title">课表(排班实例)</span>
|
|
|
|
|
+ <el-date-picker
|
|
|
|
|
+ v-model="recFilters.range"
|
|
|
|
|
+ type="daterange"
|
|
|
|
|
+ value-format="YYYY-MM-DD"
|
|
|
|
|
+ range-separator="至"
|
|
|
|
|
+ start-placeholder="开始日期"
|
|
|
|
|
+ end-placeholder="结束日期(含当日)"
|
|
|
|
|
+ class="schedule-page__date-range"
|
|
|
|
|
+ />
|
|
|
|
|
+ <el-select v-model="recFilters.status" placeholder="状态" clearable class="schedule-page__rec-status">
|
|
|
|
|
+ <el-option v-for="(t, k) in recStatusText" :key="k" :value="Number(k)" :label="t" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ <el-button @click="onRecSearch" :disabled="!selectedCourseId">刷新</el-button>
|
|
|
|
|
+ <el-button type="warning" :disabled="!selectedCourseId" @click="openGenerate">生成课表</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="selectedCourse" class="schedule-page__rec-table">
|
|
|
|
|
+ <ListPage
|
|
|
|
|
+ v-model:page="recPage"
|
|
|
|
|
+ v-model:page-size="recPageSize"
|
|
|
|
|
+ :rows="records"
|
|
|
|
|
+ :total="recTotal"
|
|
|
|
|
+ :loading="recLoading"
|
|
|
|
|
+ :error="recError"
|
|
|
|
|
+ :columns="recColumns"
|
|
|
|
|
+ >
|
|
|
|
|
+ </ListPage>
|
|
|
|
|
+ <!-- 行操作(ListPage 无操作列插槽,按批次 B 模式放在表下逐行) -->
|
|
|
|
|
+ <div class="schedule-page__rec-actions">
|
|
|
|
|
+ <div v-for="rec in records" :key="rec.recordId" class="schedule-page__rec-row">
|
|
|
|
|
+ <span class="schedule-page__rec-label">
|
|
|
|
|
+ 课表 {{ rec.recordId }} · {{ formatDateTime(rec.startTime) }} · {{ recStatusText[rec.status] ?? rec.status }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ v-if="rec.status === 0"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ type="primary"
|
|
|
|
|
+ :loading="startBusy"
|
|
|
|
|
+ @click="doStart(rec)"
|
|
|
|
|
+ >
|
|
|
|
|
+ 开始上课
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ <template v-if="rec.status === 1">
|
|
|
|
|
+ <el-button size="small" type="warning" @click="openAttendance(rec)">打卡</el-button>
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ type="danger"
|
|
|
|
|
+ :loading="finishBusy"
|
|
|
|
|
+ :disabled="finishBusy"
|
|
|
|
|
+ @click="doFinish(rec)"
|
|
|
|
|
+ >
|
|
|
|
|
+ 结束上课
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <span v-if="rec.status === 2" class="schedule-page__rec-done">已结算</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-empty v-if="records.length === 0" description="暂无课表(可点「生成课表」)" :image-size="60" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-empty v-else description="选择课程后查看课表" :image-size="60" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ⑤ 报课学生区(端点 53/54) -->
|
|
|
|
|
+ <div class="schedule-page__section">
|
|
|
|
|
+ <div class="schedule-page__head">
|
|
|
|
|
+ <span class="schedule-page__title">报课学生</span>
|
|
|
|
|
+ <el-button type="primary" :disabled="!selectedCourseId" @click="openEnroll">报名</el-button>
|
|
|
|
|
+ <span class="schedule-page__hint">欠费列 = remainderReceivable(还需缴费);报名不写资金流水、不动余额</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-table v-if="selectedCourse" :data="stuRows" :loading="stuLoading" size="small" border>
|
|
|
|
|
+ <el-table-column prop="userId" label="学生" min-width="150">
|
|
|
|
|
+ <template #default="{ row }">{{ row.realName ?? row.userId }}({{ row.userId.slice(0, 8) }}…)</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="pay" label="课程费用" width="110" />
|
|
|
|
|
+ <el-table-column prop="cost" label="课时费用" width="110" />
|
|
|
|
|
+ <el-table-column prop="giveCost" label="赠送" width="100" />
|
|
|
|
|
+ <el-table-column prop="remainderReceivable" label="还需缴费" width="110" />
|
|
|
|
|
+ <el-table-column prop="status" label="状态" width="90">
|
|
|
|
|
+ <template #default="{ row }">{{ stuStatusText[row.status] ?? String(row.status) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ <el-empty v-else description="选择课程后查看报课学生" :image-size="60" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ⑥ 欠费列表(端点 55/56;课程域全局,不随选中课程过滤) -->
|
|
|
|
|
+ <div class="schedule-page__section">
|
|
|
|
|
+ <div class="schedule-page__head">
|
|
|
|
|
+ <span class="schedule-page__title">欠费列表(全局)</span>
|
|
|
|
|
+ <span class="schedule-page__hint">口径 = remainderReceivable > 0;核销为单事务</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <ListPage
|
|
|
|
|
+ v-model:page="arrPage"
|
|
|
|
|
+ v-model:page-size="arrPageSize"
|
|
|
|
|
+ :rows="arrears"
|
|
|
|
|
+ :total="arrTotal"
|
|
|
|
|
+ :loading="arrLoading"
|
|
|
|
|
+ :error="arrError"
|
|
|
|
|
+ :columns="arrColumns"
|
|
|
|
|
+ >
|
|
|
|
|
+ </ListPage>
|
|
|
|
|
+ <div class="schedule-page__arr-actions">
|
|
|
|
|
+ <div v-for="a in arrears" :key="a.studentId + '-' + a.courseId" class="schedule-page__rec-row">
|
|
|
|
|
+ <span class="schedule-page__rec-label">
|
|
|
|
|
+ {{ studentName(a.studentId) }} · 课程 {{ a.courseId }} · 欠费 {{ formatMoney(parseFloat(a.remainderReceivable)) }} 元
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <el-button size="small" type="primary" @click="openCounteract(a)">核销</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-empty v-if="arrears.length === 0" description="当前无欠费记录" :image-size="60" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 新建排班规则对话框(端点 47) -->
|
|
|
|
|
+ <el-dialog v-model="classVisible" title="新建排班规则" width="520px">
|
|
|
|
|
+ <el-form label-width="90px">
|
|
|
|
|
+ <el-form-item label="课程">
|
|
|
|
|
+ <span>{{ selectedCourse?.name }}</span>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="授课老师">
|
|
|
|
|
+ <el-select v-model="classForm.teacher" filterable placeholder="选择老师" style="width: 100%">
|
|
|
|
|
+ <el-option v-for="m in staffOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="上课周">
|
|
|
|
|
+ <el-select v-model="classForm.weeks" multiple placeholder="选择周(-1 = 全周,与具体周互斥)" style="width: 100%">
|
|
|
|
|
+ <el-option :value="-1" label="全周(-1)" />
|
|
|
|
|
+ <el-option v-for="(t, k) in WEEK_NAMES" :key="k" :value="Number(k)" :label="t" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="上课时间">
|
|
|
|
|
+ <el-time-picker v-model="classForm.start" value-format="HH:mm:ss" format="HH:mm:ss" placeholder="选择上课时间" style="width: 100%" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="下课时间">
|
|
|
|
|
+ <el-time-picker v-model="classForm.end" value-format="HH:mm:ss" format="HH:mm:ss" placeholder="选择下课时间" style="width: 100%" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="校区">
|
|
|
|
|
+ <el-select v-model="classForm.campusId" style="width: 100%">
|
|
|
|
|
+ <el-option :value="0" label="机构级" />
|
|
|
|
|
+ <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-alert title="冲突(同老师+周+校区已有规则)→ 3122;无 room 字段(v1.8:源列只有 address)" type="info" :closable="false" />
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ <template #footer>
|
|
|
|
|
+ <el-button @click="classVisible = false">取消</el-button>
|
|
|
|
|
+ <el-button type="primary" :loading="classBusy" @click="doCreateClass">创建</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 生成课表对话框(端点 49;幂等) -->
|
|
|
|
|
+ <el-dialog v-model="genVisible" title="生成课表" width="420px">
|
|
|
|
|
+ <el-form label-width="100px">
|
|
|
|
|
+ <el-form-item label="课程">
|
|
|
|
|
+ <span>{{ selectedCourse?.name }}</span>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="生成天数">
|
|
|
|
|
+ <el-input-number v-model="genDays" :min="1" :max="60" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-alert title="幂等:同课程+起始时间已生成的课表不重复插入;changed=false 表示本次无新增" type="info" :closable="false" />
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ <template #footer>
|
|
|
|
|
+ <el-button @click="genVisible = false">取消</el-button>
|
|
|
|
|
+ <el-button type="primary" :loading="genBusy" @click="doGenerate">生成</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ④ 打卡对话框(端点 52;逐学生 type/pay/cost/giveCost) -->
|
|
|
|
|
+ <el-dialog v-model="attVisible" :title="`打卡 · 课表 ${attRecord?.recordId ?? ''}`" width="640px">
|
|
|
|
|
+ <el-table :data="attRows" size="small" border max-height="360">
|
|
|
|
|
+ <el-table-column prop="name" label="学生" min-width="130" />
|
|
|
|
|
+ <el-table-column label="类型" width="110">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-select v-model="row.type" size="small">
|
|
|
|
|
+ <el-option v-for="(t, k) in attTypeText" :key="k" :value="Number(k)" :label="t" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="课程费用" width="120">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-input v-model="row.pay" size="small" placeholder="金额" />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="课时费用" width="120">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-input v-model="row.cost" size="small" placeholder="金额" />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="赠送" width="110">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-input v-model="row.giveCost" size="small" placeholder="可空" />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ <el-alert
|
|
|
|
|
+ v-if="attRows.length === 0"
|
|
|
|
|
+ title="学生初值加载中(取课程级报课学生;提交时按该课实际学生匹配)"
|
|
|
|
|
+ type="info"
|
|
|
|
|
+ :closable="false"
|
|
|
|
|
+ class="att-loading"
|
|
|
|
|
+ />
|
|
|
|
|
+ <template #footer>
|
|
|
|
|
+ <el-button @click="attVisible = false">取消</el-button>
|
|
|
|
|
+ <el-button type="primary" :loading="attBusy" :disabled="attRows.length === 0" @click="doAttendance">提交打卡</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 报名对话框(端点 54;不写资金流水、不动余额) -->
|
|
|
|
|
+ <el-dialog v-model="enrollVisible" title="报名 / 补报名" width="520px">
|
|
|
|
|
+ <el-form label-width="110px">
|
|
|
|
|
+ <el-form-item label="课程">
|
|
|
|
|
+ <span>{{ selectedCourse?.name }}</span>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="学生">
|
|
|
|
|
+ <el-select v-model="enrollForm.userId" filterable placeholder="选择学生" style="width: 100%">
|
|
|
|
|
+ <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="课程费用">
|
|
|
|
|
+ <el-input v-model="enrollForm.pay" placeholder="必填,如 10000.00" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="单节课时费用">
|
|
|
|
|
+ <el-input v-model="enrollForm.lessonCost" placeholder="必填,如 200.00" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="课次">
|
|
|
|
|
+ <el-input-number v-model="enrollForm.number" :min="1" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="赠送课次">
|
|
|
|
|
+ <el-input v-model="enrollForm.giveNumber" placeholder="可空" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="现金折扣">
|
|
|
|
|
+ <el-input v-model="enrollForm.discountCash" placeholder="可空,金额" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="赠送金额">
|
|
|
|
|
+ <el-input v-model="enrollForm.giveCost" placeholder="可空,金额" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="校区">
|
|
|
|
|
+ <el-select v-model="enrollForm.campusId" style="width: 100%">
|
|
|
|
|
+ <el-option :value="0" label="机构级" />
|
|
|
|
|
+ <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="推荐人">
|
|
|
|
|
+ <el-select v-model="enrollForm.recommenderUserId" filterable clearable placeholder="可空" style="width: 100%">
|
|
|
|
|
+ <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="m.realName" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="备注">
|
|
|
|
|
+ <el-input v-model="enrollForm.remark" placeholder="可空" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-alert
|
|
|
|
|
+ title="报名只记应收/实收快照:不写资金流水、不动余额(真实收款走财务域);重复报名 → 3143"
|
|
|
|
|
+ type="info"
|
|
|
|
|
+ :closable="false"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ <template #footer>
|
|
|
|
|
+ <el-button @click="enrollVisible = false">取消</el-button>
|
|
|
|
|
+ <el-button type="primary" :loading="enrollBusy" @click="doEnroll">确认报名</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 欠费核销对话框(端点 56;单事务) -->
|
|
|
|
|
+ <el-dialog v-model="actVisible" title="欠费核销" width="420px">
|
|
|
|
|
+ <el-form label-width="100px">
|
|
|
|
|
+ <el-form-item label="学生">
|
|
|
|
|
+ <span>{{ studentName(actTarget?.studentId ?? null) }}</span>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="课程 id">
|
|
|
|
|
+ <span>{{ actTarget?.courseId }}</span>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="当前欠费">
|
|
|
|
|
+ <span>{{ actTarget?.remainderReceivable }}</span>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="核销金额">
|
|
|
|
|
+ <el-input v-model="actMoney" placeholder="金额(>0)" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ <template #footer>
|
|
|
|
|
+ <el-button @click="actVisible = false">取消</el-button>
|
|
|
|
|
+ <el-button type="primary" :loading="actBusy" @click="doCounteract">核销</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
@@ -60,4 +876,61 @@ onMounted(() => {
|
|
|
.schedule-page {
|
|
.schedule-page {
|
|
|
width: 100%;
|
|
width: 100%;
|
|
|
}
|
|
}
|
|
|
|
|
+.schedule-page__section {
|
|
|
|
|
+ margin-top: 16px;
|
|
|
|
|
+ padding-top: 12px;
|
|
|
|
|
+ border-top: 1px solid var(--app-color-border, #e3e7ea);
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__head {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__title {
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ min-width: 130px;
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__course-select {
|
|
|
|
|
+ width: 240px;
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__date-range {
|
|
|
|
|
+ width: 260px;
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__rec-status {
|
|
|
|
|
+ width: 120px;
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__hint {
|
|
|
|
|
+ font-size: var(--app-font-sm);
|
|
|
|
|
+ color: var(--app-color-text-2, #5d6672);
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__rec-actions {
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__arr-actions {
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__rec-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__rec-label {
|
|
|
|
|
+ font-size: var(--app-font-md);
|
|
|
|
|
+ color: var(--app-color-text-2, #5d6672);
|
|
|
|
|
+ min-width: 280px;
|
|
|
|
|
+}
|
|
|
|
|
+.schedule-page__rec-done {
|
|
|
|
|
+ font-size: var(--app-font-sm);
|
|
|
|
|
+ color: var(--app-color-text-2, #5d6672);
|
|
|
|
|
+}
|
|
|
|
|
+.att-loading {
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+}
|
|
|
</style>
|
|
</style>
|