feat(courseware,docs): 完善课件传输与岛编辑并统一全仓文档

- 增加课件上传、班级可见范围、设备补收与传输进度
- 整合自定义灵动岛编辑器和相关客户端交互
- 同步三端契约、数据库迁移、部署配置与隔离回归
- 按源码统一文档与官网说明,移除过时任务书和审查记录
- 保持产品版本为 1.4.0
This commit is contained in:
LoveChengke committed 2026-10-18 11:53:37 +08:00
1 parent 536379036a
commit c7f215a09c
127 files changed
+4334 -8482

No files matched your search

+1 -1
View File
@@ -593,7 +593,7 @@ const CLIENT_RELEASE_FETCH_TIMEOUT_MS = 10 * 60 * 1000;
* 客户端安装包缓存管理(服务端 `modules/update/client-releases.service.ts`)。
*
* 教室机器常常没有外网,所以安装包由**服务端替教室下载**再缓存:
* Web 端不直连 GitHub(CSP 的 connect-src 只放行 self 与 WebSocket,见 AGENTS.md §5 第 52 条)。
* Web 端不直连 GitHub(CSP 的 connect-src 只放行 self 与 WebSocket,见 AGENTS.md 的现行工程约定)。
*
* 权限:`list` 要查看、设备管理或更新任一权限;`fetch` / `register` / `remove` 要 `client:update-push`。
*/
+1 -1
View File
@@ -5,7 +5,7 @@ export const MANAGEMENT_GROUPS = [
id: 'teaching',
title: '教学事务',
icon: 'Notebook',
paths: ['/schedules', '/plan-editor', '/homeworks', '/notifications', '/grades'],
paths: ['/schedules', '/plan-editor', '/homeworks', '/notifications', '/courseware', '/grades'],
},
{
id: 'school',
@@ -25,7 +25,7 @@ const { isMobile } = useResponsive();
* 顶栏的主题切换。整页"从点击位置扩散的圆"由 `useThemeReveal()` 提供
* (与桌面客户端共用同一份实现,见 composables/motion.ts)。
*
* 入口只放在**登录后的主布局**里:登录页是刻意固定的深色渐变底(AGENTS.md §5 第 46 条),
* 入口只放在**登录后的主布局**里:登录页是刻意固定的深色渐变底(AGENTS.md 的现行工程约定),
* 它不随主题走,在那里放一个切换按钮只会让人以为按钮坏了。
*/
const revealThemeChange = useThemeReveal();
@@ -43,7 +43,7 @@ function handleThemeToggle(event: MouseEvent): void {
* `styles/index.css` 里 `.ch-nav.is-collapsed …` 那一段(那里有完整推导)——
* 模板里没有任何 `v-if` 增删菜单项,折叠才是连续的收拢动画而不是"重排一下"。
*
* 缓动刻意与桌面客户端同一套参数(`--nav-dur` / `--nav-ease`,AGENTS.md §5 第 49 条):
* 缓动刻意与桌面客户端同一套参数(`--nav-dur` / `--nav-ease`,AGENTS.md 的现行工程约定):
* 这里不要换成 EASE_OUT 之类的"起步快"曲线 —— 两端观感要一致,且自动化会量到中间态。
*/
const collapseLabel = computed(() => (ui.sidebarCollapsed ? '展开菜单' : '收起菜单'));
@@ -523,7 +523,7 @@ const tourSteps = computed(() => [
注意 `.header-right` 必须是 flex 容器:Element Plus 给 `.el-button` 的是
`vertical-align: middle`、给 `.el-dropdown` 的是 `vertical-align: top`,
放在普通 block 里两个 inline-level 子元素会按各自的规则落位、错开几像素
(AGENTS.md §5 第 58 条,客户端当初就是这么翻的车)。
(AGENTS.md 的现行工程约定,客户端当初就是这么翻的车)。
-->
<el-tooltip :content="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" placement="bottom">
<el-button
+1
View File
@@ -14,6 +14,7 @@ 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: 'plan-editor',
name: 'plan-editor',
+1 -1
View File
@@ -36,7 +36,7 @@ export const useAuthStore = defineStore('auth', () => {
/**
* 当前账号的**生效权限**(`GET /api/permissions/me`)。
*
* 只用来决定"入口显不显示",**不是**另一套权限规则(AGENTS.md §5 第 3 条)——
* 只用来决定"入口显不显示",**不是**另一套权限规则(AGENTS.md 的现行工程约定)——
* 真正的拦截始终在服务端。页面刷新后这里是空的,由 `AdminLayout` 挂载时调
* {@link ensurePermissions} 补上。
*/
+2 -2
View File
@@ -73,7 +73,7 @@
* 只覆盖**语义色**,不动圆角/间距/动效令牌 —— 两套主题的几何必须完全一致,
* 否则切换主题会看到布局跳一下。
*
* 刻意**不随主题**的区域(见 AGENTS.md §5 第 46 条):登录页的深色渐变底、
* 刻意**不随主题**的区域(见 AGENTS.md 的现行工程约定):登录页的深色渐变底、
* 侧栏(本来就是深色)、以及深色底上的白色文字。
*/
html.dark {
@@ -155,7 +155,7 @@ html.care-mode .care-emphasis {
}
/* ============================================================================
* 侧栏导航:折叠态是"图标导轨"(AGENTS.md §5 第 49 条)
* 侧栏导航:折叠态是"图标导轨"(AGENTS.md 的现行工程约定)
*
* 折叠**不用** el-menu 的 `:collapse` 属性:那个 prop 会把每个分组的子项卸载进浮层
* (一次结构切换)并让整条菜单重排一遍 —— 既做不出连续形变,也是"点汉堡卡一下"的根因。
+1 -1
View File
@@ -1159,7 +1159,7 @@ const deviceEmptyText = computed(() =>
</template>
<style scoped>
/* 顶部横幅 / 弹窗内提示:圆角与底都走设计令牌,不写死数值(AGENTS.md §5 第 11 条) */
/* 顶部横幅 / 弹窗内提示:圆角与底都走设计令牌,不写死数值(AGENTS.md 的现行工程约定) */
.client-banner {
margin-bottom: 12px;
border-radius: var(--ch-radius-sm);
@@ -0,0 +1,280 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
COURSEWARE_CATEGORIES,
COURSEWARE_MAX_BYTES,
COURSEWARE_TRANSFER_TIMEOUT_MS,
type CoursewareDto,
type CoursewareClassDto,
type CoursewareUploadRequest,
} from '@classhelper/shared';
import { api } from '@/api/http';
import { useAuthStore } from '@/stores/auth';
import { useResponsive } from '@/composables/useResponsive';
const auth = useAuthStore();
const { isMobile } = useResponsive();
const items = ref<CoursewareDto[]>([]),
classes = ref<CoursewareClassDto[]>([]);
const file = ref<File | null>(null),
classIds = ref<string[]>([]),
mode = ref<'cloud' | 'push'>('cloud');
const loading = ref(false),
uploading = ref(false),
progress = ref(0),
query = ref(''),
category = ref(''),
date = ref('');
const scopeItem = ref<CoursewareDto | null>(null),
scopeIds = ref<string[]>([]),
scopeMode = ref<'visibility' | 'push'>('visibility'),
saving = ref(false);
const canWrite = computed(() => auth.can('courseware:write'));
const filtered = computed(() =>
items.value.filter(
(item) =>
(!query.value || `${item.name} ${item.teacher.name}`.includes(query.value)) &&
(!category.value || item.category === category.value) &&
(!date.value || item.archiveDate === date.value),
),
);
function size(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function choose(event: Event) {
file.value = (event.target as HTMLInputElement).files?.[0] ?? null;
}
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');
} finally {
loading.value = false;
}
}
async function upload() {
if (!file.value) return void ElMessage.warning('请选择课件文件');
if (!file.value.size || file.value.size > COURSEWARE_MAX_BYTES)
return void ElMessage.warning('请选择非空且不超过 512 MB 的文件');
if (mode.value === 'push' && !classIds.value.length)
return void ElMessage.warning('请选择需要推送的授课班级');
const metadata: CoursewareUploadRequest = {
name: file.value.name,
size: file.value.size,
classIds: [...classIds.value],
mode: mode.value,
};
uploading.value = true;
progress.value = 0;
try {
await api.post<CoursewareDto>('/courseware/upload', file.value, {
params: { metadata: JSON.stringify(metadata) },
headers: { 'Content-Type': 'application/octet-stream' },
timeout: COURSEWARE_TRANSFER_TIMEOUT_MS,
onUploadProgress: (event) => {
progress.value = event.total ? Math.min(99, Math.round((event.loaded / event.total) * 100)) : 0;
},
});
progress.value = 100;
ElMessage.success(mode.value === 'push' ? '已归档并发送,离线班级上线后自动接收' : '课件已保存到云端');
await refresh();
} finally {
uploading.value = false;
}
}
function edit(item: CoursewareDto, operation: 'visibility' | 'push') {
scopeItem.value = item;
scopeMode.value = operation;
const allowed = new Set(classes.value.map((c) => c.id));
scopeIds.value = item.classes.filter((c) => allowed.has(c.id)).map((c) => c.id);
}
async function saveScope() {
if (!scopeItem.value) return;
if (scopeMode.value === 'push' && !scopeIds.value.length) return void ElMessage.warning('请选择班级');
saving.value = true;
try {
if (scopeMode.value === 'push')
await api.post(`/courseware/${scopeItem.value.id}/push`, { classIds: scopeIds.value });
else await api.patch(`/courseware/${scopeItem.value.id}/visibility`, { classIds: scopeIds.value });
scopeItem.value = null;
ElMessage.success('已保存');
await refresh();
} finally {
saving.value = false;
}
}
async function download(item: CoursewareDto) {
const blob = await api.download(`/courseware/${item.id}/download`);
const url = URL.createObjectURL(blob),
link = document.createElement('a');
link.href = url;
link.download = item.name;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
async function remove(item: CoursewareDto) {
try {
await ElMessageBox.confirm(`删除「${item.name}」后,各班将无法继续下载。`, '删除课件', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
await api.delete(`/courseware/${item.id}`);
await refresh();
}
onMounted(() => void refresh());
</script>
<template>
<div class="page courseware-page">
<header class="page-header">
<div>
<h2 class="page-title">课件</h2>
<p>保存教学文件,按日期与类型归档,发送到自己的授课班级。</p>
</div>
<el-button :loading="loading" @click="refresh">刷新</el-button>
</header>
<el-card v-if="canWrite" shadow="never">
<h3>上传课件</h3>
<el-form label-position="top" :disabled="uploading">
<el-form-item label="文件(最大 512 MB)">
<input aria-label="选择课件文件" type="file" @change="choose" />
</el-form-item>
<el-form-item label="可见范围">
<el-select
v-model="classIds"
multiple
clearable
placeholder="仅自己可见;也可选择授课班级"
style="width: 100%"
>
<el-option v-for="c in classes" :key="c.id" :value="c.id" :label="c.name" />
</el-select>
</el-form-item>
<el-form-item label="上传方式">
<el-radio-group v-model="mode">
<el-radio value="cloud">仅保存云端</el-radio>
<el-radio value="push">保存后推送所选班级</el-radio>
</el-radio-group>
</el-form-item>
<p class="hint">
未选择班级时仅自己可见。仅保存云端的课件由可见班级自主下载;推送会在班级端自动下载,先存云端再发送。
</p>
<el-button type="primary" :loading="uploading" :disabled="!file" @click="upload">
{{ mode === 'push' ? '上传并推送' : '上传云端' }}
</el-button>
<el-progress
v-if="uploading || progress === 100"
:percentage="progress"
:status="progress === 100 ? 'success' : undefined"
/>
<p v-if="uploading && progress === 99" class="hint">正在等待服务端校验与归档…</p>
</el-form>
</el-card>
<el-card shadow="never" class="table-card" v-loading="loading">
<div class="filters">
<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" />
</el-select>
<el-date-picker v-model="date" value-format="YYYY-MM-DD" placeholder="归档日期" />
</div>
<el-table :data="filtered" empty-text="暂无课件">
<el-table-column prop="name" label="文件名称" min-width="220" show-overflow-tooltip />
<el-table-column label="类型" width="110">
<template #default="{ row }">
{{ COURSEWARE_CATEGORIES[(row as CoursewareDto).category] }} · {{ row.extension || '无扩展名' }}
</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ size(row.size) }}</template>
</el-table-column>
<el-table-column prop="archiveDate" label="归档日期" width="120" />
<el-table-column prop="teacher.name" label="上传教师" width="110" />
<el-table-column label="可见范围" min-width="160">
<template #default="{ row }">
{{ (row as CoursewareDto).classes.map((c) => c.name).join('、') || '仅自己' }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="260">
<template #default="{ row }">
<el-button link type="primary" @click="download(row)">下载</el-button>
<template v-if="canWrite && row.teacher.id === auth.user?.id">
<el-button link type="primary" @click="edit(row, 'visibility')">可见范围</el-button>
<el-button link type="primary" @click="edit(row, 'push')">推送</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
:model-value="!!scopeItem"
:title="scopeMode === 'push' ? '推送课件' : '设置可见范围'"
:width="isMobile ? '94%' : '500px'"
@close="scopeItem = null"
>
<p>{{ scopeItem?.name }}</p>
<el-select v-model="scopeIds" multiple clearable style="width: 100%" placeholder="请选择自己的授课班级">
<el-option
v-for="c in classes.filter(
(c) => scopeMode !== 'push' || scopeItem?.classes.some((v) => v.id === c.id),
)"
:key="c.id"
:label="c.name"
:value="c.id"
/>
</el-select>
<p class="hint">
{{
scopeMode === 'push'
? '仅向可见范围中的所选班级发送;离线时等待上线。'
: '留空表示仅自己可见;移除班级会取消该班尚未完成的传输。已下载的本地文件需由班级自行删除。'
}}
</p>
<template #footer>
<el-button @click="scopeItem = null">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveScope">
{{ scopeMode === 'push' ? '发送课件' : '保存' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.courseware-page {
display: grid;
gap: 20px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2,
h3 {
margin-top: 0;
}
p,
.hint {
color: var(--el-text-color-secondary);
line-height: 1.6;
}
.filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.filters > * {
flex: 1;
min-width: 160px;
}
.el-progress {
margin-top: 16px;
}
</style>
+1 -1
View File
@@ -84,7 +84,7 @@ async function submit(): Promise<void> {
/*
* 登录卡片**永远按浅色渲染**。
*
* 页面底色是刻意固定的深色渐变(AGENTS.md §5 第 46 条),卡片按设计就是"深底白卡"。
* 页面底色是刻意固定的深色渐变(AGENTS.md 的现行工程约定),卡片按设计就是"深底白卡"。
* 但卡片的内部颜色原本会随 `html.dark` 变:Element Plus 的深色 css-vars 与 `--ch-*`
* 是挂在 `<html>` 上的,照常继承进白卡片 —— 于是深色主题下出现"白卡片 + 深色输入框 +
* 浅灰字"的黑白混搭(用户反馈的「登录页出现黑夜白天模式混合显示」)。
@@ -291,7 +291,7 @@ interface PublishFailure {
* 服务端逐班做权限断言与上课保护,并把每个班的结果回给我们。
*
* 为什么不再由前端对每个班各发一次请求:N 个班就是 N 次往返,权限/上课保护
* 分散在 N 个响应里,前端还得自己合并;服务端批量端点把这些收敛成一份逐班结果(见 AGENTS.md §5)。
* 分散在 N 个响应里,前端还得自己合并;服务端批量端点把这些收敛成一份逐班结果(见 AGENTS.md 的现行工程约定)。
* confirmDuringClass 为 true 表示教师已在上课警告里确认。
*/
const doPublishSaving = ref(false);
@@ -8,7 +8,7 @@ import type { PermissionScope as ContractPermissionScope } from '@classhelper/sh
* 1. **权限组**:新建 / 编辑 / 删除权限组(一组权限键),内置组只允许改、禁止删;
* 2. **成员**:把管理员 / 教师账号分配到权限组(不选任何组 = 回到角色默认模板)。
*
* 权限规则本身**不在这里实现**(AGENTS.md §5 第 3 条):本页只是把服务端
* 权限规则本身**不在这里实现**(AGENTS.md 的现行工程约定):本页只是把服务端
* `GET /permissions/catalog` 给的目录、以及 `lib/permissions.ts` 的生效规则
* 如实展示出来,真正的判定始终在服务端 —— 页面拿到的 `fromGroups` / `permissionCount`
* 都是服务端算好的结果。