feat: 完善课表档案、课件归档、提醒与客户端更新并发布 1.4.1
- 重写课表档案工作区并合并时间表、课表与完整档案导入导出 - 修复课前/上下课提醒遮罩与通知收回冲突 - 增加课件文件夹分类、教师归档路径和客户端本机归档 - 打通客户端下载/安装/完成状态上报并在管理端展示更新过程 - 修复侧栏折叠裁切、课时多选与上传控件样式 - 统一版本到 1.4.1
This commit is contained in:
1 parent
2612a912ef
commit
6347d1e5aa
59 files changed
+2251
-584
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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-*` 那组类
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in new issue
Block a user