From 35b252249d07e910a0e84563845de10747a674be Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 17 Oct 2026 09:38:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=A2=9E=E5=8A=A0=E6=97=A5?= =?UTF-8?q?=E8=AF=BE=E8=A1=A8=E7=BC=96=E8=BE=91=E4=B8=8E=E5=AD=A6=E6=9C=9F?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/PromotionSettings.vue | 77 ++ .../src/components/ReleaseProgress.vue | 47 ++ .../src/components/TransferredStudents.vue | 31 + .../web-admin/src/layouts/AdminLayout.vue | 15 + packages/web-admin/src/router/index.ts | 23 + packages/web-admin/src/stores/realtime.ts | 3 + packages/web-admin/src/views/ArchivesView.vue | 68 +- packages/web-admin/src/views/ClassesView.vue | 12 +- packages/web-admin/src/views/ClientsView.vue | 43 +- .../web-admin/src/views/NotificationsView.vue | 26 +- .../web-admin/src/views/PermissionsView.vue | 21 + .../web-admin/src/views/PlanEditorView.vue | 671 ++++++++++++++++++ .../web-admin/src/views/SchedulesView.vue | 5 +- packages/web-admin/src/views/StudentsView.vue | 35 +- .../src/views/SystemSettingsView.vue | 35 + .../web-admin/src/views/TermWeeksView.vue | 14 +- 16 files changed, 1014 insertions(+), 112 deletions(-) create mode 100644 packages/web-admin/src/components/PromotionSettings.vue create mode 100644 packages/web-admin/src/components/ReleaseProgress.vue create mode 100644 packages/web-admin/src/components/TransferredStudents.vue create mode 100644 packages/web-admin/src/views/PlanEditorView.vue create mode 100644 packages/web-admin/src/views/SystemSettingsView.vue diff --git a/packages/web-admin/src/components/PromotionSettings.vue b/packages/web-admin/src/components/PromotionSettings.vue new file mode 100644 index 0000000..9198b60 --- /dev/null +++ b/packages/web-admin/src/components/PromotionSettings.vue @@ -0,0 +1,77 @@ + + diff --git a/packages/web-admin/src/components/ReleaseProgress.vue b/packages/web-admin/src/components/ReleaseProgress.vue new file mode 100644 index 0000000..7837008 --- /dev/null +++ b/packages/web-admin/src/components/ReleaseProgress.vue @@ -0,0 +1,47 @@ + + diff --git a/packages/web-admin/src/components/TransferredStudents.vue b/packages/web-admin/src/components/TransferredStudents.vue new file mode 100644 index 0000000..883868f --- /dev/null +++ b/packages/web-admin/src/components/TransferredStudents.vue @@ -0,0 +1,31 @@ + + diff --git a/packages/web-admin/src/layouts/AdminLayout.vue b/packages/web-admin/src/layouts/AdminLayout.vue index 12064e5..f5db869 100644 --- a/packages/web-admin/src/layouts/AdminLayout.vue +++ b/packages/web-admin/src/layouts/AdminLayout.vue @@ -97,6 +97,21 @@ interface MenuItem { } const menuItems: MenuItem[] = [ + { + path: '/plan-editor', + title: '时间表与课表编辑', + icon: 'Calendar', + roles: ['ADMIN', 'TEACHER'], + permission: 'schedule:read', + }, + { path: '/roster', title: '学生名单', icon: 'User', roles: ['TEACHER'], permission: 'roster:read' }, + { + path: '/system-settings', + title: '系统设置', + icon: 'Setting', + roles: ['ADMIN', 'TEACHER'], + permission: 'system:manage', + }, { path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] }, { path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] }, { path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] }, diff --git a/packages/web-admin/src/router/index.ts b/packages/web-admin/src/router/index.ts index b3c3991..b88940d 100644 --- a/packages/web-admin/src/router/index.ts +++ b/packages/web-admin/src/router/index.ts @@ -14,6 +14,29 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/layouts/AdminLayout.vue'), redirect: '/dashboard', children: [ + { + path: 'plan-editor', + name: 'plan-editor', + component: () => import('@/views/PlanEditorView.vue'), + meta: { + title: '时间表与课表编辑', + icon: 'Calendar', + roles: ['ADMIN', 'TEACHER'], + permission: 'schedule:read', + }, + }, + { + path: 'roster', + name: 'roster', + component: () => import('@/views/StudentsView.vue'), + meta: { title: '学生名单', icon: 'User', roles: ['TEACHER'], permission: 'roster:read' }, + }, + { + path: 'system-settings', + name: 'system-settings', + component: () => import('@/views/SystemSettingsView.vue'), + meta: { title: '系统设置', icon: 'Setting', permission: 'system:manage' }, + }, { path: 'dashboard', name: 'dashboard', diff --git a/packages/web-admin/src/stores/realtime.ts b/packages/web-admin/src/stores/realtime.ts index b96ee17..69df5d7 100644 --- a/packages/web-admin/src/stores/realtime.ts +++ b/packages/web-admin/src/stores/realtime.ts @@ -48,6 +48,9 @@ export const useRealtimeStore = defineStore('realtime', () => { } function bindEvents(instance: Socket): void { + instance.on(SOCKET_EVENTS.clientReleaseProgress, (payload) => { + dispatch(SOCKET_EVENTS.clientReleaseProgress, payload); + }); instance.on('connect', () => { connected.value = true; connecting.value = false; diff --git a/packages/web-admin/src/views/ArchivesView.vue b/packages/web-admin/src/views/ArchivesView.vue index 47b9f35..8381a54 100644 --- a/packages/web-admin/src/views/ArchivesView.vue +++ b/packages/web-admin/src/views/ArchivesView.vue @@ -9,7 +9,7 @@ * * 撤销归档是误操作的退路:把班级与学生还原成在读,同样不删数据。 */ -import { computed, onMounted, reactive, ref } from 'vue'; +import { onMounted, ref } from 'vue'; import { ElMessage, ElMessageBox } from 'element-plus'; import { STUDENT_STATUS_LABELS, @@ -21,6 +21,7 @@ import { type ArchivedYearDto, type StudentStatus, } from '@classhelper/shared'; +import TransferredStudents from '@/components/TransferredStudents.vue'; import { archiveApi } from '@/api'; const loading = ref(false); @@ -80,40 +81,6 @@ async function openContent(row: ArchivedClassDto): Promise { } } -/* ------------------------------------------------------------ 执行归档 */ - -const createVisible = ref(false); -const creating = ref(false); -const createForm = reactive({ enrollmentYear: new Date().getFullYear() - 3, note: '' }); - -/** 已经归档过的届别(下拉里标出来,避免重复提交) */ -const archivedYears = computed(() => new Set(years.value.map((item) => item.enrollmentYear))); - -function openCreate(): void { - const suggested = summary.value?.suggestedEnrollmentYears ?? []; - createForm.enrollmentYear = - suggested.find((year) => !archivedYears.value.has(year)) ?? createForm.enrollmentYear; - createForm.note = ''; - createVisible.value = true; -} - -async function submitCreate(): Promise { - creating.value = true; - try { - const result = await archiveApi.create({ - enrollmentYear: createForm.enrollmentYear, - ...(createForm.note.trim() ? { note: createForm.note.trim() } : {}), - }); - ElMessage.success( - `${result.name} 已归档:${result.classCount} 个班级、${result.studentCount} 名毕业生、${result.transferredCount} 名转出`, - ); - createVisible.value = false; - await loadYears(); - } finally { - creating.value = false; - } -} - /** 撤销归档:班级与学生回到在读,不删任何数据 */ async function undo(row: ArchivedYearDto): Promise { await ElMessageBox.confirm( @@ -133,6 +100,7 @@ onMounted(loadYears); diff --git a/packages/web-admin/src/views/ClassesView.vue b/packages/web-admin/src/views/ClassesView.vue index f851393..43835e8 100644 --- a/packages/web-admin/src/views/ClassesView.vue +++ b/packages/web-admin/src/views/ClassesView.vue @@ -3,7 +3,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; import { SOCKET_EVENTS, - MAX_TERM_WEEK, + getMaxTermWeeks, SUBJECT_CATALOG, currentAcademicYearStart, formatClassName, @@ -18,7 +18,7 @@ import { type UpdateClassAccountRequest, type UserDto, } from '@classhelper/shared'; -import { classApi, courseApi, teacherApi } from '@/api'; +import { classApi, courseApi, teacherApi, termApi } from '@/api'; import { useAuthStore } from '@/stores/auth'; import { useRealtimeStore } from '@/stores/realtime'; import { useResponsive } from '@/composables/useResponsive'; @@ -37,6 +37,10 @@ const detailColumns = computed(() => (isMobile.value ? 1 : 3)); const loading = ref(false); const classes = ref([]); +const maxTermWeeks = ref(getMaxTermWeeks()); +onMounted(async () => { + maxTermWeeks.value = getMaxTermWeeks((await termApi.get()).maxTermWeeks); +}); const keyword = ref(''); async function loadClasses(): Promise { @@ -611,9 +615,9 @@ onUnmounted(() => { - + - 本学期一共多少教学周(默认 20,最多 {{ MAX_TERM_WEEK }} 周);课表周次选择与默认结束周都按它来 + 本学期一共多少教学周(默认 20,最多 {{ maxTermWeeks }} 周);课表周次选择与默认结束周都按它来 diff --git a/packages/web-admin/src/views/ClientsView.vue b/packages/web-admin/src/views/ClientsView.vue index f6f9956..643b544 100644 --- a/packages/web-admin/src/views/ClientsView.vue +++ b/packages/web-admin/src/views/ClientsView.vue @@ -13,10 +13,11 @@ * 所以"下发成功"不等于"已经执行"——它只是排进了队列; * - 更新先下载校验,再按服务端指定的立即 / 放学后 / 空闲五分钟策略安装。 * - * 权限:查看 `client:read`、删除 `client:manage`、更新 `client:update-push`、远控 `remote:control`。 + * 权限:查看 `client:read`、删除 `client:manage`、更新 `client:update`、远控 `remote:control`。 * 教师默认可以查看;删除、更新和远控默认只给管理员。无权限的按钮是 * **禁用 + tooltip 说明**而不是直接隐藏:让老师知道"有这件事、需要权限",而不是以为系统没有。 */ +import ReleaseProgress from '@/components/ReleaseProgress.vue'; import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'; import { ElMessage, ElMessageBox } from 'element-plus'; import { @@ -388,7 +389,7 @@ async function submitCommand(): Promise { .slice(0, 8) .map((device) => `· ${device.name}(${classLabel(device)})`) .join('\n')}${targets.length > 8 ? `\n· …等共 ${targets.length} 台` : ''}\n\n` + - `目标机器屏幕上会弹出确认框,本机用户确认后才会执行;命令 ${commandExpireText.value}内有效,过期自动失效。` + + `目标机器收到后立即执行,不再等待教室确认;命令 ${commandExpireText.value}内有效,过期自动失效。` + (note ? `\n备注:${note}` : ''); try { @@ -635,6 +636,7 @@ const deviceEmptyText = computed(() => — @@ -810,7 +808,7 @@ const deviceEmptyText = computed(() => row.lastCommand.result || (row.lastCommand.expiresAt && Date.parse(row.lastCommand.expiresAt) < Date.now() ? '已过期(设备未回执)' - : '等待设备接收或本机确认') + : '等待设备接收并回执') }} — @@ -1010,7 +1008,7 @@ const deviceEmptyText = computed(() => type="warning" :closable="false" show-icon - title="目标机器屏幕上会弹出确认框,本机用户确认后才会执行" + title="目标机器收到后立即执行,不再等待教室确认" > @@ -1072,7 +1070,7 @@ const deviceEmptyText = computed(() => class="client-release-submit" :icon="'Upload'" :loading="releasesDrawer.fetching" - :disabled="!can('client:update-push')" + :disabled="!can('client:update')" @click="fetchRelease" > 拉取并登记 @@ -1105,7 +1103,7 @@ const deviceEmptyText = computed(() => class="client-release-submit" :icon="'FolderAdd'" :loading="releasesDrawer.registering" - :disabled="!can('client:update-push')" + :disabled="!can('client:update')" @click="registerRelease" > 登记 @@ -1144,17 +1142,12 @@ const deviceEmptyText = computed(() => 设为更新目标 - + 删除 diff --git a/packages/web-admin/src/views/NotificationsView.vue b/packages/web-admin/src/views/NotificationsView.vue index b00851c..7956059 100644 --- a/packages/web-admin/src/views/NotificationsView.vue +++ b/packages/web-admin/src/views/NotificationsView.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'; import { useRoute } from 'vue-router'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; import { + dayKeyLocal, CALL_QUICK_PHRASES, NOTIFICATION_PRIORITIES, PRIORITY_LABELS, @@ -454,20 +455,27 @@ onUnmounted(() => { /> - - - + + +import type { PermissionScope as ContractPermissionScope } from '@classhelper/shared'; + /** * 权限组管理(对应需求「允许 admin 设置各用户权限,或创建权限组」)。 * @@ -119,6 +121,8 @@ const groupDialog = reactive({ id: '', name: '', description: '', + scope: 'SELF_CLASS' as ContractPermissionScope, + scopeValue: '', keys: [] as PermissionKey[], saving: false, }); @@ -131,6 +135,8 @@ function openCreateGroup(): void { groupDialog.id = ''; groupDialog.name = ''; groupDialog.description = ''; + groupDialog.scope = 'SELF_CLASS'; + groupDialog.scopeValue = ''; groupDialog.keys = []; groupDialog.visible = true; } @@ -139,6 +145,8 @@ function openEditGroup(group: PermissionGroupDto): void { groupDialog.id = group.id; groupDialog.name = group.name; groupDialog.description = group.description; + groupDialog.scope = group.scope ?? 'SELF_CLASS'; + groupDialog.scopeValue = group.scopeValue ?? ''; // 复制一份:取消/关闭时不能污染表格里的数据 groupDialog.keys = [...group.keys]; groupDialog.visible = true; @@ -200,6 +208,8 @@ async function submitGroup(): Promise { const payload = { name, description: groupDialog.description.trim(), + scope: groupDialog.scope, + scopeValue: groupDialog.scopeValue, // 传了就**整体替换**(服务端契约),所以这里始终带上完整勾选结果 keys: [...groupDialog.keys], }; @@ -434,6 +444,17 @@ async function submitAssign(): Promise { placeholder="例如:教务协助、机房值班" /> + + + + + + + + + + + +import type { ActiveClassPlanWeekDto as ContractActiveClassPlanWeekDto } from '@classhelper/shared'; + +import { computed, onMounted, ref, watch } from 'vue'; +import { ElMessage, ElMessageBox } from 'element-plus'; +import { + TIME_POINT_TYPES, + type ClassPlanDto, + type TimeLayoutDetailDto, + type TimePointDto, + type ClassDto, + type CourseDto, +} from '@classhelper/shared'; +import { api } from '@/api/http'; +import { classApi, courseApi } from '@/api'; +import { useAuthStore } from '@/stores/auth'; +import { useResponsive } from '@/composables/useResponsive'; +const auth = useAuthStore(); +const { isMobile } = useResponsive(); +const canTime = computed(() => auth.permissions.includes('timetable:manage')); +const canWrite = computed(() => auth.permissions.includes('schedule:write')); +const classes = ref([]), + courses = ref([]); +const classId = ref(''), + layouts = ref([]), + plans = ref([]); +const selectedLayout = ref(''), + selectedPlan = ref(''), + week = ref(1), + tab = ref('plans'); +const groups = ref<{ id: string; name: string; isGlobal: boolean; isDefault: boolean }[]>([]); +const layout = computed(() => layouts.value.find((p) => p.id === selectedLayout.value)); +const plan = computed(() => plans.value.find((p) => p.id === selectedPlan.value)); +const selected = ref([]), + copied = ref([]); +const types = { LESSON: '上课', BREAK: '课间', DIVIDER: '分割线', ACTION: '行动(只保存)' }; +const selectedGroup = ref(''), + temporaryGroup = ref(''), + expireTime = ref(''); +const scheduled = ref<{ id: string; date: string; plan: { name: string; isOverlay: boolean } }[]>([]); +const activeWeek = ref(); +function dayPlan(day: number) { + const active = activeWeek.value?.days.find((d) => d.weekday === day)?.classPlan; + return active ? (plans.value.find((p) => p.id === active.id) ?? active) : undefined; +} +function gridEntry(day: number, point: TimePointDto) { + const p = dayPlan(day); + if (!p) return; + const l = layouts.value.find((l) => l.id === p.timeLayoutId); + const slot = l?.points.find( + (s) => s.startTime === point.startTime && s.endTime === point.endTime && s.timeType === 'LESSON', + ); + return p.entries.find((e) => e.timeLayoutItemId === slot?.id); +} +async function setGrid(day: number, point: TimePointDto, value: string | null) { + const p = dayPlan(day); + if (!p) return; + const l = layouts.value.find((l) => l.id === p.timeLayoutId), + slot = l?.points.find( + (s) => s.startTime === point.startTime && s.endTime === point.endTime && s.timeType === 'LESSON', + ); + if (!slot) return; + let e = p.entries.find((e) => e.timeLayoutItemId === slot.id); + if (!e) { + e = { + id: '', + timeLayoutItemId: slot.id, + courseId: null, + isEnabled: true, + isChangedClass: false, + location: null, + }; + p.entries.push(e); + } + e.courseId = value; + await api.put(`/class-plans/${p.id}/entries`, { entries: p.entries }); +} +async function selectGroup(temporary = false) { + await api.post('/class-plan-groups/select', { + classId: classId.value, + selected: selectedGroup.value || undefined, + ...(temporary + ? { temporary: temporaryGroup.value, expireTime: new Date(expireTime.value).toISOString() } + : {}), + }); + await load(); +} +async function cancelScheduled(id: string) { + await api.delete(`/schedules/scheduled/${id}`); + await load(); +} +let loadRequest = 0; +async function load() { + if (!classId.value) return; + const maximum = classes.value.find((c) => c.id === classId.value)?.termWeeks || 60; + if (week.value > maximum) { + week.value = maximum; + return; + } + const request = ++loadRequest, + scope = { classId: classId.value, week: week.value }; + const [ls, ps, cs, gs, ss, active, selection] = await Promise.all([ + api.get('/time-layouts', { classId: scope.classId }), + api.get('/class-plans', scope), + courseApi.list(scope.classId), + api.get('/class-plan-groups', { classId: scope.classId }), + api.get('/schedules/scheduled', { classId: scope.classId }), + api.get('/schedules/active-week', scope), + api.get<{ selected?: string; temporary?: string; expireTime?: string }>('/class-plan-groups/selection', { + classId: scope.classId, + }), + ]); + if (request !== loadRequest) return; + layouts.value = ls; + plans.value = ps; + courses.value = cs; + groups.value = gs; + scheduled.value = ss; + activeWeek.value = active; + for (const d of active.days) + if (d.classPlan && !plans.value.some((p) => p.id === d.classPlan!.id)) plans.value.push(d.classPlan); + selectedGroup.value = selection.selected || ''; + temporaryGroup.value = selection.temporary || ''; + if (!plans.value.some((p) => p.id === selectedPlan.value)) + selectedPlan.value = active.days.find((d) => d.classPlan)?.classPlan?.id || plans.value[0]?.id || ''; + if (!layouts.value.some((p) => p.id === selectedLayout.value)) + selectedLayout.value = plan.value?.timeLayoutId || layouts.value[0]?.id || ''; +} +watch([classId, week], () => void load()); +watch(selectedPlan, () => { + selected.value = []; + if (plan.value) selectedLayout.value = plan.value.timeLayoutId; +}); +function addPoint() { + layout.value?.points.push({ + id: '', + name: '', + startTime: '08:00', + endTime: '08:45', + timeType: 'LESSON', + position: layout.value.points.length, + isHideDefault: false, + defaultClassId: null, + breakName: '', + actionSet: '{}', + }); +} +async function newLayout() { + const { value } = await ElMessageBox.prompt('输入时间表名称', '新时间表'); + const result = await api.post('/time-layouts', { + name: value, + classId: null, + points: [], + }); + selectedLayout.value = result.id; + await load(); +} +async function saveLayout() { + if (layout.value) { + await api.put(`/time-layouts/${layout.value.id}`, { + ...layout.value, + points: layout.value.points.map((p) => ({ ...p, id: p.id || undefined })), + }); + await load(); + ElMessage.success('时间表已保存,所有引用课表同步生效'); + } +} +async function cloneLayout() { + if (layout.value) { + await api.post(`/time-layouts/${layout.value.id}/clone`, {}); + await load(); + } +} +async function importLayout() { + const { value } = await ElMessageBox.prompt('输入来源时间表 ID(见选择框)', '从另一份时间表导入'); + if (layout.value) { + await api.post(`/time-layouts/${layout.value.id}/import`, { sourceId: value }); + await load(); + } +} +async function newPlan() { + if (!layout.value) { + ElMessage.warning('请先选择时间表'); + return; + } + const { value } = await ElMessageBox.prompt('课表名称', '新日课表'); + const p = await api.post('/class-plans', { + name: value, + classId: classId.value, + timeLayoutId: layout.value.id, + weekday: 1, + weekCountDiv: 0, + weekCountDivTotal: 4, + }); + await load(); + selectedPlan.value = p.id; +} +async function savePlan() { + if (plan.value) { + await api.put(`/class-plans/${plan.value.id}`, plan.value); + await saveEntries(); + } +} +async function saveEntries() { + if (plan.value) { + await api.put(`/class-plans/${plan.value.id}/entries`, { entries: plan.value.entries }); + ElMessage.success('整张日课表已保存'); + } +} +function entry(point: TimePointDto) { + if (!plan.value) return; + let e = plan.value.entries.find((e) => e.timeLayoutItemId === point.id); + if (!e) { + e = { + id: '', + timeLayoutItemId: point.id, + courseId: null, + isEnabled: true, + isChangedClass: false, + location: null, + }; + plan.value.entries.push(e); + } + return e; +} +function assign(point: TimePointDto, value: string | null) { + const e = entry(point); + if (e) e.courseId = value; +} +async function batch() { + const { value } = await ElMessageBox.prompt('输入科目名称(留空清空)', '批量排课'); + const c = courses.value.find((c) => c.name === value); + if (value && !c) return ElMessage.warning('科目不在本班目录'); + for (const p of layout.value?.points ?? []) if (selected.value.includes(p.id)) assign(p, c?.id || null); +} +function paste() { + if (!plan.value || !layout.value) return; + const points = layout.value.points.filter((p) => p.timeType === 'LESSON'); + for (const [i, e] of copied.value.entries()) { + const point = points[i]; + if (point) Object.assign(entry(point)!, { ...e, id: '', timeLayoutItemId: point.id }); + } +} +async function previousWeek() { + if (!plan.value) return; + const previous = await api.get('/class-plans', { + classId: classId.value, + week: Math.max(1, week.value - 1), + }); + const p = previous.find( + (p) => + p.weekday === plan.value!.weekday && + (p.weekCountDiv === 0 || ((week.value - 2) % p.weekCountDivTotal) + 1 === p.weekCountDiv), + ); + if (p) { + copied.value = p.entries; + paste(); + } +} +async function clonePlan() { + if (plan.value) { + const p = await api.post(`/class-plans/${plan.value.id}/clone`, {}); + await load(); + selectedPlan.value = p.id; + } +} +async function schedule(overlay = false) { + if (!plan.value) return; + const { value } = await ElMessageBox.prompt('日期 YYYY-MM-DD', overlay ? '创建临时层' : '预定课表'); + await api.post(`/schedules/${overlay ? 'overlay' : 'scheduled'}`, { + classId: classId.value, + classPlanId: plan.value.id, + date: value, + }); + await load(); +} +async function swap() { + if (!plan.value || selected.value.length !== 2) return ElMessage.warning('请勾选两节课'); + const { value } = await ElMessageBox.prompt('换课日期 YYYY-MM-DD', '临时换课(原表保持)'); + await api.post('/schedules/swap', { + classId: classId.value, + classPlanId: plan.value.id, + date: value, + first: selected.value[0], + second: selected.value[1], + }); + await load(); +} +async function exportPlan() { + if (!plan.value) return; + const value = await api.get(`/class-plans/${plan.value.id}/export`); + const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' })); + const a = document.createElement('a'); + a.href = url; + a.download = `${plan.value.name}.json`; + a.click(); + URL.revokeObjectURL(url); +} +async function importPlan(event: Event) { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + const value = JSON.parse(await file.text()); + if (value.format === 'classhelper-class-plan') { + await api.post('/class-plans/import', { + plan: { ...value.plan, classId: classId.value }, + entries: value.plan.entries, + }); + } else { + await api.post('/imports/class-plan', { classId: classId.value, payload: value, mode: 'merge' }); + } + await load(); +} +async function newGroup() { + const { value } = await ElMessageBox.prompt('群组名称', '新课表群组'); + await api.post('/class-plan-groups', { classId: classId.value, name: value }); + await load(); +} +async function saveGroup(g: (typeof groups.value)[number]) { + await api.put(`/class-plan-groups/${g.id}`, { ...g, classId: classId.value }); +} +async function deleteGroup(id: string) { + await api.delete(`/class-plan-groups/${id}`); + await load(); +} +let moving = '', + target = ''; +function dragStart(event: PointerEvent, id: string) { + if (!canWrite.value) return; + moving = id; + target = id; + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); +} +function dragMove(event: PointerEvent) { + const row = document.elementFromPoint(event.clientX, event.clientY)?.closest('[data-point]'); + if (row) target = row.dataset.point || target; +} +function dragEnd() { + if (moving && moving !== target && layout.value) { + if (tab.value === 'layouts' && canTime.value) { + const points = layout.value.points; + const from = points.findIndex((p) => p.id === moving), + to = points.findIndex((p) => p.id === target); + if (from >= 0 && to >= 0) points.splice(to, 0, points.splice(from, 1)[0]!); + } else { + const a = layout.value.points.find((p) => p.id === moving), + b = layout.value.points.find((p) => p.id === target); + if (a && b) { + const ea = entry(a), + eb = entry(b); + if (ea && eb) [ea.courseId, eb.courseId] = [eb.courseId, ea.courseId]; + void saveEntries(); + } + } + } + moving = ''; + target = ''; +} +onMounted(async () => { + classes.value = await classApi.list(); + classId.value = classes.value[0]?.id || ''; +}); + + + diff --git a/packages/web-admin/src/views/SchedulesView.vue b/packages/web-admin/src/views/SchedulesView.vue index ad6dd43..8b74336 100644 --- a/packages/web-admin/src/views/SchedulesView.vue +++ b/packages/web-admin/src/views/SchedulesView.vue @@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; import { + getMaxTermWeeks, SOCKET_EVENTS, SUBJECT_CATALOG, WEEKDAYS, @@ -439,9 +440,9 @@ onUnmounted(() => { - + 至 - + diff --git a/packages/web-admin/src/views/StudentsView.vue b/packages/web-admin/src/views/StudentsView.vue index 1cadf8a..26a0a5b 100644 --- a/packages/web-admin/src/views/StudentsView.vue +++ b/packages/web-admin/src/views/StudentsView.vue @@ -1,4 +1,5 @@ + diff --git a/packages/web-admin/src/views/TermWeeksView.vue b/packages/web-admin/src/views/TermWeeksView.vue index 30f2e6a..7411110 100644 --- a/packages/web-admin/src/views/TermWeeksView.vue +++ b/packages/web-admin/src/views/TermWeeksView.vue @@ -12,10 +12,11 @@ * * `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。 */ +import PromotionSettings from '@/components/PromotionSettings.vue'; import { computed, onMounted, ref } from 'vue'; import { ElMessage, ElMessageBox } from 'element-plus'; import { - MAX_TERM_WEEK, + getMaxTermWeeks, validateTermWeeks, isDayKey, type ClassDto, @@ -34,6 +35,7 @@ const scope = ref(''); /** 本地编辑中的逐周区间(保存前的草稿) */ const weeks = ref([]); const termStartDate = ref(''); +const maxTermWeeks = ref(getMaxTermWeeks()); const scopeLabel = computed(() => scope.value ? (classes.value.find((item) => item.id === scope.value)?.name ?? scope.value) : '全校默认', @@ -43,6 +45,7 @@ const scopeLabel = computed(() => const dirty = computed(() => { const current = term.value; if (!current) return false; + if (maxTermWeeks.value !== getMaxTermWeeks(current.maxTermWeeks)) return true; if (termStartDate.value !== current.termStartDate) return true; if (weeks.value.length !== current.weeks.length) return true; return weeks.value.some((week, index) => { @@ -64,6 +67,7 @@ async function load(): Promise { term.value = result; weeks.value = result.weeks.map((item) => ({ ...item })); termStartDate.value = result.termStartDate; + maxTermWeeks.value = getMaxTermWeeks(result.maxTermWeeks); } finally { loading.value = false; } @@ -82,9 +86,10 @@ async function autoGenerate(): Promise { saving.value = true; try { const result = await termApi.auto({ + ...(scope.value ? {} : { maxTermWeeks: maxTermWeeks.value }), classId: scope.value || null, termStartDate: termStartDate.value, - maxWeek: term.value?.maxWeek ?? MAX_TERM_WEEK, + maxWeek: term.value?.maxWeek ?? getMaxTermWeeks(), }); term.value = result; weeks.value = result.weeks.map((item) => ({ ...item })); @@ -112,6 +117,7 @@ async function save(): Promise { saving.value = true; try { const result = await termApi.save({ + ...(scope.value ? {} : { maxTermWeeks: maxTermWeeks.value }), classId: scope.value || null, termStartDate: termStartDate.value, weeks: weeks.value.map((week) => ({ @@ -170,6 +176,10 @@ onMounted(async () => {