feat: 完善课表档案、课件归档、提醒与客户端更新并发布 1.4.1

- 重写课表档案工作区并合并时间表、课表与完整档案导入导出
- 修复课前/上下课提醒遮罩与通知收回冲突
- 增加课件文件夹分类、教师归档路径和客户端本机归档
- 打通客户端下载/安装/完成状态上报并在管理端展示更新过程
- 修复侧栏折叠裁切、课时多选与上传控件样式
- 统一版本到 1.4.1
This commit is contained in:
LoveChengke committed 2026-10-18 17:39:54 +08:00
1 parent 2612a912ef
commit 6347d1e5aa
59 files changed
+2251 -584

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/web-admin",
"version": "1.4.0",
"version": "1.4.1",
"private": true,
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { ClientDeviceDto } from '@classhelper/shared';
const props = defineProps<{ device: ClientDeviceDto }>();
const progress = computed(() => props.device.updateProgress);
const labels = {
idle: '等待客户端接收',
downloading: '当前客户端正在下载更新包',
verifying: '正在校验更新包',
ready: '更新包已就绪',
waiting: '已下载,等待安装时机',
installing: '当前客户端正在更新,将会短暂离线',
completed: '客户端已完成更新',
failed: '客户端更新失败',
};
const waiting = computed(() =>
props.device.updateMode === 'immediate'
? '立即安装'
: props.device.updateMode === 'after-school'
? `放学后安装${props.device.updateNotBefore ? ' · ' + new Date(props.device.updateNotBefore).toLocaleString() : ''}`
: '课间且主窗口隐藏满五分钟后安装',
);
const mb = (bytes: number) => `${(bytes / 1048576).toFixed(1)} MB`;
</script>
<template>
<div class="update-status">
<template v-if="progress">
<strong>
{{ labels[progress.stage] }}
<template v-if="progress.stage === 'completed'">,版本 {{ progress.version }}</template>
</strong>
<el-progress
v-if="['downloading', 'verifying', 'waiting'].includes(progress.stage)"
:percentage="progress.percent || 0"
:indeterminate="progress.percent === null"
/>
<small v-if="progress.totalBytes">
{{ mb(progress.downloadedBytes) }} / {{ mb(progress.totalBytes) }}
</small>
<small v-if="progress.stage === 'waiting'">{{ waiting }}</small>
<el-text v-if="progress.error" type="danger">{{ progress.error }}</el-text>
<small v-if="progress.updatedAt">上次报告:{{ new Date(progress.updatedAt).toLocaleString() }}</small>
<el-text v-if="progress.stage === 'installing' && !device.online" type="warning">
正在等待新版上线确认
</el-text>
</template>
<template v-else-if="device.updateTargetVersion">
<strong>已下发 {{ device.updateTargetVersion }},等待客户端接收</strong>
<small>{{ waiting }}</small>
</template>
<span v-else>暂无更新任务</span>
<el-text
v-if="device.updateTargetVersion && !device.autoUpdate && progress?.stage !== 'completed'"
type="warning"
>
客户端已关闭自动更新,请在客户端开启后重试
</el-text>
</div>
</template>
<style scoped>
.update-status {
display: grid;
gap: 6px;
padding-block: 6px;
overflow-wrap: anywhere;
}
small {
color: var(--el-text-color-secondary);
}
</style>
@@ -103,7 +103,23 @@ watch(
width: 0;
}
.management-menu.menu-rail :deep(.el-sub-menu__title) {
padding: 0 14px !important;
padding: 0 !important;
margin: 4px 0 !important;
width: 48px;
height: 44px;
justify-content: center;
}
.management-menu.menu-rail .management-group-title {
justify-content: center;
gap: 0;
min-width: 20px;
}
.management-menu.menu-rail .management-group-title span {
flex: 0 0 0;
overflow: hidden;
}
.management-menu.menu-rail :deep(.el-sub-menu__icon-arrow) {
visibility: hidden;
}
.management-menu.menu-rail {
overflow-x: hidden;
+1 -1
View File
@@ -5,7 +5,7 @@ export const MANAGEMENT_GROUPS = [
id: 'teaching',
title: '教学事务',
icon: 'Notebook',
paths: ['/schedules', '/plan-editor', '/homeworks', '/notifications', '/courseware', '/grades'],
paths: ['/plan-editor', '/homeworks', '/notifications', '/courseware', '/grades'],
},
{
id: 'school',
+13 -3
View File
@@ -14,13 +14,23 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/layouts/AdminLayout.vue'),
redirect: '/dashboard',
children: [
{ path: 'courseware', name: 'courseware', component: () => import('@/views/CoursewareView.vue'), meta: { title: '课件', icon: 'FolderOpened', roles: ['ADMIN', 'TEACHER'], permission: 'courseware:read' } },
{
path: 'courseware',
name: 'courseware',
component: () => import('@/views/CoursewareView.vue'),
meta: {
title: '课件',
icon: 'FolderOpened',
roles: ['ADMIN', 'TEACHER'],
permission: 'courseware:read',
},
},
{
path: 'plan-editor',
name: 'plan-editor',
component: () => import('@/views/PlanEditorView.vue'),
meta: {
title: '时间表与课表编辑',
title: '课表档案',
icon: 'Calendar',
roles: ['ADMIN', 'TEACHER'],
permission: 'schedule:read',
@@ -79,7 +89,7 @@ const routes: RouteRecordRaw[] = [
{
path: 'schedules',
name: 'schedules',
component: () => import('@/views/SchedulesView.vue'),
redirect: '/plan-editor',
meta: { title: '课表管理', icon: 'Calendar' },
},
{
+30
View File
@@ -907,6 +907,36 @@ body {
}
}
/* 原生文件选择保留键盘与读屏能力,按钮外观与管理端控件保持一致。 */
input[type='file']:not([hidden]) {
max-width: 100%;
min-width: 0;
font: inherit;
color: var(--el-text-color-regular);
}
input[type='file']::file-selector-button {
padding: 8px 16px;
margin-right: 12px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font: inherit;
cursor: pointer;
}
.toolbar {
row-gap: 12px;
}
.toolbar > .el-button + .el-button {
margin-left: 0;
}
.el-form--inline .el-form-item {
max-width: 100%;
}
.el-form-item__content {
min-width: 0;
}
/*
* 抽屉(el-drawer)。和弹窗不同,抽屉的位移由 `.el-drawer` **自身**的
* `transition: all var(--el-transition-duration)` 承载(`.el-drawer-fade-*` 那组类
+4 -19
View File
@@ -18,13 +18,13 @@
* **禁用 + tooltip 说明**而不是直接隐藏:让老师知道"有这件事、需要权限",而不是以为系统没有。
*/
import ReleaseProgress from '@/components/ReleaseProgress.vue';
import ClientUpdateStatus from '@/components/ClientUpdateStatus.vue';
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
CLIENT_COMMAND_EXPIRE_MS,
CLIENT_COMMAND_KINDS,
CLIENT_COMMAND_LABELS,
CLIENT_HEARTBEAT_INTERVAL_MS,
clientReleaseFileName,
compareVersions,
normalizeVersion,
@@ -47,7 +47,7 @@ const { isMobile } = useResponsive();
const auth = useAuthStore();
/** 自动刷新间隔:跟客户端心跳同频,刷太快没有新数据 */
const AUTO_REFRESH_MS = CLIENT_HEARTBEAT_INTERVAL_MS;
const AUTO_REFRESH_MS = 3000;
function can(key: PermissionKey): boolean {
return auth.can(key);
@@ -214,9 +214,6 @@ function classLabel(device: ClientDeviceDto): string {
}
/** 「已指定 <版本>」:只显示版本号,避免与「已下载待安装」挤在一起 */
function targetVersionLabel(device: ClientDeviceDto): string {
return normalizeVersion(device.updateTargetVersion) || device.updateTargetVersion;
}
/* -------------------------------------------------------------- 下发更新 */
@@ -814,20 +811,8 @@ const deviceEmptyText = computed(() =>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="更新" min-width="150">
<template #default="{ row }">
<template v-if="row.pendingVersion">
<el-tag class="client-tag" size="small" type="success" effect="plain">
已下载待安装 v{{ normalizeVersion(row.pendingVersion) }}
</el-tag>
</template>
<template v-if="row.updateTargetVersion">
<el-tag class="client-tag" size="small" type="primary" effect="plain">
已指定 v{{ targetVersionLabel(row) }}
</el-tag>
</template>
<span v-if="!row.pendingVersion && !row.updateTargetVersion" class="text-muted">—</span>
</template>
<el-table-column label="更新过程" min-width="290">
<template #default="{ row }"><ClientUpdateStatus :device="row" /></template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
+150 -4
View File
@@ -8,6 +8,7 @@ import {
type CoursewareDto,
type CoursewareClassDto,
type CoursewareUploadRequest,
type CoursewareFolderDto,
} from '@classhelper/shared';
import { api } from '@/api/http';
import { useAuthStore } from '@/stores/auth';
@@ -19,6 +20,7 @@ const items = ref<CoursewareDto[]>([]),
const file = ref<File | null>(null),
classIds = ref<string[]>([]),
mode = ref<'cloud' | 'push'>('cloud');
const fileInput = ref<HTMLInputElement>();
const loading = ref(false),
uploading = ref(false),
progress = ref(0),
@@ -30,11 +32,51 @@ const scopeItem = ref<CoursewareDto | null>(null),
scopeMode = ref<'visibility' | 'push'>('visibility'),
saving = ref(false);
const canWrite = computed(() => auth.can('courseware:write'));
const folders = ref<CoursewareFolderDto[]>([]),
folderPath = ref(''),
folderFilter = ref('');
const grouping = ref<'date' | 'folder'>('date');
const dateFolder = ref('');
const dateFolders = computed(() =>
[
...new Set(
items.value.map((item) => item.archivePath?.split('/').slice(0, -1).join('/') || item.archiveDate),
),
]
.sort()
.reverse(),
);
async function newFolder() {
try {
const { value } = await ElMessageBox.prompt(
'输入文件夹名称,可用 / 创建多层分类,如 地理/必修一',
'新建文件夹',
);
const path = String(value ?? '').trim();
if (!path) return;
await api.post('/courseware/folders', { path });
await refresh();
folderPath.value = path;
} catch (error) {
if (error !== 'cancel' && error !== 'close') throw error;
}
}
async function move(item: CoursewareDto, target: string) {
await api.patch(`/courseware/${item.id}/folder`, { folderPath: target });
await refresh();
}
const filtered = computed(() =>
items.value.filter(
(item) =>
(!query.value || `${item.name} ${item.teacher.name}`.includes(query.value)) &&
(!category.value || item.category === category.value) &&
(grouping.value !== 'folder' ||
!folderFilter.value ||
item.folderPath === folderFilter.value ||
item.folderPath?.startsWith(folderFilter.value + '/')) &&
(grouping.value !== 'date' ||
!dateFolder.value ||
(item.archivePath?.startsWith(dateFolder.value + '/') ?? item.archiveDate === dateFolder.value)) &&
(!date.value || item.archiveDate === date.value),
),
);
@@ -42,13 +84,25 @@ function size(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function choose(event: Event) {
file.value = (event.target as HTMLInputElement).files?.[0] ?? null;
const picked = (event.target as HTMLInputElement).files?.[0];
// 取消选择时不丢掉上一次选中的文件。
if (picked) file.value = picked;
}
function pickFile() {
if (!fileInput.value) return;
// 允许重新选择同一个文件:清空后再打开选择器。
fileInput.value.value = '';
fileInput.value.click();
}
async function refresh() {
loading.value = true;
try {
items.value = await api.get<CoursewareDto[]>('/courseware');
if (canWrite.value) classes.value = await api.get<CoursewareClassDto[]>('/courseware/classes');
if (canWrite.value)
[classes.value, folders.value] = await Promise.all([
api.get<CoursewareClassDto[]>('/courseware/classes'),
api.get<CoursewareFolderDto[]>('/courseware/folders'),
]);
} finally {
loading.value = false;
}
@@ -64,6 +118,7 @@ async function upload() {
size: file.value.size,
classIds: [...classIds.value],
mode: mode.value,
folderPath: folderPath.value,
};
uploading.value = true;
progress.value = 0;
@@ -133,7 +188,7 @@ onMounted(() => void refresh());
<header class="page-header">
<div>
<h2 class="page-title">课件</h2>
<p>保存教学文件,按日期与类型归档,发送到自己的授课班级。</p>
<p>创建文件夹分类,或按教师、上传年份、月、日、时间浏览课件。</p>
</div>
<el-button :loading="loading" @click="refresh">刷新</el-button>
</header>
@@ -141,7 +196,34 @@ onMounted(() => void refresh());
<h3>上传课件</h3>
<el-form label-position="top" :disabled="uploading">
<el-form-item label="文件(最大 512 MB)">
<input aria-label="选择课件文件" type="file" @change="choose" />
<div class="file-picker">
<el-button :disabled="uploading" @click="pickFile">
{{ file ? '重新选择文件' : '选择文件' }}
</el-button>
<span v-if="file" class="file-name">{{ file.name }} · {{ size(file.size) }}</span>
<span v-else class="file-placeholder">未选择文件</span>
<input
ref="fileInput"
aria-label="选择课件文件"
type="file"
:disabled="uploading"
class="hidden-input"
@change="choose"
/>
</div>
</el-form-item>
<el-form-item label="分类文件夹">
<div class="folder-picker">
<el-select v-model="folderPath" clearable placeholder="按上传日期自动归档">
<el-option
v-for="folder in folders"
:key="folder.id"
:value="folder.path"
:label="folder.path"
/>
</el-select>
<el-button @click="newFolder">新建文件夹</el-button>
</div>
</el-form-item>
<el-form-item label="可见范围">
<el-select
@@ -176,6 +258,16 @@ onMounted(() => void refresh());
</el-card>
<el-card shadow="never" class="table-card" v-loading="loading">
<div class="filters">
<el-radio-group v-model="grouping">
<el-radio-button value="date">日期归档</el-radio-button>
<el-radio-button value="folder">文件夹分类</el-radio-button>
</el-radio-group>
<el-select v-if="grouping === 'folder'" v-model="folderFilter" clearable placeholder="全部文件夹">
<el-option v-for="folder in folders" :key="folder.id" :value="folder.path" :label="folder.path" />
</el-select>
<el-select v-else v-model="dateFolder" filterable clearable placeholder="教师 / 年 / 月 / 日 / 时间">
<el-option v-for="folder in dateFolders" :key="folder" :value="folder" :label="folder" />
</el-select>
<el-input v-model="query" clearable placeholder="搜索文件名、教师" />
<el-select v-model="category" clearable placeholder="全部类型">
<el-option v-for="(label, key) in COURSEWARE_CATEGORIES" :key="key" :label="label" :value="key" />
@@ -184,6 +276,26 @@ onMounted(() => void refresh());
</div>
<el-table :data="filtered" empty-text="暂无课件">
<el-table-column prop="name" label="文件名称" min-width="220" show-overflow-tooltip />
<el-table-column label="分类 / 归档位置" min-width="230">
<template #default="{ row }">
<el-select
v-if="canWrite && row.teacher.id === auth.user?.id"
:model-value="row.folderPath || ''"
clearable
size="small"
placeholder="自动日期归档"
@change="move(row, $event || '')"
>
<el-option
v-for="folder in folders"
:key="folder.id"
:value="folder.path"
:label="folder.path"
/>
</el-select>
<small class="archive-path">{{ row.archivePath }}</small>
</template>
</el-table-column>
<el-table-column label="类型" width="110">
<template #default="{ row }">
{{ COURSEWARE_CATEGORIES[(row as CoursewareDto).category] }} · {{ row.extension || '无扩展名' }}
@@ -277,4 +389,38 @@ p,
.el-progress {
margin-top: 16px;
}
.folder-picker {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
}
.folder-picker .el-select {
flex: 1 1 220px;
}
.archive-path {
display: block;
overflow-wrap: anywhere;
color: var(--el-text-color-secondary);
margin-top: 6px;
}
.file-picker {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.file-name,
.file-placeholder {
min-width: 0;
overflow-wrap: anywhere;
color: var(--el-text-color-regular);
}
.file-placeholder {
color: var(--el-text-color-secondary);
}
.hidden-input {
display: none;
}
</style>
File diff suppressed because it is too large. Load diff