feat(web): 增加日课表编辑与学期设备管理界面

This commit is contained in:
LoveChengke committed 2026-10-17 09:38:29 +08:00
1 parent 4dc824c283
commit 35b252249d
16 files changed
+1014 -112

No files matched your search

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { api } from '@/api/http';
type Status = {
config: { month: number; day: number; hour: number; classCount: number };
year: number;
pending: boolean;
runs: {
year: number;
status: string;
message: string;
classCount: number;
studentCount: number;
snapshotPath: string;
}[];
};
const status = ref<Status>();
async function load() {
status.value = await api.get('/term/promotion');
}
async function save() {
if (status.value) await api.put('/term/promotion', status.value.config);
await load();
}
async function catchUp() {
if (!status.value) return;
const preview = await api.post<{ classCount: number; studentCount: number }>('/term/promotion/run', {
year: status.value.year,
dryRun: true,
});
await ElMessageBox.confirm(
`将影响 ${preview.classCount} 个班、${preview.studentCount} 名学生。执行前自动保存全库快照;高三归档,其余升一级,新建高一骨架。`,
'补跑学年升级',
{ type: 'warning' },
);
await api.post('/term/promotion/run', { year: status.value.year, dryRun: false });
await load();
}
onMounted(load);
</script>
<template>
<el-card v-if="status">
<h3>每年自动学年升级</h3>
<el-form inline>
<el-form-item label="月">
<el-input-number v-model="status.config.month" :min="1" :max="12" />
</el-form-item>
<el-form-item label="日">
<el-input-number v-model="status.config.day" :min="1" :max="31" />
</el-form-item>
<el-form-item label="时">
<el-input-number v-model="status.config.hour" :min="0" :max="23" />
</el-form-item>
<el-form-item label="新高一班级数">
<el-input-number v-model="status.config.classCount" :min="1" :max="99" />
</el-form-item>
<el-button @click="save">保存升级时间</el-button>
</el-form>
<p>新学年开学日重置为 9 月 1 日,保留总周数上限;新班主任与任课关系由管理员指定。</p>
<el-alert
v-if="status.pending"
title="有尚未执行的学年升级,请预览影响后补跑"
type="warning"
:closable="false"
/>
<el-button v-if="status.pending" @click="catchUp">预览并补跑 {{ status.year }} 学年</el-button>
<el-table :data="status.runs">
<el-table-column prop="year" label="学年" />
<el-table-column prop="status" label="结果" />
<el-table-column prop="classCount" label="班级数" />
<el-table-column prop="studentCount" label="学生数" />
<el-table-column prop="message" label="执行记录" />
<el-table-column prop="snapshotPath" label="执行前快照" />
</el-table>
</el-card>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { SOCKET_EVENTS, type ReleaseProgressDto } from '@classhelper/shared';
import { api } from '@/api/http';
import { useRealtimeStore } from '@/stores/realtime';
const progress = ref<ReleaseProgressDto>();
const realtime = useRealtimeStore();
const labels = {
idle: '未开始',
downloading: '下载中',
verifying: '校验中',
ready: '完成',
failed: '失败',
waiting: '等待安装策略',
installing: '安装中',
};
const update = (p: unknown) => {
progress.value = p as ReleaseProgressDto;
};
let timer: ReturnType<typeof setInterval>;
async function load() {
progress.value = await api.get('/update/client/progress');
}
onMounted(() => {
void load();
realtime.on(SOCKET_EVENTS.clientReleaseProgress, update);
timer = setInterval(() => void load(), 3000);
});
onUnmounted(() => {
clearInterval(timer);
realtime.off(SOCKET_EVENTS.clientReleaseProgress, update);
});
</script>
<template>
<div v-if="progress" role="status">
<p>
安装包:{{ labels[progress.stage] }} {{ progress.version }} · {{ progress.downloadedBytes }} /
{{ progress.totalBytes || '未知' }} 字节
</p>
<el-progress
:percentage="progress.percent ?? 0"
:indeterminate="progress.percent === null && progress.stage === 'downloading'"
:status="progress.stage === 'failed' ? 'exception' : undefined"
/>
<p v-if="progress.error">{{ progress.error }}</p>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { api } from '@/api/http';
const students = ref<
{ id: string; studentNo: string; name: string; transferNote: string; class: { name: string } | null }[]
>([]);
async function load() {
students.value = await api.get('/archives/transferred');
}
async function restore(id: string) {
await ElMessageBox.confirm('撤回后学生恢复到原班,转出历史仍保留。', '撤回转出');
await api.post(`/archives/transferred/${id}/restore`);
await load();
}
onMounted(load);
</script>
<template>
<el-card>
<h3>已转出</h3>
<el-table :data="students">
<el-table-column prop="studentNo" label="学号" />
<el-table-column prop="name" label="姓名" />
<el-table-column prop="class.name" label="原班级" />
<el-table-column prop="transferNote" label="转出说明" />
<el-table-column label="操作">
<template #default="{ row }"><el-button link @click="restore(row.id)">撤回转出</el-button></template>
</el-table-column>
</el-table>
</el-card>
</template>
@@ -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'] },
+23
View File
@@ -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',
@@ -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;
+3 -65
View File
@@ -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<void> {
}
}
/* ------------------------------------------------------------ 执行归档 */
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<void> {
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<void> {
await ElMessageBox.confirm(
@@ -133,6 +100,7 @@ onMounted(loadYears);
<template>
<div class="page">
<TransferredStudents />
<div class="page-header">
<div>
<h2 class="page-title">毕业归档</h2>
@@ -144,7 +112,6 @@ onMounted(loadYears);
</div>
<div class="toolbar">
<el-button :icon="'Refresh'" :loading="loading" @click="loadYears">刷新</el-button>
<el-button type="primary" :icon="'FolderAdd'" @click="openCreate">归档届别</el-button>
</div>
</div>
@@ -323,35 +290,6 @@ onMounted(loadYears);
</el-drawer>
<!-- 执行归档 -->
<el-dialog v-model="createVisible" title="归档一个届别" width="520px">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 12px">
<template #title>归档不删数据</template>
<template #default>
该届的班级会从常规列表里隐去、在读学生标记为「已毕业」;
作业、通知、成绩全部原样保留,可在归档详情里查看。填错届别可以用「撤销归档」还原。
</template>
</el-alert>
<el-form label-width="100px">
<el-form-item label="届别(入学年)">
<el-select v-model="createForm.enrollmentYear" filterable allow-create style="width: 100%">
<el-option
v-for="year in summary?.suggestedEnrollmentYears ?? []"
:key="year"
:label="`${year} 级${archivedYears.has(year) ? '(已归档)' : ''}`"
:value="year"
:disabled="archivedYears.has(year)"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="createForm.note" maxlength="200" placeholder="例如:2026 年 6 月毕业(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="submitCreate">确认归档</el-button>
</template>
</el-dialog>
</div>
</template>
+8 -4
View File
@@ -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<ClassDto[]>([]);
const maxTermWeeks = ref(getMaxTermWeeks());
onMounted(async () => {
maxTermWeeks.value = getMaxTermWeeks((await termApi.get()).maxTermWeeks);
});
const keyword = ref('');
async function loadClasses(): Promise<void> {
@@ -611,9 +615,9 @@ onUnmounted(() => {
</span>
</el-form-item>
<el-form-item label="学期周数">
<el-input-number v-model="form.termWeeks" :min="1" :max="MAX_TERM_WEEK" :step="1" />
<el-input-number v-model="form.termWeeks" :min="1" :max="maxTermWeeks" :step="1" />
<span class="text-muted ml-8">
本学期一共多少教学周(默认 20,最多 {{ MAX_TERM_WEEK }} 周);课表周次选择与默认结束周都按它来
本学期一共多少教学周(默认 20,最多 {{ maxTermWeeks }} 周);课表周次选择与默认结束周都按它来
</span>
</el-form-item>
</el-form>
+18 -25
View File
@@ -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<void> {
.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(() =>
<template>
<div class="page">
<ReleaseProgress v-if="can('client:update')" />
<div class="page-header">
<div>
<h2 class="page-title">客户端设备</h2>
@@ -673,15 +675,15 @@ const deviceEmptyText = computed(() =>
<div class="client-actions">
<el-tooltip
:disabled="can('client:update-push')"
content="当前账号没有「向客户端统一下发更新」权限(client:update-push)"
:disabled="can('client:update')"
content="当前账号没有「向客户端统一下发更新」权限(client:update)"
placement="top"
>
<span>
<el-button
type="primary"
:icon="'Upload'"
:disabled="!can('client:update-push')"
:disabled="!can('client:update')"
@click="openUpdateDialog"
>
一键推送更新
@@ -698,16 +700,12 @@ const deviceEmptyText = computed(() =>
</el-tooltip>
<el-tooltip
:disabled="can('client:update-push')"
content="当前账号没有「管理安装包」权限(client:update-push)"
:disabled="can('client:update')"
content="当前账号没有「管理安装包」权限(client:update)"
placement="top"
>
<span>
<el-button
:icon="'FolderOpened'"
:disabled="!can('client:update-push')"
@click="openReleasesDrawer"
>
<el-button :icon="'FolderOpened'" :disabled="!can('client:update')" @click="openReleasesDrawer">
安装包管理
</el-button>
</span>
@@ -783,7 +781,7 @@ const deviceEmptyText = computed(() =>
<div v-if="row.pendingCommand.payload" class="client-sub text-muted">
{{ row.pendingCommand.payload }}
</div>
<div class="client-sub text-muted">等待确认(本机用户点确认后才执行)</div>
<div class="client-sub text-muted">等待设备接收并执行</div>
</template>
<span v-else class="text-muted">—</span>
</template>
@@ -810,7 +808,7 @@ const deviceEmptyText = computed(() =>
row.lastCommand.result ||
(row.lastCommand.expiresAt && Date.parse(row.lastCommand.expiresAt) < Date.now()
? '已过期(设备未回执)'
: '等待设备接收或本机确认')
: '等待设备接收并回执')
}}
</template>
<span v-else>—</span>
@@ -1010,7 +1008,7 @@ const deviceEmptyText = computed(() =>
type="warning"
:closable="false"
show-icon
title="目标机器屏幕上会弹出确认框,本机用户确认后才会执行"
title="目标机器收到后立即执行,不再等待教室确认"
>
<template #default>
命令 {{ commandExpireText }}内有效,过期自动失效(机器离线几天后开机也不会突然关机)。
@@ -1024,7 +1022,7 @@ const deviceEmptyText = computed(() =>
:loading="commandDialog.submitting"
@click="submitCommand"
>
下发并二次确认
确认下发
</el-button>
</template>
</el-dialog>
@@ -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(() =>
<el-button
link
type="primary"
:disabled="!row.fileReady || !can('client:update-push')"
:disabled="!row.fileReady || !can('client:update')"
@click="useReleaseAsTarget(row)"
>
设为更新目标
</el-button>
<el-button
link
type="danger"
:disabled="!can('client:update-push')"
@click="removeRelease(row)"
>
<el-button link type="danger" :disabled="!can('client:update')" @click="removeRelease(row)">
删除
</el-button>
</template>
@@ -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(() => {
/>
</el-select>
<!-- 按天归档:只列当前班级/优先级下真实存在的日期与条数 -->
<el-select
<el-date-picker
v-model="filter.date"
type="date"
value-format="YYYY-MM-DD"
placeholder="按天归档"
clearable
style="width: 170px"
@change="refreshList"
>
<el-option
v-for="item in days"
:key="item.date"
:label="`${item.date}(${item.count})`"
:value="item.date"
/>
</el-select>
<template #default="cell">
<span
:title="
days.some((d) => d.date === dayKeyLocal(cell.date))
? `${days.find((d) => d.date === dayKeyLocal(cell.date))?.count} 条通知`
: ''
"
>
{{ cell.text }}
<b v-if="days.some((d) => d.date === dayKeyLocal(cell.date))">·</b>
</span>
</template>
</el-date-picker>
<!-- 按发布人归档:跟着班级/优先级/日期收敛 -->
<el-select
v-model="filter.createdBy"
@@ -1,4 +1,6 @@
<script setup lang="ts">
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<void> {
const payload = {
name,
description: groupDialog.description.trim(),
scope: groupDialog.scope,
scopeValue: groupDialog.scopeValue,
// 传了就**整体替换**(服务端契约),所以这里始终带上完整勾选结果
keys: [...groupDialog.keys],
};
@@ -434,6 +444,17 @@ async function submitAssign(): Promise<void> {
placeholder="例如:教务协助、机房值班"
/>
</el-form-item>
<el-form-item label="数据范围">
<el-select v-model="groupDialog.scope">
<el-option label="关联班级" value="SELF_CLASS" />
<el-option label="年级" value="GRADE" />
<el-option label="部(多个年级)" value="DIVISION" />
<el-option label="全校" value="ALL" />
</el-select>
</el-form-item>
<el-form-item label="范围值">
<el-input v-model="groupDialog.scopeValue" placeholder="高一 或 高一,高二,高三;空年级范围不授权" />
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="groupDialog.description"
@@ -0,0 +1,671 @@
<script setup lang="ts">
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<ClassDto[]>([]),
courses = ref<CourseDto[]>([]);
const classId = ref(''),
layouts = ref<TimeLayoutDetailDto[]>([]),
plans = ref<ClassPlanDto[]>([]);
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<string[]>([]),
copied = ref<ClassPlanDto['entries']>([]);
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<ContractActiveClassPlanWeekDto>();
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<TimeLayoutDetailDto[]>('/time-layouts', { classId: scope.classId }),
api.get<ClassPlanDto[]>('/class-plans', scope),
courseApi.list(scope.classId),
api.get<typeof groups.value>('/class-plan-groups', { classId: scope.classId }),
api.get<typeof scheduled.value>('/schedules/scheduled', { classId: scope.classId }),
api.get<ContractActiveClassPlanWeekDto>('/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<TimeLayoutDetailDto>('/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<ClassPlanDto>('/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<ClassPlanDto[]>('/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<ClassPlanDto>(`/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<HTMLElement>('[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 || '';
});
</script>
<template>
<div class="page-container">
<h2>时间表与日课表编辑</h2>
<div class="toolbar">
<el-select v-model="classId" placeholder="班级">
<el-option v-for="c in classes" :key="c.id" :value="c.id" :label="c.name" />
</el-select>
<el-button :disabled="week <= 1" @click="week--">上一周</el-button>
<span>第 {{ week }} 周</span>
<el-button :disabled="week >= (classes.find((c) => c.id === classId)?.termWeeks || 60)" @click="week++">
下一周
</el-button>
</div>
<el-tabs v-model="tab">
<el-tab-pane label="日课表" name="plans">
<div class="toolbar">
<el-select v-model="selectedPlan" placeholder="选择日课表">
<el-option
v-for="p in plans"
:key="p.id"
:value="p.id"
:label="`${p.name} · 星期${p.weekday || '任意'}`"
/>
</el-select>
<el-button :disabled="!canWrite" @click="newPlan">新日课表</el-button>
<el-button :disabled="!plan || !canWrite" @click="clonePlan">克隆</el-button>
<el-button :disabled="!plan" @click="exportPlan">导出</el-button>
<label v-if="canWrite">
导入 JSON
<input type="file" accept="application/json" @change="importPlan" />
</label>
</div>
<el-card class="table-card">
<h3>星期 × 时间点</h3>
<el-table :data="layout?.points.filter((p) => p.timeType === 'LESSON') || []">
<el-table-column label="时间" width="140">
<template #default="{ row }">{{ row.startTime }}–{{ row.endTime }}</template>
</el-table-column>
<el-table-column v-for="day in 7" :key="day" :label="`星期${day}`" min-width="140">
<template #default="{ row }">
<el-select
v-if="dayPlan(day)"
:model-value="gridEntry(day, row)?.courseId"
:disabled="!canWrite"
clearable
@change="setGrid(day, row, $event)"
>
<el-option v-for="c in courses" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-button v-if="dayPlan(day)" link @click="selectedPlan = dayPlan(day)!.id">
编辑此日
</el-button>
<span v-else>未排课</span>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card v-if="plan">
<el-form :inline="!isMobile">
<el-form-item label="名称"><el-input v-model="plan.name" /></el-form-item>
<el-form-item label="星期(0 任意)">
<el-input-number v-model="plan.weekday" :min="0" :max="7" />
</el-form-item>
<el-form-item label="轮换第几周(0 不轮换)">
<el-input-number v-model="plan.weekCountDiv" :min="0" :max="9" />
</el-form-item>
<el-form-item label="共几周">
<el-input-number v-model="plan.weekCountDivTotal" :min="1" :max="9" />
</el-form-item>
<el-form-item label="时间表">
<el-select
v-model="plan.timeLayoutId"
@change="
selectedLayout = plan.timeLayoutId;
plan.entries = [];
"
>
<el-option v-for="l in layouts" :key="l.id" :label="l.name" :value="l.id" />
</el-select>
</el-form-item>
<el-form-item label="群组">
<el-select v-model="plan.associatedGroup" clearable>
<el-option v-for="g in groups" :key="g.id" :value="g.id" :label="g.name" />
</el-select>
</el-form-item>
<el-switch v-model="plan.isEnabled" active-text="默认启用" />
</el-form>
<div class="toolbar" v-if="canWrite">
<el-button @click="batch">多选批量</el-button>
<el-button @click="copied = JSON.parse(JSON.stringify(plan.entries))">复制</el-button>
<el-button @click="paste">粘贴</el-button>
<el-button @click="previousWeek">复制自前一周同一天</el-button>
<el-button @click="swap">换课</el-button>
<el-button @click="schedule(false)">预定</el-button>
<el-button @click="schedule(true)">临时层</el-button>
<el-button type="primary" @click="savePlan">保存日课表</el-button>
</div>
<div class="table-card">
<table class="editor-table">
<thead>
<tr>
<th>选择 / 拖动</th>
<th>时间点</th>
<th>科目</th>
<th>启用</th>
</tr>
</thead>
<tbody>
<tr
v-for="p in layout?.points.filter((p) => p.timeType === 'LESSON')"
:key="p.id"
:data-point="p.id"
>
<td>
<input type="checkbox" v-model="selected" :value="p.id" />
<button
class="drag"
@pointerdown="dragStart($event, p.id)"
@pointermove="dragMove"
@pointerup="dragEnd"
@pointercancel="dragEnd"
>
拖动
</button>
</td>
<td>{{ p.name }} {{ p.startTime }}–{{ p.endTime }}</td>
<td>
<el-select
:model-value="entry(p)?.courseId"
:disabled="!canWrite"
clearable
@change="assign(p, $event)"
>
<el-option v-for="c in courses" :key="c.id" :value="c.id" :label="c.name" />
</el-select>
</td>
<td>
<el-switch
v-if="entry(p)"
:model-value="entry(p)!.isEnabled"
@change="entry(p)!.isEnabled = Boolean($event)"
/>
</td>
</tr>
</tbody>
</table>
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="时间表" name="layouts">
<div class="toolbar">
<el-select v-model="selectedLayout">
<el-option v-for="l in layouts" :key="l.id" :label="`${l.name} (${l.id})`" :value="l.id" />
</el-select>
<el-button :disabled="!canTime" @click="newLayout">新建</el-button>
<el-button :disabled="!canTime || !layout" @click="cloneLayout">克隆</el-button>
<el-button :disabled="!canTime || !layout" @click="importLayout">从另一份导入</el-button>
</div>
<el-card v-if="layout" class="table-card">
<el-input v-model="layout.name" :disabled="!canTime" />
<table class="editor-table">
<thead>
<tr>
<th>排序</th>
<th>名称 / 类型</th>
<th>起止</th>
<th>默认课 / 隐藏 / 课间名</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(p, i) in layout.points" :key="p.id || i" :data-point="p.id" :data-type="p.timeType">
<td>
<button
class="drag"
@pointerdown="dragStart($event, p.id)"
@pointermove="dragMove"
@pointerup="dragEnd"
>
拖动
</button>
</td>
<td>
<el-input v-model="p.name" />
<el-select v-model="p.timeType">
<el-option v-for="t in TIME_POINT_TYPES" :key="t" :value="t" :label="types[t]" />
</el-select>
</td>
<td>
<el-time-select v-model="p.startTime" start="00:00" end="23:59" step="00:05" />
<el-time-select v-model="p.endTime" start="00:00" end="23:59" step="00:05" />
</td>
<td>
<el-select v-model="p.defaultClassId" clearable placeholder="默认科目">
<el-option v-for="c in courses" :key="c.id" :value="c.id" :label="c.name" />
</el-select>
<el-checkbox v-model="p.isHideDefault">默认隐藏</el-checkbox>
<el-input v-model="p.breakName" placeholder="课间名称" />
<el-input
v-if="p.timeType === 'ACTION'"
v-model="p.actionSet"
placeholder="行动 JSON(不执行)"
/>
</td>
<td><el-button :disabled="!canTime" @click="layout.points.splice(i, 1)">删除</el-button></td>
</tr>
</tbody>
</table>
<el-button :disabled="!canTime" @click="addPoint">添加时间点</el-button>
<el-button type="primary" :disabled="!canTime" @click="saveLayout">保存时间表</el-button>
</el-card>
</el-tab-pane>
<el-tab-pane label="群组与科目" name="groups">
<el-button :disabled="!canWrite" @click="newGroup">新群组</el-button>
<div v-for="g in groups" :key="g.id" class="toolbar">
<el-input v-model="g.name" />
<el-checkbox v-model="g.isGlobal">全局组</el-checkbox>
<el-checkbox v-model="g.isDefault">默认组</el-checkbox>
<el-button @click="saveGroup(g)">保存</el-button>
<el-button @click="deleteGroup(g.id)">删除</el-button>
</div>
<el-card>
<h3>选定群组 / 临时群组</h3>
<el-select v-model="selectedGroup" clearable placeholder="默认群组">
<el-option v-for="g in groups" :key="g.id" :label="g.name" :value="g.id" />
</el-select>
<el-button @click="selectGroup(false)">切换群组</el-button>
<el-select v-model="temporaryGroup" placeholder="临时群组">
<el-option v-for="g in groups" :key="g.id" :label="g.name" :value="g.id" />
</el-select>
<el-date-picker
v-model="expireTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="临时群组过期时间"
/>
<el-button :disabled="!temporaryGroup || !expireTime" @click="selectGroup(true)">
启用临时群组
</el-button>
<h3>预定与临时层</h3>
<el-table :data="scheduled">
<el-table-column prop="date" label="日期" />
<el-table-column prop="plan.name" label="课表" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button @click="cancelScheduled(row.id)">取消预定</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-table :data="courses">
<el-table-column prop="name" label="科目" />
<el-table-column prop="teacher.name" label="任课老师(在班级管理指定)" />
<el-table-column label="简称">
<template #default="{ row }">
<el-input
v-model="row.initial"
:disabled="!canWrite"
@change="api.patch(`/courses/${row.id}`, { initial: row.initial })"
/>
</template>
</el-table-column>
<el-table-column label="户外">
<template #default="{ row }">
<el-switch
v-model="row.isOutDoor"
:disabled="!canWrite"
@change="api.patch(`/courses/${row.id}`, { isOutDoor: row.isOutDoor })"
/>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
gap: var(--ch-space-3, 12px);
flex-wrap: wrap;
margin-block: var(--ch-space-3, 12px);
}
.editor-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
}
.editor-table td,
.editor-table th {
padding: var(--ch-space-2, 8px);
border-bottom: 1px solid var(--ch-border);
text-align: left;
}
.editor-table tr[data-type='BREAK'] {
background: var(--ch-surface-soft);
}
.editor-table tr[data-type='ACTION'] {
color: var(--ch-accent);
}
.drag {
touch-action: none;
cursor: grab;
color: var(--ch-text);
background: var(--ch-surface);
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-sm);
}
</style>
@@ -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(() => {
<el-input v-model="form.location" placeholder="例如 教学楼 A301" />
</el-form-item>
<el-form-item label="周次范围">
<el-input-number v-model="form.weekStart" :min="1" :max="30" />
<el-input-number v-model="form.weekStart" :min="1" :max="weekOptions.at(-1) || getMaxTermWeeks()" />
<span style="margin: 0 8px">至</span>
<el-input-number v-model="form.weekEnd" :min="1" :max="30" />
<el-input-number v-model="form.weekEnd" :min="1" :max="weekOptions.at(-1) || getMaxTermWeeks()" />
</el-form-item>
<el-form-item label="单双周" prop="weekParity">
<el-radio-group v-model="form.weekParity">
+30 -5
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
@@ -17,6 +18,9 @@ import {
import { callApi, classApi, studentApi } from '@/api';
import TableImportDialog from '@/components/TableImportDialog.vue';
const readOnly = computed(() => useRouteName.value === 'roster');
const useRouteName = computed(() => route.name);
const route = useRoute();
const loading = ref(false);
const students = ref<StudentDto[]>([]);
const classes = ref<ClassDto[]>([]);
@@ -24,6 +28,26 @@ const filter = reactive({ classId: '', keyword: '', status: '' as '' | StudentSt
/** 表格勾选(批量调班 / 批量转出用) */
const selection = ref<StudentDto[]>([]);
function exportRoster() {
const lines = [
['学号', '姓名', '班级', '状态'],
...students.value.map((s) => [s.studentNo, s.name, s.className || '', STUDENT_STATUS_LABELS[s.status]]),
];
const url = URL.createObjectURL(
new Blob(
[
'\ufeff' +
lines.map((r) => r.map((v) => '"' + String(v).replace(/"/g, '""') + '"').join(',')).join('\n'),
],
{ type: 'text/csv;charset=utf-8' },
),
);
const a = document.createElement('a');
a.href = url;
a.download = '学生名单.csv';
a.click();
URL.revokeObjectURL(url);
}
const classOptions = computed(() => classes.value);
async function loadClasses(): Promise<void> {
@@ -304,8 +328,9 @@ async function submitCall(): Promise<void> {
@clear="loadStudents"
/>
<el-button :icon="'Search'" @click="loadStudents">查询</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
<el-button type="warning" :icon="'Document'" @click="openImport">导入名单</el-button>
<el-button @click="exportRoster">导出名单</el-button>
<el-button v-if="!readOnly" type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
<el-button v-if="!readOnly" type="warning" :icon="'Document'" @click="openImport">导入名单</el-button>
</div>
</div>
@@ -313,7 +338,7 @@ async function submitCall(): Promise<void> {
<template #header>
<div class="card-header">
<span>共 {{ students.length }} 名学生</span>
<div class="toolbar">
<div v-if="!readOnly" class="toolbar">
<el-button
type="primary"
plain
@@ -343,7 +368,7 @@ async function submitCall(): Promise<void> {
empty-text="暂无学生数据"
@selection-change="(rows: StudentDto[]) => (selection = rows)"
>
<el-table-column type="selection" width="46" />
<el-table-column v-if="!readOnly" type="selection" width="46" />
<el-table-column prop="studentNo" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="110" />
<el-table-column label="性别" width="80">
@@ -370,7 +395,7 @@ async function submitCall(): Promise<void> {
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="330" fixed="right">
<el-table-column v-if="!readOnly" label="操作" width="330" fixed="right">
<template #default="{ row }">
<el-button link type="success" @click="openCall(row)">叫人</el-button>
<el-button link type="primary" @click="openTransfer([row])">调班</el-button>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { AutostartStatusDto } from '@classhelper/shared';
import { api } from '@/api/http';
const status = ref<AutostartStatusDto>();
const busy = ref(false);
async function load() {
status.value = await api.get('/system/autostart');
}
async function toggle(value: string | number | boolean) {
busy.value = true;
try {
status.value = await api.put('/system/autostart', { enabled: value === true });
} finally {
busy.value = false;
await load();
}
}
onMounted(load);
</script>
<template>
<div class="page-container">
<h2>系统设置</h2>
<el-card>
<h3>服务端开机自启动</h3>
<el-switch
:model-value="status?.enabled ?? false"
:disabled="!status?.supported"
:loading="busy"
@change="toggle"
/>
<p role="status">{{ status?.detail ?? '正在读取系统状态…' }}</p>
</el-card>
</div>
</template>
+12 -2
View File
@@ -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<TermWeekDto[]>([]);
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<void> {
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<void> {
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<void> {
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 () => {
<template>
<div class="page">
<PromotionSettings />
<el-form-item v-if="!scope" label="全校学期总周数">
<el-input-number v-model="maxTermWeeks" :min="1" :max="60" />
</el-form-item>
<div class="page-header">
<div>
<h2 class="page-title">学期周次</h2>