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
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
"version": "1.3.3",
"version": "1.4.0",
"private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
+1 -1
View File
@@ -136,7 +136,7 @@ function normalizeIntegrityLabel(dir) {
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
console.log('[dist] 已把交付物完整性标签修正为 Medium:', dir);
} catch (error) {
console.warn('[dist] 完整性标签修正失败(交付物可能装不上,见 AGENTS.md §7 第 7 条):', error.message);
console.warn('[dist] 完整性标签修正失败(交付物可能装不上,见 AGENTS.md 的现行工程约定):', error.message);
}
// 目录自身恢复为"继承父目录的 Low",保证下次构建时带 Low 标签的 7za 仍能写进这个目录。
// 宿主目录树本来就没有 Low 标签体系时,这一步会被系统拒绝(保持继承即可),属正常,不提示。
@@ -1,260 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
ISLAND_COMPONENT_LABELS,
type IslandComponentKind,
type IslandComponentNode,
} from '@classhelper/shared';
const props = defineProps<{ node: IslandComponentNode }>();
const node = computed(() => props.node);
const emit = defineEmits<{
change: [];
remove: [];
duplicate: [];
wrap: [kind: IslandComponentKind];
}>();
const tab = ref('content');
watch(
() => props.node.id,
() => (tab.value = 'content'),
);
const fonts = {
secondaryFontSize: '辅助文字',
bodyFontSize: '正文',
emphasizedFontSize: '强调文字',
largeFontSize: '大号文字',
} as const;
</script>
<template>
<div class="inspector-heading">
<span class="eyebrow">组件设置</span>
<h2>{{ ISLAND_COMPONENT_LABELS[node.kind] }}</h2>
<p>只影响当前选中的组件</p>
</div>
<nav class="panel-tabs" aria-label="组件设置分类">
<button
v-for="item in [
['content', '内容'],
['appearance', '外观'],
['advanced', '高级'],
]"
:key="item[0]"
:class="{ active: tab === item[0] }"
@click="tab = item[0]!"
>
{{ item[1] }}
</button>
</nav>
<div class="inspector-body" @change="emit('change')">
<template v-if="tab === 'content'">
<section class="setting-section">
<h3>显示内容</h3>
<template v-if="node.kind === 'clock'">
<label class="toggle-field">
<span>12 小时制</span>
<input type="checkbox" v-model="node.settings.hour12" />
</label>
<label class="toggle-field">
<span>显示秒</span>
<input type="checkbox" v-model="node.settings.seconds" />
</label>
</template>
<label v-else-if="node.kind === 'countdown'" class="field">
倒计时目标
<select v-model="node.settings.target">
<option value="auto">自动:课中距下课,课间距上课</option>
<option value="next">下一节课</option>
<option value="end">本节下课</option>
<option value="school">放学</option>
</select>
</label>
<template v-else-if="node.kind === 'text'">
<label class="field">
文字内容
<textarea rows="4" v-model="node.settings.text" />
</label>
<label class="toggle-field">
<span>使用模板</span>
<input type="checkbox" v-model="node.settings.template" />
</label>
<p class="field-hint">可用变量:{date} 日期、{time} 时间、{course} 当前课程。</p>
</template>
<template v-else-if="node.kind === 'schedule'">
<label class="field">
显示方式
<select v-model="node.settings.display">
<option value="current">当前课程</option>
<option value="next">下一节课</option>
<option value="today">今日课表</option>
</select>
</label>
<label v-if="node.settings.display === 'today'" class="field inline-field">
显示节数
<input type="number" min="1" max="30" v-model.number="node.settings.count" />
</label>
<label class="toggle-field">
<span>显示课程时间</span>
<input type="checkbox" v-model="node.settings.showPeriod" />
</label>
<label class="toggle-field">
<span>显示上课进度条</span>
<input type="checkbox" v-model="node.settings.showProgress" />
</label>
</template>
<template v-else-if="['group', 'stack', 'slide', 'rolling'].includes(node.kind)">
<label class="field">
排列方向
<select v-model="node.settings.direction">
<option value="horizontal">横向</option>
<option value="vertical">纵向</option>
</select>
</label>
<label class="field inline-field">
组件间距
<input type="number" min="0" max="80" v-model.number="node.settings.gap" />
</label>
<label v-if="node.kind === 'slide'" class="field inline-field">
切换间隔(秒)
<input type="number" min="1" max="300" v-model.number="node.settings.interval" />
</label>
<label v-if="node.kind === 'rolling'" class="field inline-field">
滚动速度
<input type="number" min="1" max="200" v-model.number="node.settings.speed" />
</label>
<p class="field-hint">将组件拖到此容器内,即可一起排列。</p>
</template>
<p v-else class="field-hint">
{{
['notification', 'homework', 'call'].includes(node.kind)
? '收到消息后在岛内显示,沿用原有的展开、已读和关闭操作。没有消息时不占空间。'
: '此组件无需额外内容设置,可在「外观」中调整显示。'
}}
</p>
</section>
<section class="setting-section">
<h3>组件操作</h3>
<button class="wide-button" @click="emit('duplicate')">创建副本</button>
<label class="field">
包裹到新容器
<select
data-action="wrap"
:value="''"
@change="emit('wrap', ($event.target as HTMLSelectElement).value as IslandComponentKind)"
>
<option value="" disabled>选择容器类型</option>
<option v-for="kind in ['slide', 'rolling', 'group', 'stack'] as const" :key="kind" :value="kind">
{{ ISLAND_COMPONENT_LABELS[kind] }}
</option>
</select>
</label>
<button class="wide-button danger-button" @click="emit('remove')">删除组件</button>
</section>
</template>
<template v-else-if="tab === 'appearance'">
<section class="setting-section">
<label class="toggle-field">
<span>独立外观</span>
<input type="checkbox" v-model="node.appearance.resourceOverridingEnabled" />
</label>
<p class="field-hint">关闭时使用灵动岛的整体外观。</p>
</section>
<fieldset :disabled="!node.appearance.resourceOverridingEnabled">
<section class="setting-section">
<h3>字体大小</h3>
<label v-for="(label, key) in fonts" :key="key" class="field inline-field">
{{ label }}
<input type="number" min="8" max="96" v-model.number="node.appearance[key]" />
</label>
</section>
<section class="setting-section">
<h3>颜色与形状</h3>
<label class="toggle-field">
<span>自定义文字颜色</span>
<input type="checkbox" v-model="node.appearance.customForegroundEnabled" />
</label>
<label v-if="node.appearance.customForegroundEnabled" class="field inline-field">
文字颜色
<input type="color" v-model="node.appearance.foreground" />
</label>
<label class="toggle-field">
<span>自定义背景颜色</span>
<input type="checkbox" v-model="node.appearance.customBackgroundEnabled" />
</label>
<label v-if="node.appearance.customBackgroundEnabled" class="field inline-field">
背景颜色
<input type="color" v-model="node.appearance.background" />
</label>
<label class="toggle-field">
<span>自定义背景透明度</span>
<input type="checkbox" v-model="node.appearance.customBackgroundOpacityEnabled" />
</label>
<label v-if="node.appearance.customBackgroundOpacityEnabled" class="field inline-field">
背景透明度
<input
type="number"
min="0"
max="1"
step="0.05"
v-model.number="node.appearance.backgroundOpacity"
/>
</label>
<label class="toggle-field">
<span>自定义圆角</span>
<input type="checkbox" v-model="node.appearance.customCornerRadiusEnabled" />
</label>
<label v-if="node.appearance.customCornerRadiusEnabled" class="field inline-field">
圆角
<input type="number" min="0" max="100" v-model.number="node.appearance.cornerRadius" />
</label>
</section>
</fieldset>
<section class="setting-section">
<label class="field inline-field">
组件透明度
<input type="number" min="0" max="1" step="0.05" v-model.number="node.appearance.opacity" />
</label>
</section>
</template>
<template v-else>
<section class="setting-section">
<h3>尺寸与对齐</h3>
<label class="field">
对齐方式
<select v-model="node.advanced.align">
<option value="stretch">拉伸</option>
<option value="start">起始</option>
<option value="center">居中</option>
<option value="end">末端</option>
</select>
</label>
<p class="field-hint">父容器设有宽度时生效。</p>
<label class="field inline-field">
固定宽度
<input type="number" min="0" max="2000" v-model.number="node.advanced.width" />
</label>
<label class="field inline-field">
最大宽度
<input type="number" min="0" max="2000" v-model.number="node.advanced.maxWidth" />
</label>
<p class="field-hint">单位为像素,0 表示自适应;超过最大宽度的内容从右侧截断。</p>
</section>
<section class="setting-section">
<h3>隐藏条件</h3>
<p class="field-hint">任一条件满足时隐藏,且不占用空间。</p>
<label
v-for="(label, rule) in {
'in-class': '上课时',
'no-message': '没有消息时',
'no-class': '没有当前课程时',
}"
:key="rule"
class="toggle-field"
>
<span>{{ label }}</span>
<input type="checkbox" v-model="node.advanced.hideRules" :value="rule" />
</label>
</section>
</template>
</div>
</template>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import {
COURSEWARE_CATEGORIES,
COURSEWARE_ISLAND_SIZE,
ISLAND_SHADOW_PAD,
type CoursewareTransfer,
type IslandAppearance,
} from '@classhelper/shared';
const props = defineProps<{ transfer: CoursewareTransfer; appearance: IslandAppearance }>();
const card = ref<HTMLElement>();
const style = computed(() => {
const position = props.appearance.position;
return {
width: `${COURSEWARE_ISLAND_SIZE.width}px`,
height: `${COURSEWARE_ISLAND_SIZE.height}px`,
[position.startsWith('bottom') ? 'bottom' : 'top']: `${ISLAND_SHADOW_PAD}px`,
...(position.endsWith('left')
? { left: `${ISLAND_SHADOW_PAD}px` }
: position.endsWith('right')
? { right: `${ISLAND_SHADOW_PAD}px` }
: { left: '50%', transform: 'translateX(-50%)' }),
'--transfer-accent': props.appearance.accent,
};
});
const status = computed(
() =>
({
downloading: '正在接收课件',
verifying: '正在校验文件',
completed: '课件接收完成',
failed: '课件接收失败',
})[props.transfer.stage],
);
function report() {
const rect = card.value?.getBoundingClientRect();
if (rect) window.island?.setHitRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
}
watch(
() => [props.appearance.position, props.transfer],
() => void nextTick(report),
);
onMounted(() => {
report();
window.addEventListener('resize', report);
});
onUnmounted(() => window.removeEventListener('resize', report));
</script>
<template>
<section ref="card" class="courseware-transfer" :style="style" aria-label="课件传输状态">
<header>
<span>{{ status }}</span>
<strong>{{ transfer.stage === 'failed' ? '失败' : `${transfer.progress}%` }}</strong>
</header>
<div class="filename" :title="transfer.name">{{ transfer.name }}</div>
<div class="metadata">
{{ COURSEWARE_CATEGORIES[transfer.category] }} · {{ transfer.teacherName || '教师' }}
<span>
{{ (transfer.receivedBytes / 1048576).toFixed(1) }} /
{{ (transfer.totalBytes / 1048576).toFixed(1) }} MB
</span>
</div>
<progress :value="transfer.progress" max="100" :aria-label="`${transfer.name} 传输进度`" />
<p v-if="transfer.error">{{ transfer.error }}</p>
</section>
</template>
<style scoped>
.courseware-transfer {
position: fixed;
box-sizing: border-box;
padding: 17px 24px;
border-radius: 24px;
color: white;
background: rgba(20, 20, 24, 0.97);
box-shadow: 0 2px 8px #0004;
font:
13px/1.4 'Segoe UI',
'Microsoft YaHei',
sans-serif;
overflow: hidden;
pointer-events: none;
}
header,
.metadata {
display: flex;
justify-content: space-between;
gap: 10px;
}
header {
color: #ffffffb8;
font-size: 12px;
}
header strong {
color: var(--transfer-accent);
}
.filename {
font-weight: 600;
font-size: 16px;
margin: 5px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.metadata {
color: #ffffffb8;
font-size: 12px;
}
progress {
display: block;
width: 100%;
height: 5px;
margin-top: 11px;
border: 0;
border-radius: 8px;
overflow: hidden;
accent-color: var(--transfer-accent);
}
progress::-webkit-progress-bar {
background: #ffffff30;
}
progress::-webkit-progress-value {
background: var(--transfer-accent);
}
p {
margin: 3px 0;
font-size: 11px;
color: #ffb4ab;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -1,815 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from 'vue';
import {
ISLAND_COMPONENT_LABELS,
CLASSISLAND_ISLAND_METRICS as CI,
ISLAND_SHADOW_PAD,
islandNode,
normalizeIslandLayouts,
type IslandLayoutSettings,
type IslandAppearance,
type IslandComponentKind,
type IslandComponentNode,
type IslandState,
type ScheduleDto,
} from '@classhelper/shared';
import ComponentNode from './ComponentNode.vue';
import ComponentInspector from './ComponentInspector.vue';
import ComponentIcon from './ComponentIcon.vue';
const props = defineProps<{
settings: IslandLayoutSettings;
state: IslandState;
editing: boolean;
appearance: IslandAppearance;
schedules: ScheduleDto[];
embedded?: boolean;
}>();
const emit = defineEmits<{
save: [settings: IslandLayoutSettings];
done: [];
host: [element: HTMLElement | null];
}>();
function hostRef(element: unknown) {
emit('host', element instanceof HTMLElement ? element : null);
}
const draft = ref(normalizeIslandLayouts(props.settings));
watch(
() => props.settings,
(s) => {
draft.value = normalizeIslandLayouts(s);
},
{ deep: true },
);
const scheme = computed(() => draft.value.schemes.find((s) => s.id === draft.value.activeSchemeId)!);
const editingSurface = ref<'compact' | 'expanded'>('compact');
const pendingCount = computed(
() => new Set([props.state.active?.id, ...props.state.queued.map((item) => item.id)].filter(Boolean)).size,
);
const residentCompact = computed(
() => draft.value.residentEnabled && !props.editing && props.state.mode !== 'expanded',
);
const idleExpanded = computed(
() =>
draft.value.residentEnabled &&
!props.editing &&
props.state.mode === 'expanded' &&
pendingCount.value === 0,
);
const rows = computed(() =>
(props.editing ? editingSurface.value === 'expanded' : idleExpanded.value)
? scheme.value.expandedRows
: scheme.value.rows,
);
function setSurface(surface: 'compact' | 'expanded') {
editingSurface.value = surface;
selectedId.value = '';
selectedRowId.value = '';
panel.value = 'appearance';
}
function openResident(event: Event) {
if (!residentCompact.value || (event.target as HTMLElement).closest('button, a, input, select, textarea'))
return;
window.island?.sendAction('expand');
}
function residentKey(event: KeyboardEvent) {
if (event.target !== event.currentTarget) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openResident(event);
}
}
function expandResident() {
window.island?.sendAction('expand');
}
function collapseResident() {
window.island?.sendAction('collapse');
}
function onPointerDown(event: PointerEvent) {
if (event.pointerType === 'touch' || event.pointerType === 'pen') window.island?.noteResidentTouch();
}
const selectedId = ref(''),
selectedKind = ref<IslandComponentKind>('clock');
const panel = ref<'component' | 'row' | 'appearance'>('appearance');
const selectedRowId = ref('');
const activeRow = computed(() => rows.value.find((r) => r.id === selectedRowId.value) || rows.value[0]!);
const componentGroups = [
{ name: '日常信息', kinds: ['clock', 'date', 'schedule', 'countdown', 'text', 'separator'] },
{ name: '消息提醒', kinds: ['notification', 'homework', 'call'] },
{ name: '组合容器', kinds: ['group', 'stack', 'slide', 'rolling'] },
] as const;
const savedAt = ref('');
function selectNode(nodeId: string) {
selectedId.value = nodeId;
panel.value = 'component';
}
function selectRow(rowId: string) {
selectedRowId.value = rowId;
selectedId.value = '';
panel.value = 'row';
}
function switchScheme() {
selectedId.value = '';
selectedRowId.value = '';
panel.value = 'appearance';
save();
}
const now = ref(Date.now()),
canvasScale = ref(1),
pan = ref({ x: 0, y: 0 });
const canvas = ref<HTMLElement>();
const previewStage = ref<HTMLElement>();
const autoScale = ref(1);
const previewScale = computed(() => canvasScale.value * autoScale.value);
const shell = ref<HTMLElement>(),
inner = ref<HTMLElement>();
const viewport = ref({ width: 0, height: 0 });
function allNodes(nodes: IslandComponentNode[]): IslandComponentNode[] {
return nodes.flatMap((n) => [n, ...allNodes(n.children)]);
}
const selected = computed(() =>
rows.value.flatMap((r) => allNodes(r.nodes)).find((n) => n.id === selectedId.value),
);
const alertRow = computed(
() =>
rows.value.find((r) => r.main && r.allowNotifications) || rows.value.find((r) => r.allowNotifications),
);
const alertNodeId = computed(() => {
if (residentCompact.value || idleExpanded.value) return '';
const kind = props.state.active?.kind ?? 'notification';
return (
(alertRow.value &&
allNodes(alertRow.value.nodes).find((n) => n.kind === kind || n.kind === 'notification')?.id) ||
''
);
});
const hasMessage = computed(
() =>
props.state.mode !== 'hidden' &&
Boolean(props.state.active) &&
(!draft.value.residentEnabled || props.state.mode === 'expanded') &&
!props.editing,
);
const shellStyle = computed<CSSProperties>(() => ({
width: viewport.value.width ? `${viewport.value.width}px` : 'max-content',
minHeight: !props.editing && viewport.value.height ? `${viewport.value.height}px` : undefined,
maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`,
maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`,
color: '#ffffffeb',
fontSize: `${(props.appearance.fontSize / 13) * CI.bodyFontSize}px`,
'--custom-color': '#20202080',
'--custom-radius': `${scheme.value.radius}px`,
'--custom-row-gap': `${scheme.value.spacing}px`,
...(draft.value.residentEnabled
? {
'--secondary': `${CI.secondaryFontSize}px`,
'--emphasized': `${CI.emphasizedFontSize}px`,
'--large': `${CI.largeFontSize}px`,
'--resident-height': `${CI.rowHeight}px`,
'--resident-padding': `${CI.horizontalPadding * scheme.value.scale}px`,
}
: {}),
gap: `${scheme.value.spacing}px`,
}));
function id() {
return `node-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function save() {
const value = normalizeIslandLayouts(JSON.parse(JSON.stringify(draft.value)));
draft.value = value;
emit('save', value);
savedAt.value = '已自动保存';
}
function addRow(after: number) {
rows.value.splice(after + 1, 0, {
id: id(),
name: '新主界面行',
main: false,
allowNotifications: true,
gap: 8,
nodes: [],
});
selectRow(rows.value[after + 1]!.id);
save();
}
function add(kind = selectedKind.value, row = activeRow.value) {
if (!row) return;
const n = islandNode(kind, id());
row.nodes.push(n);
selectNode(n.id);
save();
}
function findList(nodeId: string, nodes?: IslandComponentNode[]): IslandComponentNode[] | null {
for (const list of nodes ? [nodes] : rows.value.map((r) => r.nodes)) {
if (list.some((n) => n.id === nodeId)) return list;
for (const n of list) {
const found = findList(nodeId, n.children);
if (found) return found;
}
}
return null;
}
function remove() {
const list = findList(selectedId.value);
if (list)
list.splice(
list.findIndex((n) => n.id === selectedId.value),
1,
);
selectedId.value = '';
panel.value = 'row';
save();
}
function duplicate() {
const n = selected.value,
list = findList(selectedId.value);
if (!n || !list) return;
const copy = JSON.parse(JSON.stringify(n)) as IslandComponentNode;
for (const child of allNodes([copy])) child.id = id();
list.splice(list.indexOf(n) + 1, 0, copy);
selectNode(copy.id);
save();
}
function wrap(kind: IslandComponentKind) {
if (!['slide', 'rolling', 'group', 'stack'].includes(kind)) return;
const n = selected.value,
list = findList(selectedId.value);
if (!n || !list) return;
const container = islandNode(kind, id());
container.children = [n];
list.splice(list.indexOf(n), 1, container);
selectNode(container.id);
save();
}
function newScheme() {
const name = `新方案 ${draft.value.schemes.length + 1}`;
const copy = JSON.parse(JSON.stringify(scheme.value));
copy.id = id();
copy.name = name;
for (const r of [...copy.rows, ...copy.expandedRows]) {
r.id = id();
for (const n of allNodes(r.nodes)) n.id = id();
}
draft.value.schemes.push(copy);
draft.value.activeSchemeId = copy.id;
switchScheme();
}
function exportScheme() {
const url = URL.createObjectURL(
new Blob([JSON.stringify({ format: 'classhelper-island-layout', scheme: scheme.value }, null, 2)], {
type: 'application/json',
}),
);
const a = document.createElement('a');
a.href = url;
a.download = `${scheme.value.name}.json`;
a.click();
URL.revokeObjectURL(url);
}
const error = ref('');
async function importScheme(event: Event) {
try {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file || file.size > 1_000_000) throw new Error('请选择小于 1MB 的 JSON');
const parsed = JSON.parse(await file.text());
if (parsed.format !== 'classhelper-island-layout') throw new Error('方案格式不正确');
const value = normalizeIslandLayouts({
layout: 'custom',
activeSchemeId: parsed.scheme.id,
schemes: [parsed.scheme],
});
const s = value.schemes[0]!;
s.id = id();
draft.value.schemes.push(s);
draft.value.activeSchemeId = s.id;
switchScheme();
error.value = '';
} catch (e) {
error.value = String(e);
}
}
let drag: {
type: 'node' | 'library' | 'row';
id: string;
kind?: IslandComponentKind;
rowId?: string;
targetId?: string;
} | null = null;
function startDrag(
event: PointerEvent,
type: 'node' | 'library' | 'row',
nodeId: string,
kind?: IslandComponentKind,
) {
event.preventDefault();
event.stopPropagation();
try {
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
} catch {
/* 合成事件无活动指针;真实触摸使用捕获 */
}
drag = { type, id: nodeId, kind };
}
function move(event: PointerEvent) {
if (!drag) return;
const element = document.elementFromPoint(event.clientX, event.clientY);
drag.rowId = element?.closest<HTMLElement>('[data-row]')?.dataset.row;
drag.targetId = element?.closest<HTMLElement>('[data-node]')?.dataset.node;
}
function drop() {
if (!drag) return;
const moving = drag;
drag = null;
const row = rows.value.find((r) => r.id === moving.rowId);
if (!row) return;
if (moving.type === 'library') {
add(moving.kind, row);
return;
}
if (moving.type === 'row') {
const from = rows.value.findIndex((r) => r.id === moving.id),
to = rows.value.indexOf(row);
if (from >= 0) rows.value.splice(to, 0, rows.value.splice(from, 1)[0]!);
save();
return;
}
const list = findList(moving.id),
node = list?.find((n) => n.id === moving.id);
if (!list || !node || allNodes([node]).some((n) => n.id === moving.targetId)) return;
const target = allNodes(row.nodes).find((n) => n.id === moving.targetId);
list.splice(list.indexOf(node), 1);
if (target && ['group', 'stack', 'slide', 'rolling'].includes(target.kind)) target.children.push(node);
else if (target) {
const targetList = findList(target.id)!;
targetList.splice(targetList.indexOf(target), 0, node);
} else row.nodes.push(node);
selectNode(node.id);
save();
}
let panning: { x: number; y: number; originX: number; originY: number } | null = null;
function panStart(e: PointerEvent) {
if (e.target !== e.currentTarget) return;
panning = { x: e.clientX, y: e.clientY, originX: pan.value.x, originY: pan.value.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function panMove(e: PointerEvent) {
if (panning)
pan.value = { x: panning.originX + e.clientX - panning.x, y: panning.originY + e.clientY - panning.y };
move(e);
}
let stopViewport: (() => void) | undefined;
let observer: ResizeObserver | undefined,
measureTimer: ReturnType<typeof setTimeout> | undefined,
clock: ReturnType<typeof setInterval>;
function measure() {
clearTimeout(measureTimer);
measureTimer = setTimeout(() => {
if (props.embedded && shell.value && canvas.value) {
const padding = getComputedStyle(canvas.value);
const availableWidth =
canvas.value.clientWidth - parseFloat(padding.paddingLeft) - parseFloat(padding.paddingRight);
const availableHeight =
canvas.value.clientHeight - parseFloat(padding.paddingTop) - parseFloat(padding.paddingBottom);
autoScale.value = Math.min(
1,
Math.max(80, availableWidth) /
Math.max(1, previewStage.value?.offsetWidth ?? shell.value.offsetWidth),
Math.max(100, availableHeight) /
Math.max(1, previewStage.value?.offsetHeight ?? shell.value.offsetHeight),
);
return;
}
if (inner.value) {
// 内部组件的自然尺寸决定整个岛体;提醒不再拥有另一层岛壳。
const rect = inner.value.getBoundingClientRect(),
padding = shell.value ? getComputedStyle(shell.value) : null,
scale = props.editing ? canvasScale.value : 1;
window.island?.reportSize({
width: Math.ceil(
rect.width / scale +
parseFloat(padding?.paddingLeft || '0') +
parseFloat(padding?.paddingRight || '0'),
),
height: Math.ceil(
rect.height / scale +
parseFloat(padding?.paddingTop || '0') +
parseFloat(padding?.paddingBottom || '0'),
),
});
const control = shell.value?.querySelector('.resident-bell')?.getBoundingClientRect();
window.island?.reportResidentControl(
control ? { x: control.x, y: control.y, width: control.width, height: control.height } : null,
);
}
}, 120);
}
watch([() => props.editing, scheme, () => props.state], () => void nextTick(measure), { deep: true });
onMounted(() => {
clock = setInterval(() => (now.value = Date.now()), 1000);
observer = new ResizeObserver(measure);
if (inner.value) observer.observe(inner.value);
if (canvas.value) observer.observe(canvas.value);
if (previewStage.value) observer.observe(previewStage.value);
stopViewport = window.island?.onViewport((v) => (viewport.value = v));
measure();
});
onUnmounted(() => {
clearInterval(clock);
clearTimeout(measureTimer);
stopViewport?.();
observer?.disconnect();
});
</script>
<template>
<div
:class="editing ? 'layout-editor' : 'custom-window'"
:data-embedded="embedded || undefined"
@pointerdown.capture="onPointerDown"
@pointermove="panMove"
@pointerup="
drop();
panning = null;
"
@pointercancel="
drag = null;
panning = null;
"
>
<header v-if="editing" class="editor-header">
<div class="editor-title">
<span class="editor-mark" aria-hidden="true">◒</span>
<div>
<h1>自定义灵动岛</h1>
<p>把需要的信息,放进岛里</p>
</div>
</div>
<div class="scheme-picker">
<span>当前方案</span>
<select aria-label="当前方案" v-model="draft.activeSchemeId" @change="switchScheme">
<option v-for="s in draft.schemes" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
<details class="scheme-menu">
<summary aria-label="方案操作">•••</summary>
<div class="scheme-menu-body">
<button @click="newScheme">复制为新方案</button>
<button @click="exportScheme">导出方案</button>
<label class="import-button">
导入方案
<input type="file" accept="application/json" @change="importScheme" />
</label>
</div>
</details>
</div>
<span class="save-state" role="status">{{ savedAt || '更改会自动保存' }}</span>
<button
class="primary-button done-button"
@click="
save();
emit('done');
"
>
完成
</button>
</header>
<aside v-if="editing" class="library">
<div class="panel-heading">
<h2>添加组件</h2>
<p>拖入岛内,或选中后添加</p>
</div>
<section v-for="group in componentGroups" :key="group.name" class="library-group">
<h3>{{ group.name }}</h3>
<div class="component-grid">
<button
v-for="kind in group.kinds"
:key="kind"
class="library-item"
:class="{ active: selectedKind === kind }"
:aria-pressed="selectedKind === kind"
@pointerdown="startDrag($event, 'library', '', kind)"
@click="selectedKind = kind"
@dblclick="add(kind)"
>
<span class="component-symbol"><ComponentIcon :kind="kind" /></span>
<span>{{ ISLAND_COMPONENT_LABELS[kind] }}</span>
</button>
</div>
</section>
<div class="library-add">
<label class="field">
添加到
<select :value="activeRow.id" @change="selectedRowId = ($event.target as HTMLSelectElement).value">
<option v-for="row in rows" :key="row.id" :value="row.id">{{ row.name }}</option>
</select>
</label>
<button class="primary-button wide-button" @click="add()">
添加{{ ISLAND_COMPONENT_LABELS[selectedKind] }}
</button>
</div>
</aside>
<main ref="canvas" :class="{ canvas: editing }" @pointerdown="panStart">
<div v-if="editing" class="canvas-toolbar">
<nav class="surface-tabs" aria-label="编辑自定义灵动岛内容">
<button :class="{ active: editingSurface === 'compact' }" @click="setSurface('compact')">
常态内容
</button>
<button :class="{ active: editingSurface === 'expanded' }" @click="setSurface('expanded')">
无通知展开时
</button>
</nav>
<div>
<button
:class="{ active: panel === 'appearance' }"
@click="
panel = 'appearance';
selectedId = '';
"
>
整体外观
</button>
<button
@click="
pan = { x: 0, y: 0 };
canvasScale = 1;
"
>
重置视图
</button>
</div>
</div>
<div
ref="previewStage"
class="preview-stage"
:style="editing ? { transform: `translate(${pan.x}px,${pan.y}px) scale(${previewScale})` } : {}"
>
<div
ref="shell"
class="custom-card"
:class="{ separated: scheme.separated, 'has-alert': hasMessage, 'resident-size': residentCompact }"
:role="residentCompact ? 'button' : undefined"
:tabindex="residentCompact ? 0 : undefined"
:aria-label="
residentCompact ? (pendingCount ? `查看 ${pendingCount} 条通知` : '展开常驻岛') : undefined
"
:aria-expanded="draft.residentEnabled && !editing ? state.mode === 'expanded' : undefined"
@click="openResident"
@keydown="residentKey"
:data-priority="hasMessage ? state.active?.priority : undefined"
:style="[shellStyle, editing ? { width: 'max-content', maxWidth: 'none', maxHeight: 'none' } : {}]"
>
<div
ref="inner"
class="custom-inner"
:class="{ 'resident-compact': residentCompact }"
:style="{ zoom: scheme.scale }"
>
<header v-if="idleExpanded" class="resident-panel-heading">
<span>常驻岛</span>
<button aria-label="收起常驻岛" @click.stop="collapseResident">收起 ↑</button>
</header>
<div class="layout-rows">
<section
v-for="row in rows"
:key="row.id"
:data-row="row.id"
class="layout-row"
:class="{ 'row-selected': editing && selectedRowId === row.id, 'row-editing': editing }"
:style="{ '--row-gap': `${row.gap}px` }"
>
<div v-if="editing" class="row-tools">
<button
class="row-drag"
aria-label="拖动行"
@pointerdown="startDrag($event, 'row', row.id)"
>
⠿
</button>
<button class="row-name" @click="selectRow(row.id)">
{{ row.name }}
<span v-if="row.main">主要行</span>
</button>
<button class="row-settings" :aria-label="`${row.name}设置`" @click="selectRow(row.id)">
行设置
</button>
</div>
<div class="row-content" @click.self="editing && selectRow(row.id)">
<div class="row-components">
<ComponentNode
v-for="n in row.nodes.filter(
(n) => !['notification', 'homework', 'call'].includes(n.kind),
)"
:key="n.id"
:node="n"
:state="state"
:now="now"
:schedules="schedules"
:editing="editing"
:selected-id="selectedId"
:alert-node-id="alertNodeId"
@select="selectNode"
@drag="(e, id) => startDrag(e, 'node', id)"
>
<template #alerts>
<div id="custom-alert-host" :ref="hostRef" v-show="hasMessage" />
</template>
</ComponentNode>
</div>
<ComponentNode
v-for="n in row.nodes.filter((n) =>
['notification', 'homework', 'call'].includes(n.kind),
)"
:key="n.id"
:node="n"
:state="state"
:now="now"
:schedules="schedules"
:editing="editing"
:selected-id="selectedId"
:alert-node-id="alertNodeId"
@select="selectNode"
@drag="(e, id) => startDrag(e, 'node', id)"
>
<template #alerts>
<div id="custom-alert-host" :ref="hostRef" v-show="hasMessage" />
</template>
</ComponentNode>
<div
v-if="row.id === alertRow?.id && !alertNodeId"
id="custom-alert-host"
:ref="hostRef"
v-show="hasMessage"
/>
<span v-if="editing && !row.nodes.length" class="empty-row">拖入组件,或从左侧添加</span>
</div>
</section>
</div>
<button
v-if="residentCompact"
class="resident-bell"
:class="{ 'bell-empty': !pendingCount }"
:aria-label="pendingCount ? `查看 ${pendingCount} 条通知` : '展开常驻岛'"
@click.stop="expandResident"
>
<ComponentIcon v-if="pendingCount" kind="notification" />
<svg
v-else
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="1.6"
aria-hidden="true"
>
<path d="m8 10 4 4 4-4" />
</svg>
<span v-if="pendingCount">{{ pendingCount > 99 ? '99+' : pendingCount }}</span>
</button>
</div>
</div>
<button
v-if="editing"
class="add-row-button"
@click="addRow(rows.findIndex((r) => r.id === activeRow.id))"
>
+ 在下方新建主界面行
</button>
</div>
<div v-if="editing" class="canvas-footer">
<span>点选组件以设置 · 拖动句柄以排序 · 空白处可平移</span>
<label>
缩放
<input
aria-label="画布缩放"
type="range"
v-model.number="canvasScale"
min="0.3"
max="2"
step="0.1"
/>
<output>{{ Math.round(canvasScale * 100) }}%</output>
</label>
</div>
</main>
<div v-if="!alertRow" id="custom-alert-host" :ref="hostRef" style="display: none" />
<aside v-if="editing" class="settings">
<ComponentInspector
v-if="panel === 'component' && selected"
:node="selected"
@change="save"
@remove="remove"
@duplicate="duplicate"
@wrap="wrap"
/>
<template v-else-if="panel === 'row'">
<div class="inspector-heading">
<span class="eyebrow">布局设置</span>
<h2>组件行</h2>
<p>管理这一行的排列与提醒</p>
</div>
<div class="inspector-body" @change="save">
<section class="setting-section">
<h3>基本设置</h3>
<label class="field">
行名称
<input v-model="activeRow.name" />
</label>
<label class="field inline-field">
组件间距
<input type="number" min="0" max="80" v-model.number="activeRow.gap" />
</label>
</section>
<section class="setting-section">
<h3>消息提醒</h3>
<label class="toggle-field">
<span>允许显示提醒</span>
<input type="checkbox" v-model="activeRow.allowNotifications" />
</label>
<button
class="wide-button"
:disabled="activeRow.main"
@click="
rows.forEach((r) => (r.main = r.id === activeRow.id));
save();
"
>
{{ activeRow.main ? '已设为主要行' : '设为主要行' }}
</button>
<p class="field-hint">提醒优先显示在主要行内;若该行禁用提醒,则显示在下一条允许提醒的行内。</p>
</section>
<section class="setting-section">
<button class="wide-button" @click="addRow(rows.indexOf(activeRow))">在此行下方新增行</button>
<button
class="wide-button danger-button"
:disabled="rows.length < 2"
@click="
rows.splice(rows.indexOf(activeRow), 1);
selectedRowId = '';
save();
"
>
删除这一行
</button>
</section>
</div>
</template>
<template v-else>
<div class="inspector-heading">
<span class="eyebrow">方案设置</span>
<h2>整体外观</h2>
<p>
{{
draft.residentEnabled
? editingSurface === 'compact'
? '正在编辑收起时的常驻内容'
: '正在编辑没有通知时的展开内容'
: '所有组件共用一个灵动岛'
}}
</p>
</div>
<div class="inspector-body" @change="save">
<section class="setting-section">
<p class="field-hint">
通知、外观与弹出动画沿用经典灵动岛。在应用的灵动岛设置中调整颜色、字号、圆角和动画速度。
</p>
</section>
<section class="setting-section">
<h3>当前方案</h3>
<label class="field">
方案名称
<input v-model="scheme.name" />
</label>
<label class="field inline-field">
界面缩放
<input
type="number"
v-model.number="scheme.scale"
:min="draft.residentEnabled ? CI.scaleMin : 0.5"
:max="draft.residentEnabled ? CI.scaleMax : 3"
step="0.05"
/>
</label>
</section>
<section class="setting-section">
<h3>多行布局</h3>
<label class="field inline-field">
行间距
<input
type="number"
v-model.number="scheme.spacing"
min="0"
:max="draft.residentEnabled ? CI.rowSpacingMax : 100"
step="5"
/>
</label>
<label class="toggle-field">
<span>各行分离显示</span>
<input type="checkbox" v-model="scheme.separated" />
</label>
<p class="field-hint">默认将所有行放在同一个岛体内;开启后各行拥有独立背景。</p>
</section>
</div>
</template>
<p v-if="error" class="editor-error" role="alert">{{ error }}</p>
</aside>
</div>
</template>
<style src="./custom-island.css"></style>
@@ -8,6 +8,7 @@ import {
type ScheduleDto,
} from '@classhelper/shared';
import FixedIsland from './IslandApp.vue';
import CoursewareTransfer from './CoursewareTransfer.vue';
import './custom-island.css';
import CustomIslandRuntime from './CustomIslandRuntime.vue';
const settings = ref(defaultIslandLayouts()),
@@ -50,12 +51,13 @@ onMounted(async () => {
</script>
<template>
<CustomIslandRuntime
v-if="resident()"
v-if="resident() && !state.coursewareTransfer"
:settings="settings"
:state="state"
:schedules="schedules"
:appearance="appearance"
:native-visible="nativeVisible"
/>
<FixedIsland v-if="ready" v-show="!resident()" :suspended="resident()" />
<FixedIsland v-if="ready" v-show="!resident() && !state.coursewareTransfer" :suspended="resident() || !!state.coursewareTransfer" />
<CoursewareTransfer v-if="state.coursewareTransfer" :transfer="state.coursewareTransfer" :appearance="appearance" />
</template>
@@ -730,7 +730,7 @@ body {
*
* **必须关在 `@media (hover: hover) and (pointer: fine)` 里**:教室机器上的希沃触摸屏
* 点一下会产生"幽灵 hover"并**一直粘着**,直到点别处才消失 —— 不关的话,点过「知道了」
* 的按钮会永远保持高亮(同 AGENTS.md §5 第 30 条那类问题)。
* 的按钮会永远保持高亮(同 AGENTS.md 的现行工程约定那类问题)。
* 这是 beUI `useHoverCapable` 的 CSS 版:纯样式效果用媒体查询就够,不必挂 composable。
*/
@media (hover: hover) and (pointer: fine) {
@@ -203,535 +203,3 @@
.custom-window .preview-stage {
width: max-content;
}
.layout-editor {
--editor-bg: #f6f7f9;
--editor-border: #e6e9ef;
--editor-text: #202735;
--editor-muted: #7b8494;
--editor-accent: #3c69de;
position: fixed;
inset: 10px;
display: grid;
grid-template-columns: 232px minmax(0, 1fr) 300px;
grid-template-rows: 76px minmax(0, 1fr);
color: var(--editor-text);
background: #fff;
border: 1px solid #dce1e9;
border-radius: 18px;
overflow: hidden;
font:
13px 'Segoe UI Variable Display',
'Microsoft YaHei',
sans-serif;
box-shadow: 0 12px 40px #15203c22;
}
.layout-editor h1,
.layout-editor h2,
.layout-editor h3,
.layout-editor p {
margin: 0;
}
.layout-editor button,
.layout-editor select,
.layout-editor input,
.layout-editor textarea {
font: inherit;
}
.layout-editor button,
.layout-editor select,
.layout-editor input:not([type='checkbox']):not([type='range']),
.layout-editor textarea {
min-height: 36px;
border: 1px solid var(--editor-border);
border-radius: 8px;
padding: 7px 10px;
color: var(--editor-text);
background: #fff;
max-width: 100%;
}
.layout-editor button {
cursor: pointer;
touch-action: none;
transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.layout-editor button:hover {
background: #f0f3f9;
}
.layout-editor button:disabled {
cursor: default;
opacity: 0.45;
}
.layout-editor button:focus-visible,
.layout-editor input:focus-visible,
.layout-editor select:focus-visible,
.layout-editor textarea:focus-visible,
.layout-editor summary:focus-visible {
outline: 2px solid var(--editor-accent);
outline-offset: 2px;
}
.layout-editor input[type='checkbox'],
.layout-editor input[type='range'] {
accent-color: var(--editor-accent);
}
.layout-editor input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
flex-shrink: 0;
}
.layout-editor input[type='color'] {
width: 48px;
padding: 4px !important;
cursor: pointer;
}
.layout-editor .editor-header {
grid-column: 1/-1;
display: flex;
align-items: center;
gap: 24px;
padding: 0 24px;
border-bottom: 1px solid var(--editor-border);
z-index: 3;
}
.layout-editor .editor-title {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.layout-editor .editor-mark {
font-size: 30px;
color: #222b3b;
}
.layout-editor h1 {
font-size: 17px;
font-weight: 650;
letter-spacing: 0.5px;
}
.layout-editor .editor-title p {
font-size: 11px;
color: var(--editor-muted);
margin-top: 3px;
}
.layout-editor .scheme-picker {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.layout-editor .scheme-picker > span {
color: var(--editor-muted);
font-size: 12px;
}
.layout-editor .scheme-picker select {
width: 170px;
background: var(--editor-bg);
}
.layout-editor .scheme-menu {
position: relative;
}
.layout-editor .scheme-menu summary {
list-style: none;
cursor: pointer;
padding: 10px;
border-radius: 8px;
}
.layout-editor .scheme-menu-body {
position: absolute;
right: 0;
top: 40px;
width: 170px;
padding: 6px;
background: white;
border: 1px solid var(--editor-border);
border-radius: 12px;
box-shadow: 0 8px 28px #1c2d4c1a;
}
.layout-editor .scheme-menu-body button,
.layout-editor .import-button {
width: 100%;
display: block;
border: 0;
text-align: left;
padding: 10px;
cursor: pointer;
border-radius: 6px;
}
.layout-editor .import-button:hover {
background: var(--editor-bg);
}
.layout-editor .import-button input {
display: none;
}
.layout-editor .save-state {
color: var(--editor-muted);
font-size: 11px;
}
.layout-editor .primary-button {
color: #fff;
background: var(--editor-accent);
border-color: var(--editor-accent);
}
.layout-editor .primary-button:hover {
background: #305ac8;
}
.layout-editor .done-button {
min-width: 80px;
}
.layout-editor .library,
.layout-editor .settings {
overflow: auto;
background: #fff;
}
.layout-editor .library {
border-right: 1px solid var(--editor-border);
padding: 22px 16px 16px;
}
.layout-editor .panel-heading {
padding: 0 4px 20px;
}
.layout-editor h2 {
font-size: 17px;
font-weight: 650;
}
.layout-editor .panel-heading p,
.layout-editor .inspector-heading p {
font-size: 12px;
color: var(--editor-muted);
margin-top: 7px;
line-height: 1.6;
}
.layout-editor .library-group {
margin-bottom: 22px;
}
.layout-editor .library-group h3 {
color: var(--editor-muted);
font-size: 11px;
font-weight: 500;
margin: 0 4px 10px;
}
.layout-editor .component-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.layout-editor .library-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 4px;
font-size: 12px;
border-color: #eff1f5;
}
.layout-editor .component-symbol {
font-size: 22px;
height: 24px;
display: flex;
align-items: center;
color: #65728a;
}
.layout-editor .library-item.active {
background: #f0f4ff;
border-color: #97b2f4;
color: var(--editor-accent);
}
.layout-editor .library-item.active .component-symbol {
color: inherit;
}
.layout-editor .library-add {
position: sticky;
bottom: -16px;
padding: 16px 0;
background: white;
border-top: 1px solid var(--editor-border);
}
.layout-editor .canvas {
position: relative;
overflow: hidden;
touch-action: none;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 160px 36px 100px;
background:
radial-gradient(#d9dfe9 0.8px, transparent 0.8px) 0 0 / 20px 20px,
#f2f4f8;
}
.layout-editor .canvas-toolbar {
position: absolute;
top: 22px;
left: 24px;
right: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.layout-editor .canvas-toolbar > span {
color: var(--editor-muted);
font-size: 12px;
}
.layout-editor .canvas-toolbar > div {
display: flex;
gap: 6px;
pointer-events: auto;
}
.layout-editor .canvas-toolbar button {
font-size: 12px;
box-shadow: 0 2px 4px #18305a05;
}
.layout-editor .canvas-toolbar button.active {
color: var(--editor-accent);
border-color: #b6c8f0;
}
.layout-editor .preview-stage {
transform-origin: center top;
flex-shrink: 0;
max-width: none;
}
.layout-editor .custom-card {
min-width: 280px;
overflow: visible;
}
.layout-editor .row-editing {
position: relative;
padding: 34px 4px 8px;
border: 1px dashed #ffffff36;
border-radius: 12px;
}
.layout-editor .row-editing.row-selected {
border-color: #8baeff;
}
.layout-editor .row-tools {
position: absolute;
top: 0;
left: 2px;
right: 2px;
display: flex;
align-items: center;
gap: 3px;
height: 28px;
}
.layout-editor .row-tools button {
border: 0;
min-height: 24px;
padding: 3px 6px;
background: transparent;
color: #ffffffab;
font-size: 11px;
}
.layout-editor .row-tools button:hover {
background: #ffffff12;
color: white;
}
.layout-editor .row-name {
display: flex;
align-items: center;
gap: 6px;
}
.layout-editor .row-name span {
font-size: 9px;
padding: 1px 5px;
color: #a9c3ff;
border-radius: 4px;
background: #83a8ff22;
}
.layout-editor .row-tools .row-settings {
margin-left: auto;
}
.layout-editor .row-content {
min-width: 260px;
min-height: 52px;
}
.layout-editor .empty-row {
color: #ffffff60;
padding: 10px 18px;
font-size: 12px;
}
.layout-editor .add-row-button {
display: block;
width: 100%;
margin-top: 16px;
color: #7d899d;
background: #ffffff70;
border: 1px dashed #c7d0e0;
font-size: 12px;
}
.layout-editor .canvas-footer {
position: absolute;
bottom: 18px;
left: 24px;
right: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--editor-muted);
font-size: 11px;
pointer-events: none;
}
.layout-editor .canvas-footer label {
display: flex;
align-items: center;
gap: 8px;
pointer-events: auto;
}
.layout-editor .canvas-footer input {
width: 90px;
}
.layout-editor .canvas-footer output {
width: 34px;
}
.layout-editor .settings {
border-left: 1px solid var(--editor-border);
}
.layout-editor .inspector-heading {
padding: 24px 24px 20px;
}
.layout-editor .eyebrow {
display: block;
color: var(--editor-muted);
font-size: 10px;
margin-bottom: 8px;
}
.layout-editor .panel-tabs {
display: flex;
gap: 6px;
padding: 0 24px 16px;
border-bottom: 1px solid var(--editor-border);
}
.layout-editor .panel-tabs button {
flex: 1;
border: 0;
min-height: 32px;
font-size: 12px;
background: var(--editor-bg);
color: #788295;
}
.layout-editor .panel-tabs button.active {
color: var(--editor-accent);
background: #edf2ff;
}
.layout-editor .inspector-body {
padding: 0 24px 24px;
}
.layout-editor .setting-section {
padding: 20px 0;
border-bottom: 1px solid var(--editor-border);
}
.layout-editor .setting-section:last-child {
border-bottom: 0;
}
.layout-editor .setting-section h3 {
font-size: 12px;
font-weight: 650;
margin-bottom: 16px;
}
.layout-editor .field {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
margin-bottom: 16px;
}
.layout-editor .field:last-child {
margin-bottom: 0;
}
.layout-editor .field > input:not([type='color']),
.layout-editor .field > textarea,
.layout-editor .field > select {
width: 100%;
}
.layout-editor .inline-field {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.layout-editor .inline-field input[type='number'] {
width: 80px;
}
.layout-editor .toggle-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 38px;
font-size: 12px;
}
.layout-editor .field-hint {
font-size: 11px;
color: var(--editor-muted);
line-height: 1.8;
margin: 8px 0 12px;
}
.layout-editor .range-field {
display: flex;
align-items: center;
gap: 12px;
}
.layout-editor .range-field input {
flex: 1;
min-width: 0;
}
.layout-editor .range-field output {
width: 36px;
color: var(--editor-muted);
}
.layout-editor .wide-button {
width: 100%;
margin-bottom: 12px;
}
.layout-editor .wide-button:last-child {
margin-bottom: 0;
}
.layout-editor .danger-button {
color: #cc4a52;
background: #fff;
}
.layout-editor .danger-button:hover {
background: #fff3f4;
}
.layout-editor fieldset {
padding: 0;
margin: 0;
border: 0;
}
.layout-editor fieldset:disabled {
opacity: 0.45;
}
.layout-editor .editor-error {
margin: 12px 24px;
padding: 10px;
background: #fff1f1;
color: #bc3f4b;
border-radius: 8px;
line-height: 1.7;
}
@media (max-width: 1100px) {
.layout-editor {
grid-template-columns: 188px minmax(0, 1fr) 260px;
}
.layout-editor .editor-header {
gap: 12px;
padding: 0 18px;
}
.layout-editor .scheme-picker > span,
.layout-editor .save-state,
.layout-editor .canvas-footer > span {
display: none;
}
.layout-editor .inspector-heading,
.layout-editor .inspector-body {
padding-left: 18px;
padding-right: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.layout-editor button {
transition: none;
}
}
@@ -316,6 +316,8 @@ function formatSize(bytes: number): string {
* 每次心跳后与主窗口隐藏后都会调用。
*/
export async function maybeInstallPendingUpdate(): Promise<void> {
const courseware = island.getCoursewareTransfer();
if (courseware && ['downloading', 'verifying'].includes(courseware.stage)) return;
if (installing) return;
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
@@ -1,3 +1,4 @@
import { pollCourseware } from './courseware.js';
import { spawn } from 'node:child_process';
import os from 'node:os';
import fs from 'node:fs';
@@ -206,6 +207,7 @@ async function beat(): Promise<void> {
if (!envelope.success || !envelope.data?.deviceId) throw new Error('心跳响应格式不正确');
if (epoch !== connectionEpoch) return;
const payload = envelope.data;
void pollCourseware();
state.lastHeartbeatAt = new Date().toISOString();
state.lastError = '';
state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? '';
@@ -0,0 +1,240 @@
import type { ReadableStream } from 'node:stream/web';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { app, BrowserWindow, shell } from 'electron';
import {
COURSEWARE_MAX_BYTES,
COURSEWARE_TRANSFER_TIMEOUT_MS,
type ApiResponse,
type CoursewareDto,
type CoursewarePushDto,
type CoursewareTransfer,
} from '@classhelper/shared';
import { getConfig } from './config.js';
import { connectionEvents } from './config-events.js';
import { island } from './island.js';
import { logger } from './logger.js';
let transfer: CoursewareTransfer | null = null,
epoch = 0;
let abort: AbortController | null = null,
polling = false;
let queue: Promise<unknown> = Promise.resolve();
const jobs = new Map<string, Promise<void>>();
let clearTimer: ReturnType<typeof setTimeout> | undefined;
export function getCoursewareTransfer() {
return transfer;
}
function publish(value: CoursewareTransfer | null) {
transfer = value;
island.setCoursewareTransfer(value);
for (const win of BrowserWindow.getAllWindows())
if (!win.isDestroyed()) win.webContents.send('courseware:progress', value);
}
connectionEvents.on('changed', () => {
epoch++;
abort?.abort();
clearTimeout(clearTimer);
publish(null);
});
function checkId(id: string) {
if (typeof id !== 'string' || !/^[a-z0-9-]{1,100}$/i.test(id)) throw new Error('课件编号无效');
}
function scopeDirectory() {
const config = getConfig();
const scope = createHash('sha256')
.update(`${config.serverUrl}\n${config.username}`)
.digest('hex')
.slice(0, 24);
return path.join(app.getPath('userData'), 'courseware', scope);
}
async function json<T>(route: string, signal: AbortSignal, body?: object): Promise<T> {
const config = getConfig();
if (!config.token) throw new Error('请先登录班级账号');
const response = await fetch(`${config.serverUrl}/api/courseware${route}`, {
method: body ? 'POST' : 'GET',
headers: {
authorization: `Bearer ${config.token}`,
...(body ? { 'content-type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal,
redirect: 'error',
});
const value = (await response.json()) as ApiResponse<T>;
if (!response.ok || !value.success) throw new Error(value.message || `请求失败(${response.status})`);
return value.data;
}
async function digest(file: string) {
const hash = createHash('sha256');
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
return hash.digest('hex');
}
export function downloadCourseware(id: string): Promise<void> {
checkId(id);
const startedEpoch = epoch,
key = `${epoch}:${id}`;
const existing = jobs.get(key);
if (existing) return existing;
const job = queue
.catch(() => undefined)
.then(async () => {
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
clearTimeout(clearTimer);
abort = new AbortController();
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(COURSEWARE_TRANSFER_TIMEOUT_MS)]);
const config = getConfig();
let partial = '';
try {
const item = await json<CoursewareDto>(`/${encodeURIComponent(id)}`, signal);
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
if (
!Number.isInteger(item.size) ||
item.size < 1 ||
item.size > COURSEWARE_MAX_BYTES ||
!/^[a-f0-9]{64}$/.test(item.sha256)
)
throw new Error('课件文件信息无效');
const safeName =
`_${item.name.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/[. ]+$/, '')}`.slice(0, 182);
const dir = path.join(scopeDirectory(), id),
file = path.join(dir, safeName);
partial = `${file}.part`;
const base: CoursewareTransfer = {
id,
name: item.name,
category: item.category,
teacherName: item.teacher.name,
receivedBytes: 0,
totalBytes: item.size,
progress: 0,
stage: 'downloading',
};
publish(base);
await fsp.mkdir(dir, { recursive: true });
let valid = false;
try {
valid = (await fsp.stat(file)).size === item.size && (await digest(file)) === item.sha256;
} catch {
/* 尚未下载 */
}
if (!valid) {
const response = await fetch(
`${config.serverUrl}/api/courseware/${encodeURIComponent(id)}/download`,
{ headers: { authorization: `Bearer ${config.token}` }, signal, redirect: 'error' },
);
if (!response.ok || !response.body) throw new Error(`课件下载失败(HTTP ${response.status})`);
const hash = createHash('sha256');
let received = 0,
last = 0;
await pipeline(
Readable.fromWeb(response.body as ReadableStream),
new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length;
if (received > item.size) return callback(new Error('课件大小不匹配'));
hash.update(chunk);
if (Date.now() - last > 120) {
last = Date.now();
if (startedEpoch === epoch)
publish({
...base,
receivedBytes: received,
progress: Math.min(99, Math.floor((received / item.size) * 100)),
});
}
callback(null, chunk);
},
}),
fs.createWriteStream(partial, { flags: 'w' }),
{ signal },
);
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
publish({ ...base, receivedBytes: received, progress: 99, stage: 'verifying' });
if (received !== item.size || hash.digest('hex') !== item.sha256)
throw new Error('课件校验失败,请重新下载');
// 下载完成后再次检查可见权限,撤回的文件不得落成本地副本。
await json<CoursewareDto>(`/${encodeURIComponent(id)}`, signal);
if (startedEpoch !== epoch || signal.aborted) throw new Error('登录会话已切换或下载已取消');
await fsp.rm(file, { force: true });
await fsp.rename(partial, file);
}
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
publish({ ...base, receivedBytes: item.size, progress: 100, stage: 'completed' });
} catch (error) {
if (partial) await fsp.rm(partial, { force: true }).catch(() => undefined);
if (startedEpoch === epoch)
publish({
...(transfer?.id === id
? transfer
: {
id,
name: '课件',
category: 'other',
teacherName: '',
receivedBytes: 0,
totalBytes: 0,
progress: 0,
}),
stage: 'failed',
error: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
abort = null;
if (startedEpoch === epoch)
clearTimer = setTimeout(
() => {
if (transfer?.id === id) publish(null);
},
transfer?.stage === 'failed' ? 12000 : 5000,
);
}
})
.finally(() => jobs.delete(key));
jobs.set(key, job);
queue = job;
// IPC 调用者或轮询负责呈现错误;队列自身始终有拒绝处理器。
void job.catch(() => undefined);
return job;
}
export async function pollCourseware() {
if (polling || !getConfig().token || process.env.ELECTRON_SMOKE_TEST === '1') return;
const startedEpoch = epoch,
config = getConfig();
polling = true;
try {
const tasks = await json<CoursewarePushDto[]>(
`/transfers?deviceKey=${encodeURIComponent(config.deviceKey)}`,
AbortSignal.timeout(10000),
);
for (const task of tasks) {
if (startedEpoch !== epoch) break;
try {
await downloadCourseware(task.courseware.id);
if (startedEpoch !== epoch) break;
await json(`/transfers/${encodeURIComponent(task.id)}/ack`, AbortSignal.timeout(10000), {
deviceKey: config.deviceKey,
});
} catch (error) {
logger.warn(`课件接收失败,下次心跳重试:${String(error)}`);
}
}
} catch (error) {
logger.debug(`读取课件传输任务失败:${String(error)}`);
} finally {
polling = false;
}
}
export async function revealCourseware(id: string) {
checkId(id);
const dir = path.join(scopeDirectory(), id);
const names = await fsp.readdir(dir).catch(() => []);
const file = names.find((name) => !name.endsWith('.part'));
if (!file) throw new Error('请先完成课件下载');
shell.showItemInFolder(path.join(dir, file));
}
+6 -1
View File
@@ -1,3 +1,4 @@
import { downloadCourseware, getCoursewareTransfer, pollCourseware, revealCourseware } from './courseware.js';
import { island } from './island.js';
import { getUpdateProgress } from './auto-update.js';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
@@ -14,6 +15,10 @@ import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
export { IPC_CHANNELS };
export function registerIpcHandlers(): void {
ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id));
ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id));
ipcMain.handle('courseware:sync', () => { void pollCourseware(); });
ipcMain.handle('courseware:get-progress', () => getCoursewareTransfer());
ipcMain.handle('client:get-update-progress', () => getUpdateProgress());
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
@@ -136,7 +141,7 @@ export function registerIpcHandlers(): void {
return true;
});
/** 服务端联动状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md §5 第 9 条) */
/** 服务端连接状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md 的现行工程约定) */
ipcMain.handle(IPC_CHANNELS.clientStatus, () => getClientAgentStatus());
/** 立即上报一次(设置页「立即上报」按钮) */
+39 -5
View File
@@ -3,6 +3,8 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, ipcMain, screen } from 'electron';
import {
COURSEWARE_ISLAND_SIZE,
type CoursewareTransfer,
DEFAULT_ISLAND_APPEARANCE,
CLASSISLAND_ISLAND_METRICS,
normalizeIslandLayouts,
@@ -629,11 +631,29 @@ class IslandController {
getState(): IslandState {
return {
...this.state,
coursewareTransfer: this.coursewareShowing ? this.state.coursewareTransfer : null,
lessonReminder: this.lessonShowing ? this.lessonCue : null,
queued: [...this.state.queued],
};
}
setCoursewareTransfer(value: CoursewareTransfer | null): void {
this.setState({ coursewareTransfer: value });
}
getCoursewareTransfer(): CoursewareTransfer | null {
return this.state.coursewareTransfer ?? null;
}
private get coursewareShowing(): boolean {
return (
!!this.state.coursewareTransfer &&
!(
this.state.mode !== 'hidden' &&
this.state.active &&
(this.state.active.priority === 'URGENT' || this.state.active.kind === 'call')
)
);
}
isReady(): boolean {
return this.ready && Boolean(this.win) && !this.win?.isDestroyed();
}
@@ -662,7 +682,7 @@ class IslandController {
/**
* 同一条消息**可能经由两条广播链路到达**:服务端 `createCall` 既发 `notification:new`
* (班级房间)又发 `call:new`(学生/班级 user 房间),两条 DTO 的 id 相同、
* (班级房间)又发 `call:new`(班级会话 session 房间),两条 DTO 的 id 相同、
* 只有"是否叫人"的语义不同。后到的那条绝不能把已有的"叫人"降级成普通通知 ——
* 否则胶囊显示成"新消息"、上课时段还会被当成普通通知隐藏(用户实测截图:
* 叫人胶囊写着"新消息 / 系统管理员 · 点击查看")。
@@ -897,8 +917,7 @@ class IslandController {
if (this.state.active) this.scheduleCollapse(TIMEOUTS.expanded);
break;
}
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
// 经典模式课堂普通通知保持隐藏;普通叫人可主动点开,紧急消息也须支持收起后再次查看。
const showing = this.state.active;
// 岛当前可见(胶囊态)时,用户是"对着屏幕上这条岛"点的:
// 只要队列里有更**新**的消息(例如紧急通知之后又来了作业),就应当打开新那条,
@@ -1055,7 +1074,7 @@ class IslandController {
* 起因:用户反馈"点「知道了」「已读」时没有收回动画"(其他场景都有)。
* 状态**在同一帧就已经落地**(调用方 `dismissActive()` / `clearPendingForClose()` 已清空
* active/queued),这里只是把形态切回胶囊 —— **不引入任何跨进程握手,也没有收回快照**
* (见 AGENTS.md §5 第 34 条:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* (见 AGENTS.md 的现行工程约定:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* 它在触摸屏上会露旧帧;这里复用的是已被 `collapse` 用例验收过的那条路径)。
*/
private beginClose(): void {
@@ -1069,7 +1088,7 @@ class IslandController {
* 而窗口此刻还是展开/列表态的大包围盒 —— 若这里同步缩窗,卡片会在动画的 360ms 里
* 被窗口裁掉(冒烟用例「整批关闭过程中卡片不被窗口裁切」实测最大越界 554.9px,
* 用户看到的是一瞬间的硬裁边)。窗口要变小一律等**隐藏之后**由 `syncWindow()` 做,
* 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md §5 第 30 条)是同一条纪律。
* 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md 的现行工程约定)是同一条纪律。
*/
this.setState({ mode: 'pill', reason: 'closing' }, { skipWindow: true });
const speed = clamp(this.appearance.speed, 0.5, 2, 1);
@@ -1368,6 +1387,16 @@ class IslandController {
*/
private syncWindow(): void {
if (!this.win || this.win.isDestroyed()) return;
if (this.coursewareShowing && !this.editingLayout) {
this.cancelFade();
if (!this.win.isVisible() || this.customLayout) this.applyWindowLayout();
this.syncTouchLayout();
this.win.setOpacity(this.appearance.opacity);
if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual'))
this.win.showInactive();
this.setInteractive(false, '课件传输不遮挡桌面操作');
return;
}
if (this.editingLayout) {
this.setInteractive(true, '布局编辑');
return;
@@ -1894,6 +1923,7 @@ class IslandController {
/** 当前形态下**岛本身**的尺寸(不是窗口尺寸;响应进程的 `.island-card` 宽高) */
private formSize(): { width: number; height: number } {
if (this.coursewareShowing) return COURSEWARE_ISLAND_SIZE;
if (this.customLayout) return this.customSize;
if (this.lessonShowing) return { width: SIZES.expanded.width, height: 40 };
// 多条通知的列表形态:高度由统一布局算法算出(渲染进程算的是同一个数)
@@ -1964,6 +1994,10 @@ class IslandController {
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
private syncHitFromCursor(cursor?: { x: number; y: number } | null): void {
if (this.coursewareShowing) {
this.setInteractive(false, '课件传输');
return;
}
this.updateResidentPointer(cursor);
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
this.setInteractive(false, '窗口不可见');
@@ -0,0 +1,195 @@
/** 本地 HTTP 替身验证真实下载管线;只使用冒烟专属 userData,不连接真实服务端。 */
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { app } from 'electron';
import type { CoursewareDto } from '@classhelper/shared';
import { defaultIslandLayouts } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { downloadCourseware, getCoursewareTransfer } from './courseware.js';
import { island } from './island.js';
export async function verifyCoursewareSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
) {
if (process.env.ELECTRON_SMOKE_TEST !== '1' || !app.getPath('userData').includes('smoke'))
throw new Error('课件自检只允许使用冒烟临时配置');
const original = getConfig(),
bytes = Buffer.alloc(1024 * 1024, 83);
let corrupt = false,
downloads = 0,
slow = false;
const item: CoursewareDto = {
id: 'courseware-smoke',
name: '课件接收验证.pptx',
size: bytes.length,
category: 'presentation',
extension: 'pptx',
archiveDate: '2026-10-18',
sha256: createHash('sha256').update(bytes).digest('hex'),
createdAt: new Date().toISOString(),
teacher: { id: 'temporary-teacher', name: '临时课件教师' },
classes: [],
};
const server = http.createServer((req, res) => {
if (req.url?.endsWith('/download')) {
downloads++;
res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': bytes.length });
let offset = 0;
const timer = setInterval(
() => {
const chunk = bytes.subarray(offset, offset + 32768);
res.write(corrupt ? Buffer.alloc(chunk.length) : chunk);
offset += chunk.length;
if (offset >= bytes.length) {
clearInterval(timer);
res.end();
}
},
slow ? 100 : 12,
);
res.on('close', () => clearInterval(timer));
return;
}
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ success: true, data: item, message: 'ok' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
const wait = async (predicate: () => boolean | Promise<boolean>) => {
const end = Date.now() + 10000;
while (Date.now() < end) {
if (await predicate()) return true;
await new Promise((resolve) => setTimeout(resolve, 40));
}
return false;
};
async function localFiles(dir = path.join(app.getPath('userData'), 'courseware')): Promise<string[]> {
return (
await Promise.all(
(await fs.readdir(dir, { withFileTypes: true }).catch(() => [])).map((entry) =>
entry.isDirectory() ? localFiles(path.join(dir, entry.name)) : [path.join(dir, entry.name)],
),
)
).flat();
}
try {
saveConfig({
serverUrl: `http://127.0.0.1:${port}`,
username: 'courseware-smoke',
token: 'local-courseware-smoke',
islandLayout: defaultIslandLayouts(),
});
island.syncCustomLayout();
const job = downloadCourseware(item.id);
await check('课件后台下载去重且持续报告真实字节进度', async () => {
const same = job === downloadCourseware(item.id);
const progressed = await wait(() => {
const value = getCoursewareTransfer();
return !!value && value.progress > 0 && value.progress < 100;
});
return same && progressed && getCoursewareTransfer()?.teacherName === item.teacher.name;
});
await job;
await check('课件校验完成后保存正式文件并显示完成', async () => {
const files = await localFiles();
return (
getCoursewareTransfer()?.stage === 'completed' &&
getCoursewareTransfer()?.progress === 100 &&
files.length === 1 &&
(await fs.readFile(files[0]!)).equals(bytes)
);
});
await check('已完整接收的相同课件不重复传输', async () => {
await downloadCourseware(item.id);
return downloads === 1;
});
for (const layout of ['fixed', 'custom'] as const) {
await check(
`${layout === 'fixed' ? '固定岛' : '自定义常驻岛'}显示课件名称、类型、教师和进度`,
async () => {
saveConfig({ islandLayout: { ...defaultIslandLayouts(), layout } });
island.syncCustomLayout();
island.setCoursewareTransfer({
id: item.id,
name: item.name,
category: item.category,
teacherName: item.teacher.name,
receivedBytes: 512000,
totalBytes: item.size,
progress: 48,
stage: 'downloading',
});
return wait(
async () =>
!!(await island
.getWindow()!
.webContents.executeJavaScript(
`(() => {const card=document.querySelector('.courseware-transfer');return card?.textContent.includes('课件接收验证.pptx')&&card.textContent.includes('临时课件教师')&&card.textContent.includes('演示文稿')&&card.querySelector('progress')?.value===48;})()`,
)),
);
},
);
}
await check('紧急通知优先展示,关闭后恢复课件进度', async () => {
island.pushNotification({
id: 'courseware-urgent',
title: '紧急通知优先',
content: '课件接收继续进行',
priority: 'URGENT',
createdAt: new Date().toISOString(),
});
const urgent =
island.getState().mode === 'expanded' &&
!island.getState().coursewareTransfer &&
island.getCoursewareTransfer()?.progress === 48;
island.removeNotification('courseware-urgent');
return urgent && (await wait(() => island.getState().coursewareTransfer?.progress === 48));
});
island.setCoursewareTransfer(null);
item.id = 'corrupt-courseware';
corrupt = true;
await check('摘要不匹配时拒绝保存且清理临时文件', async () => {
let rejected = false;
try {
await downloadCourseware(item.id);
} catch {
rejected = true;
}
return (
rejected &&
getCoursewareTransfer()?.stage === 'failed' &&
!(await localFiles()).some((file) => file.includes(item.id))
);
});
corrupt = false;
slow = true;
item.id = 'cancel-courseware';
const cancelled = downloadCourseware(item.id).then(
() => false,
() => true,
);
await wait(() => getCoursewareTransfer()?.id === item.id && (getCoursewareTransfer()?.progress ?? 0) > 0);
saveConfig({ token: null });
await check(
'切换登录会话会中止接收并清理进度与临时文件',
async () =>
(await cancelled) &&
getCoursewareTransfer() === null &&
!(await localFiles()).some((file) => file.endsWith('.part')),
);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
saveConfig({
serverUrl: original.serverUrl,
username: original.username,
token: original.token,
islandLayout: original.islandLayout,
});
island.setCoursewareTransfer(null);
island.syncCustomLayout();
island.getWindow()?.hide();
}
}
@@ -43,7 +43,7 @@ export async function verifyLayoutSmoke(
'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))',
)) &&
editor.webContents.executeJavaScript(
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.includes('在下方新建'));b?.click();return Boolean(document.querySelector('.layout-editor')); })()`,
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='新增行');b?.click();return Boolean(document.querySelector('.island-editor-page .el-card')) && !document.querySelector('.layout-editor, iframe'); })()`,
)
);
});
@@ -52,11 +52,12 @@ export async function verifyLayoutSmoke(
const dragGeometry = await editor.webContents.executeJavaScript(`(() => {
const handle=document.querySelector('.node-handle[data-kind=clock]');
const row=document.querySelectorAll('[data-row]')[1]; if(!handle||!row)throw Error('缺少拖动目标');
document.querySelector('.preview-canvas')?.scrollIntoView({block:'center'});
const r=row.getBoundingClientRect(); const x=r.left+Math.min(20,r.width/2),y=r.top+r.height/2;
handle.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:19,pointerType:'touch',clientX:10,clientY:10}));
handle.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
handle.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
return {row:r.toJSON(), point:{x,y}, hit:document.elementFromPoint(x,y)?.outerHTML.slice(0,600), canvas:document.querySelector('.canvas')?.getBoundingClientRect().toJSON(), stage:document.querySelector('.preview-stage')?.getBoundingClientRect().toJSON()};
return {row:r.toJSON(), point:{x,y}, hit:document.elementFromPoint(x,y)?.outerHTML.slice(0,600), canvas:document.querySelector('.preview-canvas')?.getBoundingClientRect().toJSON()};
})()`);
await pause();
const moved =
@@ -66,7 +67,11 @@ export async function verifyLayoutSmoke(
});
await check('组件可以包裹进容器且设置独立保存', async () => {
await editor.webContents.executeJavaScript(
`(() => { const select=document.querySelector('[data-action=wrap]');if(!select)throw Error('未选中组件');select.value='group';select.dispatchEvent(new Event('change',{bubbles:true})); })()`,
`document.querySelector('[data-action=wrap] .el-select__wrapper')?.click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`(() => { const option=[...document.querySelectorAll('.el-select-dropdown__item')].find(e=>e.textContent.trim()==='分组'); if(!option)throw Error('容器菜单未出现');option.click(); })()`,
);
await pause();
return (
@@ -77,40 +82,49 @@ export async function verifyLayoutSmoke(
});
await check('组件设置按内容外观高级切换,整体外观单独显示', async () => {
const initial = await editor.webContents.executeJavaScript(`(() => {
const panel=document.querySelector('.settings');
return panel?.textContent.includes('显示内容') && !panel.textContent.includes('字体大小') && !panel.textContent.includes('背景颜色');
const panel=[...document.querySelectorAll('.component-settings .el-tab-pane')].find(e=>getComputedStyle(e).display!=='none');
return panel?.textContent.includes('排列方向') && !panel.textContent.includes('背景颜色');
})()`);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.panel-tabs button')[1].click()`,
`document.querySelectorAll('.component-settings .el-tabs__item')[1].click()`,
);
await pause();
const appearance = await editor.webContents.executeJavaScript(`(() => {
const panel=document.querySelector('.settings');
return panel?.textContent.includes('字体大小') && !panel.textContent.includes('组件操作');
const panel=[...document.querySelectorAll('.component-settings .el-tab-pane')].find(e=>getComputedStyle(e).display!=='none');
return panel?.textContent.includes('正文字号') && !panel.textContent.includes('排列方向');
})()`);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.panel-tabs button')[2].click()`,
`document.querySelectorAll('.component-settings .el-tabs__item')[2].click()`,
);
await pause();
const advanced = await editor.webContents.executeJavaScript(`(() => {
const panel=document.querySelector('.settings');
return panel?.textContent.includes('隐藏条件') && !panel.textContent.includes('字体大小');
const panel=[...document.querySelectorAll('.component-settings .el-tab-pane')].find(e=>getComputedStyle(e).display!=='none');
return panel?.textContent.includes('隐藏条件') && !panel.textContent.includes('背景颜色');
})()`);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.panel-tabs button')[0].click()`,
`document.querySelectorAll('.component-settings .el-tabs__item')[0].click()`,
);
await pause();
return initial && appearance && advanced;
});
await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => {
return editor.webContents.executeJavaScript(`(() => {
const stage=document.querySelector('.preview-stage')?.getBoundingClientRect();
const canvas=document.querySelector('.canvas')?.getBoundingClientRect();
const footer=document.querySelector('.canvas-footer')?.getBoundingClientRect();
return Boolean(stage&&canvas&&footer&&stage.left>=canvas.left&&stage.right<=canvas.right&&stage.bottom<=footer.top-4);
const canvas=document.querySelector('.preview-canvas')?.getBoundingClientRect();
const footer=document.querySelector('.preview-toolbar')?.getBoundingClientRect();
return Boolean(canvas&&footer&&canvas.bottom<=footer.top+1&&document.documentElement.scrollWidth<=innerWidth);
})()`);
});
await check('原生编辑页使用客户端主题并兼容深色', async () => {
return editor.webContents.executeJavaScript(`(() => {
const root=document.documentElement, wasDark=root.classList.contains('dark');
root.classList.add('dark');
const card=document.querySelector('.scheme-card'), style=getComputedStyle(card);
const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelectorAll('.island-editor-page .el-card').length>=4;
root.classList.toggle('dark',wasDark);return Boolean(ok);
})()`);
});
if (process.env.ISLAND_SHOTS_DIR) {
await editor.webContents.executeJavaScript(`document.querySelector('.main').scrollTop=0`);
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
fs.writeFileSync(
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-editor.png'),
@@ -118,7 +132,9 @@ export async function verifyLayoutSmoke(
);
}
await check('退出编辑后配置可从 getConfig 读回', async () => {
await editor.webContents.executeJavaScript('document.querySelector(".done-button")?.click()');
await editor.webContents.executeJavaScript(
`[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='应用并返回')?.click()`,
);
await pause();
return (
!island.getEditingLayout() &&
@@ -1,3 +1,4 @@
import { verifyCoursewareSmoke } from './smoke-courseware.js';
import { verifyLayoutSmoke } from './smoke-layout.js';
import { verifyAgentSmoke } from './smoke-agent.js';
import fs from 'node:fs';
@@ -84,6 +85,7 @@ export async function runQuickSmoke(win: BrowserWindow): Promise<void> {
}
await verifyAgentSmoke(check);
await verifyLayoutSmoke(check);
await verifyCoursewareSmoke(check);
await check('检查结束时没有可见或聚焦的验证窗口', () =>
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
);
+10 -1
View File
@@ -1,4 +1,4 @@
import type { ReleaseProgressDto as ContractReleaseProgressDto } from '@classhelper/shared';
import type { ReleaseProgressDto as ContractReleaseProgressDto, CoursewareTransfer } from '@classhelper/shared';
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
import type { ClientAgentStatus } from '../main/client-agent.js';
@@ -20,6 +20,15 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
*/
const bridge: DesktopBridge = {
downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id),
revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id),
syncCourseware: () => ipcRenderer.invoke('courseware:sync'),
getCoursewareTransfer: () => ipcRenderer.invoke('courseware:get-progress'),
onCoursewareTransfer: (handler) => {
const listener = (_event: unknown, value: CoursewareTransfer | null) => handler(value);
ipcRenderer.on('courseware:progress', listener);
return () => ipcRenderer.removeListener('courseware:progress', listener);
},
islandEditLayout: () => ipcRenderer.invoke('island:edit-layout'),
onIslandEditorOpen: (handler) => {
const listener = () => handler();
+2 -1
View File
@@ -10,6 +10,7 @@ import { getCacheScope, scopedKey } from './scope.js';
*/
export const CACHE_STORES = [
'courseware',
'profile',
'classes',
'schedules',
@@ -32,7 +33,7 @@ export interface CacheStoreStat {
}
const DB_NAME = 'classhelper-cache';
const DB_VERSION = 1;
const DB_VERSION = 2;
let databasePromise: Promise<IDBDatabase> | null = null;
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
ISLAND_COMPONENT_LABELS,
type IslandComponentNode,
type IslandComponentKind,
} from '@classhelper/shared';
const props = defineProps<{ node: IslandComponentNode }>();
const emit = defineEmits<{ change: []; remove: []; duplicate: []; wrap: [kind: IslandComponentKind] }>();
const node = computed(() => props.node),
tab = ref('content');
watch(
() => props.node.id,
() => {
tab.value = 'content';
},
);
const fonts = {
secondaryFontSize: '辅助文字',
bodyFontSize: '正文',
emphasizedFontSize: '强调文字',
largeFontSize: '大号文字',
} as const;
const containers = ['group', 'stack', 'slide', 'rolling'] as const;
</script>
<template>
<div class="component-settings">
<h3>{{ ISLAND_COMPONENT_LABELS[node.kind] }}</h3>
<el-tabs v-model="tab" stretch>
<el-tab-pane label="内容" name="content">
<el-form label-position="top">
<template v-if="node.kind === 'clock'">
<el-form-item label="12 小时制">
<el-switch v-model="node.settings.hour12" @change="emit('change')" />
</el-form-item>
<el-form-item label="显示秒">
<el-switch v-model="node.settings.seconds" @change="emit('change')" />
</el-form-item>
</template>
<el-form-item v-else-if="node.kind === 'countdown'" label="倒计时目标">
<el-select v-model="node.settings.target" @change="emit('change')">
<el-option label="自动:距上课 / 下课" value="auto" />
<el-option label="下一节课" value="next" />
<el-option label="本节下课" value="end" />
<el-option label="放学" value="school" />
</el-select>
</el-form-item>
<template v-else-if="node.kind === 'text'">
<el-form-item label="文字内容">
<el-input v-model="node.settings.text" type="textarea" :rows="3" @change="emit('change')" />
</el-form-item>
<el-form-item label="启用模板变量">
<el-switch v-model="node.settings.template" @change="emit('change')" />
</el-form-item>
<p class="hint">支持 {date} 日期、{time} 时间、{course} 当前课程。</p>
</template>
<template v-else-if="node.kind === 'schedule'">
<el-form-item label="显示课程">
<el-select v-model="node.settings.display" @change="emit('change')">
<el-option label="当前课程" value="current" />
<el-option label="下一节课" value="next" />
<el-option label="今日课表" value="today" />
</el-select>
</el-form-item>
<el-form-item v-if="node.settings.display === 'today'" label="显示节数">
<el-input-number v-model="node.settings.count" :min="1" :max="30" @change="emit('change')" />
</el-form-item>
<el-form-item label="显示课程时间">
<el-switch v-model="node.settings.showPeriod" @change="emit('change')" />
</el-form-item>
<el-form-item label="显示上课进度">
<el-switch v-model="node.settings.showProgress" @change="emit('change')" />
</el-form-item>
</template>
<template v-else-if="containers.includes(node.kind as (typeof containers)[number])">
<el-form-item label="排列方向">
<el-select v-model="node.settings.direction" @change="emit('change')">
<el-option label="横向" value="horizontal" />
<el-option label="纵向" value="vertical" />
</el-select>
</el-form-item>
<el-form-item label="组件间距">
<el-input-number v-model="node.settings.gap" :min="0" :max="80" @change="emit('change')" />
</el-form-item>
<el-form-item v-if="node.kind === 'slide'" label="切换间隔(秒)">
<el-input-number
v-model="node.settings.interval"
:min="1"
:max="300"
@change="emit('change')"
/>
</el-form-item>
<el-form-item v-if="node.kind === 'rolling'" label="滚动速度">
<el-input-number v-model="node.settings.speed" :min="1" :max="200" @change="emit('change')" />
</el-form-item>
</template>
<p v-else class="hint">此组件使用实时数据。可在外观和高级设置中调整显示。</p>
<el-divider />
<el-form-item label="包裹到容器">
<el-select
data-action="wrap"
model-value=""
placeholder="选择容器类型"
@change="(kind: IslandComponentKind) => emit('wrap', kind)"
>
<el-option
v-for="kind in containers"
:key="kind"
:label="ISLAND_COMPONENT_LABELS[kind]"
:value="kind"
/>
</el-select>
</el-form-item>
<div class="actions">
<el-button @click="emit('duplicate')">复制组件</el-button>
<el-button type="danger" plain @click="emit('remove')">删除</el-button>
</div>
</el-form>
</el-tab-pane>
<el-tab-pane label="外观" name="appearance">
<el-form label-position="top">
<el-form-item label="独立外观">
<el-switch v-model="node.appearance.resourceOverridingEnabled" @change="emit('change')" />
</el-form-item>
<p class="hint">关闭时沿用灵动岛的整体外观。</p>
<el-form-item v-for="(label, key) in fonts" :key="key" :label="`${label}字号`">
<el-input-number
v-model="node.appearance[key]"
:disabled="!node.appearance.resourceOverridingEnabled"
:min="8"
:max="96"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义文字颜色">
<el-switch
v-model="node.appearance.customForegroundEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-color-picker
v-if="node.appearance.customForegroundEnabled"
v-model="node.appearance.foreground"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义背景颜色">
<el-switch
v-model="node.appearance.customBackgroundEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-color-picker
v-if="node.appearance.customBackgroundEnabled"
v-model="node.appearance.background"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义背景透明度">
<el-switch
v-model="node.appearance.customBackgroundOpacityEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-input-number
v-if="node.appearance.customBackgroundOpacityEnabled"
v-model="node.appearance.backgroundOpacity"
:min="0"
:max="1"
:step="0.05"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义圆角">
<el-switch
v-model="node.appearance.customCornerRadiusEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-input-number
v-if="node.appearance.customCornerRadiusEnabled"
v-model="node.appearance.cornerRadius"
:min="0"
:max="100"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="组件透明度">
<el-slider
v-model="node.appearance.opacity"
:min="0"
:max="1"
:step="0.05"
@change="emit('change')"
/>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="高级" name="advanced">
<el-form label-position="top">
<el-form-item label="对齐方式">
<el-select v-model="node.advanced.align" @change="emit('change')">
<el-option
v-for="(label, key) in { stretch: '拉伸', start: '起始', center: '居中', end: '末端' }"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</el-form-item>
<el-form-item label="固定宽度(px)">
<el-input-number v-model="node.advanced.width" :min="0" :max="2000" @change="emit('change')" />
</el-form-item>
<el-form-item label="最大宽度(px)">
<el-input-number v-model="node.advanced.maxWidth" :min="0" :max="2000" @change="emit('change')" />
</el-form-item>
<p class="hint">宽度为 0 时自适应;对齐在父容器设定宽度时生效。</p>
<el-form-item label="隐藏条件">
<el-checkbox-group v-model="node.advanced.hideRules" @change="emit('change')">
<el-checkbox label="上课时" value="in-class" />
<el-checkbox label="没有消息时" value="no-message" />
<el-checkbox label="没有当前课程时" value="no-class" />
</el-checkbox-group>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
h3 {
margin: 0 0 6px;
}
.hint {
color: var(--ch-text-secondary);
line-height: 1.6;
font-size: 12px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.el-select {
width: 100%;
}
.el-form-item :deep(.el-form-item__content) {
gap: 10px;
}
.el-checkbox-group {
display: flex;
flex-direction: column;
}
</style>
@@ -8,7 +8,7 @@
*
* 客户端相较 Web 端多一层现实约束:教室机器多为**希沃触摸屏**,
* 触摸点按会产生粘住的幽灵 hover,因此 `useHoverCapable()` 在客户端是**必需**的,
* 不是锦上添花(见 AGENTS.md §5 第 30 条)。
* 不是锦上添花(见 AGENTS.md 的现行工程约定)。
*/
import { computed, onUnmounted, ref, watch, type CSSProperties, type Ref } from 'vue';
import {
@@ -11,6 +11,7 @@ import {
Connection,
Expand,
Fold,
FolderOpened,
FullScreen,
Guide,
InfoFilled,
@@ -62,6 +63,7 @@ const APP_ICONS = {
Connection,
Expand,
Fold,
FolderOpened,
FullScreen,
Guide,
InfoFilled,
@@ -40,6 +40,7 @@ const primaryItems = [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/courseware', title: '课件', icon: 'FolderOpened' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
];
@@ -18,6 +18,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('../layouts/ClientLayout.vue'),
redirect: '/schedule',
children: [
{ path: 'courseware', name: 'courseware', component: () => import('../views/CoursewareView.vue'), meta: { title: '课件' } },
{
path: 'schedule',
name: 'schedule',
@@ -48,6 +48,10 @@ export const useRealtimeStore = defineStore('realtime', () => {
}
function bind(instance: Socket): void {
instance.on(SOCKET_EVENTS.coursewareChanged, (payload: { id: string }) => {
dispatch(SOCKET_EVENTS.coursewareChanged, payload);
void window.desktop?.syncCourseware();
});
instance.on(SOCKET_EVENTS.clientReleaseProgress, (payload) => {
dispatch(SOCKET_EVENTS.clientReleaseProgress, payload);
});
@@ -784,7 +784,7 @@ textarea {
/*
* 卡片抬升。**必须关在 `@media (hover: hover) and (pointer: fine)` 里**:
* 教室机器的希沃触摸屏点一下会产生"幽灵 hover"并一直粘着,
* 不加这层的话卡片被点过之后就永远浮着(见 AGENTS.md §5 第 30 条)。
* 不加这层的话卡片被点过之后就永远浮着(见 AGENTS.md 的现行工程约定)。
* 这也正是 `composables/motion.ts` 里 `useHoverCapable()` 的 CSS 版本 ——
* 纯 CSS 效果用媒体查询就够了,不必为它挂一个 composable。
*/
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
COURSEWARE_CATEGORIES,
SOCKET_EVENTS,
type CoursewareDto,
type CoursewareTransfer,
} from '@classhelper/shared';
import { api } from '../api/http.js';
import { fetchWithCache } from '../cache/index.js';
import { getCacheScope } from '../cache/scope.js';
import { useRealtimeStore } from '../stores/realtime.js';
const realtime = useRealtimeStore();
const items = ref<CoursewareDto[]>([]),
loading = ref(false),
offline = ref(false),
error = ref('');
const query = ref(''),
category = ref(''),
date = ref(''),
busy = ref<string[]>([]),
transfer = ref<CoursewareTransfer | null>(null);
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),
),
);
let disposed = false;
async function refresh() {
const scope = getCacheScope();
loading.value = true;
try {
const result = await fetchWithCache<CoursewareDto[]>(
'courseware',
'list',
() => api.get('/courseware'),
[],
);
if (disposed || scope !== getCacheScope()) return;
items.value = result.data;
offline.value = result.fromCache;
error.value = result.error ?? '';
} finally {
loading.value = false;
}
}
async function download(item: CoursewareDto) {
if (!window.desktop) return;
busy.value.push(item.id);
try {
await window.desktop.downloadCourseware(item.id);
ElMessage.success('课件已下载,可打开所在文件夹');
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : String(e));
} finally {
busy.value = busy.value.filter((id) => id !== item.id);
}
}
async function reveal(id: string) {
try {
await window.desktop?.revealCourseware(id);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请先下载课件');
}
}
let offProgress: (() => void) | undefined;
const onRefresh = () => void refresh();
realtime.on(SOCKET_EVENTS.coursewareChanged, onRefresh);
realtime.on('__connected__', onRefresh);
let timer: ReturnType<typeof setInterval>;
onMounted(() => {
void refresh();
timer = setInterval(() => void refresh(), 30000);
offProgress = window.desktop?.onCoursewareTransfer((value) => {
transfer.value = value;
});
void window.desktop?.getCoursewareTransfer().then((value) => {
if (!disposed) transfer.value = value;
});
});
onUnmounted(() => {
disposed = true;
clearInterval(timer);
offProgress?.();
realtime.off(SOCKET_EVENTS.coursewareChanged, onRefresh);
realtime.off('__connected__', onRefresh);
});
</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-alert
v-if="offline || error"
:title="offline ? '当前显示离线缓存;联网后会重新校验可见范围。已下载文件可在本机文件夹查看。' : error"
type="warning"
:closable="false"
/>
<el-card v-if="transfer" shadow="never">
<strong>{{ transfer.name }}</strong>
<p>
{{ COURSEWARE_CATEGORIES[transfer.category] }} · {{ transfer.teacherName }} ·
{{
transfer.stage === 'completed' ? '接收完成' : transfer.stage === 'failed' ? '接收失败' : '正在接收'
}}
</p>
<el-progress
:percentage="transfer.progress"
:status="
transfer.stage === 'failed' ? 'exception' : transfer.stage === 'completed' ? 'success' : undefined
"
/>
<p v-if="transfer.error">{{ transfer.error }}</p>
</el-card>
<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-card shadow="never" class="table-card" v-loading="loading">
<el-table :data="filtered" empty-text="老师还没有向本班共享课件">
<el-table-column prop="name" label="课件名称" min-width="200" show-overflow-tooltip />
<el-table-column label="类型" min-width="110">
<template #default="{ row }">
{{ COURSEWARE_CATEGORIES[(row as CoursewareDto).category] }} · {{ row.extension || '无扩展名' }}
</template>
</el-table-column>
<el-table-column prop="teacher.name" label="上传教师" width="110" />
<el-table-column prop="archiveDate" label="归档日期" width="120" />
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ (row.size / 1048576).toFixed(2) }} MB</template>
</el-table-column>
<el-table-column label="操作" min-width="200">
<template #default="{ row }">
<el-button link type="primary" :loading="busy.includes(row.id)" @click="download(row)">
下载
</el-button>
<el-button link @click="reveal(row.id)">打开文件夹</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.courseware-page {
display: grid;
gap: 16px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
margin: 0;
}
p {
color: var(--ch-text-secondary);
line-height: 1.6;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.filters > * {
flex: 1;
min-width: 160px;
}
</style>
@@ -30,9 +30,9 @@ const visibleItems = computed(() =>
/* ------------------------------------------------------- 按天归档 / 按发布人 / 按类型 */
/**
* 三个筛选都在**已加载的列表**上做,不再请求服务端:
* 客户端是离线优先的(AGENTS.md §5 第 10 条),断网回退缓存时归档功能必须照常可用。
* 客户端是离线优先的(AGENTS.md 的现行工程约定),断网回退缓存时归档功能必须照常可用。
* 口径与服务端一致:按 `assignDate`(本地日期)分组 —— 用 createdAt 的 UTC 切片会把晚上
* 发出来的通知算到第二天(AGENTS.md §5 第 19 条)。
* 发出来的通知算到第二天(AGENTS.md 的现行工程约定)。
*/
const dayFilter = ref('');
const creatorFilter = ref('');
@@ -1,19 +1,48 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { onBeforeRouteLeave, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { AllowDropType } from 'element-plus/es/components/tree/src/tree.type';
import type TreeNode from 'element-plus/es/components/tree/src/model/node';
import {
defaultIslandLayouts,
CLASSISLAND_ISLAND_METRICS as METRICS,
DEFAULT_ISLAND_APPEARANCE,
ISLAND_COMPONENT_LABELS,
defaultIslandLayouts,
islandNode,
normalizeIslandLayouts,
type IslandComponentKind,
type IslandComponentNode,
type IslandLayoutRow,
type IslandState,
type IslandAppearance,
} from '@classhelper/shared';
import CustomIsland from '../../../island/CustomIsland.vue';
import ComponentNode from '../../../island/ComponentNode.vue';
import ComponentIcon from '../../../island/ComponentIcon.vue';
import IslandComponentSettings from '../../components/IslandComponentSettings.vue';
import { getIslandSchedules } from '../../island/bridge.js';
const router = useRouter();
const settings = ref(defaultIslandLayouts());
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const schedules = ref(getIslandSchedules());
const state = ref<IslandState>({
const settings = ref(defaultIslandLayouts()),
appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const schedules = ref(getIslandSchedules()),
now = ref(Date.now()),
ready = ref(false),
error = ref(''),
pending = ref(0),
saved = ref(false);
const surface = ref<'compact' | 'expanded'>('compact'),
panel = ref<'scheme' | 'row' | 'component'>('scheme');
const selectedId = ref(''),
rowId = ref(''),
libraryOpen = ref(false),
zoom = ref(1),
importInput = ref<HTMLInputElement>();
const scheme = computed(() =>
settings.value.schemes.find((item) => item.id === settings.value.activeSchemeId)!,
);
const rows = computed(() => (surface.value === 'expanded' ? scheme.value.expandedRows : scheme.value.rows));
const activeRow = computed(() => rows.value.find((row) => row.id === rowId.value) ?? rows.value[0]!);
const state: IslandState = {
mode: 'hidden',
active: null,
queued: [],
@@ -23,105 +52,706 @@ const state = ref<IslandState>({
listExpanded: false,
maxCardHeight: 1024,
updatedAt: 0,
});
const ready = ref(false);
const error = ref('');
let refresh: ReturnType<typeof setInterval>;
onMounted(async () => {
const config = await window.desktop?.getConfig();
if (config) {
settings.value = { ...config.islandLayout, layout: 'custom', residentEnabled: true };
appearance.value = config.island;
await window.desktop?.saveConfig({ islandLayout: JSON.parse(JSON.stringify(settings.value)) });
};
const groups = [
{ label: '日常信息', kinds: ['clock', 'date', 'schedule', 'countdown', 'text', 'separator'] },
{ label: '消息提醒', kinds: ['notification', 'homework', 'call'] },
{ label: '组合容器', kinds: ['group', 'stack', 'slide', 'rolling'] },
] as const;
const containers: readonly string[] = ['group', 'stack', 'slide', 'rolling'];
function id() {
return crypto.randomUUID();
}
function all(nodes: IslandComponentNode[]): IslandComponentNode[] {
return nodes.flatMap((node) => [node, ...all(node.children)]);
}
const selected = computed(() =>
rows.value.flatMap((row) => all(row.nodes)).find((node) => node.id === selectedId.value),
);
let saveQueue = Promise.resolve(),
revision = 0,
clock: ReturnType<typeof setInterval>;
function save() {
const snapshot = normalizeIslandLayouts(JSON.parse(JSON.stringify(settings.value)));
const current = ++revision;
pending.value++;
saved.value = false;
saveQueue = saveQueue.then(async () => {
try {
await window.desktop?.saveConfig({ islandLayout: snapshot });
if (current === revision) {
error.value = '';
saved.value = true;
}
} catch (e) {
error.value = e instanceof Error ? e.message : '保存失败,请重试';
} finally {
pending.value--;
}
});
return saveQueue;
}
function selectNode(nodeId: string) {
selectedId.value = nodeId;
panel.value = 'component';
rowId.value = rows.value.find((row) => all(row.nodes).some((node) => node.id === nodeId))?.id ?? '';
}
function selectRow(value: string) {
rowId.value = value;
selectedId.value = '';
panel.value = 'row';
}
function resetSelection() {
selectedId.value = '';
rowId.value = '';
panel.value = 'scheme';
}
function switchScheme() {
resetSelection();
void save();
}
function add(kind: IslandComponentKind) {
const node = islandNode(kind, id());
activeRow.value.nodes.push(node);
selectNode(node.id);
libraryOpen.value = false;
void save();
}
function addRow() {
const row: IslandLayoutRow = {
id: id(),
name: `组件行 ${rows.value.length + 1}`,
main: false,
allowNotifications: true,
gap: 8,
nodes: [],
};
rows.value.push(row);
selectRow(row.id);
void save();
}
function findList(nodeId: string, nodes?: IslandComponentNode[]): IslandComponentNode[] | undefined {
for (const list of nodes ? [nodes] : rows.value.map((row) => row.nodes)) {
if (list.some((node) => node.id === nodeId)) return list;
for (const node of list) {
const found = findList(nodeId, node.children);
if (found) return found;
}
}
ready.value = true;
refresh = setInterval(() => {
}
function removeNode() {
const list = findList(selectedId.value);
if (list)
list.splice(
list.findIndex((node) => node.id === selectedId.value),
1,
);
selectedId.value = '';
panel.value = 'row';
void save();
}
function copyNode() {
if (!selected.value) return;
const list = findList(selected.value.id)!;
const copy: IslandComponentNode = JSON.parse(JSON.stringify(selected.value));
all([copy]).forEach((node) => (node.id = id()));
list.splice(list.indexOf(selected.value) + 1, 0, copy);
selectNode(copy.id);
void save();
}
function wrap(kind: IslandComponentKind) {
if (!selected.value || !containers.includes(kind)) return;
const list = findList(selected.value.id)!,
container = islandNode(kind, id());
container.children = [selected.value];
list.splice(list.indexOf(selected.value), 1, container);
selectNode(container.id);
void save();
}
function removeRow() {
if (rows.value.length < 2) return;
const index = rows.value.indexOf(activeRow.value);
rows.value.splice(index, 1);
if (!rows.value.some((row) => row.main)) rows.value[0]!.main = true;
rowId.value = '';
void save();
}
function setMain() {
rows.value.forEach((row) => (row.main = row.id === activeRow.value.id));
void save();
}
function cloneScheme() {
const copy = JSON.parse(JSON.stringify(scheme.value)) as typeof scheme.value;
copy.id = id();
copy.name = `${copy.name} 副本`;
[...copy.rows, ...copy.expandedRows].forEach((row) => {
row.id = id();
all(row.nodes).forEach((node) => (node.id = id()));
});
settings.value.schemes.push(copy);
settings.value.activeSchemeId = copy.id;
switchScheme();
}
async function deleteScheme() {
if (settings.value.schemes.length < 2) return;
try {
await ElMessageBox.confirm(`删除方案「${scheme.value.name}」?`, '删除方案', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
});
} catch {
return;
}
settings.value.schemes = settings.value.schemes.filter((item) => item.id !== scheme.value.id);
settings.value.activeSchemeId = settings.value.schemes[0]!.id;
switchScheme();
}
function exportScheme() {
const url = URL.createObjectURL(
new Blob([JSON.stringify({ format: 'classhelper-island-layout', scheme: scheme.value }, null, 2)], {
type: 'application/json',
}),
);
const a = document.createElement('a');
a.href = url;
a.download = `${scheme.value.name.replace(/[<>:"/\\|?*]/g, '_')}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function importScheme(event: Event) {
const input = event.target as HTMLInputElement;
try {
const file = input.files?.[0];
if (!file) return;
if (file.size > 1000000) throw new Error('方案文件不能超过 1 MB');
const parsed = JSON.parse(await file.text());
if (parsed.format !== 'classhelper-island-layout' || !parsed.scheme?.rows?.length)
throw new Error('方案格式不正确');
const imported = normalizeIslandLayouts({ schemes: [parsed.scheme], activeSchemeId: parsed.scheme.id })
.schemes[0]!;
imported.id = id();
[...imported.rows, ...imported.expandedRows].forEach((row) => {
row.id = id();
all(row.nodes).forEach((node) => (node.id = id()));
});
settings.value.schemes.push(imported);
settings.value.activeSchemeId = imported.id;
switchScheme();
ElMessage.success('方案已导入');
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '导入失败');
} finally {
input.value = '';
}
}
async function apply() {
if (!ready.value) return;
settings.value.layout = 'custom';
settings.value.residentEnabled = true;
await save();
if (!error.value) {
ElMessage.success('自定义灵动岛已应用');
await router.push('/settings/island');
}
}
async function load() {
try {
const config = await window.desktop?.getConfig();
if (config) {
settings.value = normalizeIslandLayouts(config.islandLayout);
appearance.value = config.island;
}
ready.value = true;
error.value = '';
} catch (e) {
error.value = String(e);
}
}
onMounted(async () => {
await load();
clock = setInterval(() => {
now.value = Date.now();
schedules.value = getIslandSchedules();
}, 1000);
});
onUnmounted(() => clearInterval(refresh));
// 保存按顺序发送;快速拖动不会让较早的响应覆盖较新的草稿。
let saving = Promise.resolve();
function save(value: typeof settings.value) {
settings.value = value;
const snapshot = JSON.parse(JSON.stringify(value));
saving = saving
.then(async () => {
await window.desktop?.saveConfig({ islandLayout: snapshot });
error.value = '';
})
.catch(() => {
error.value = '保存失败,请重试';
});
onUnmounted(() => clearInterval(clock));
onBeforeRouteLeave(async () => {
if (!ready.value) return true;
await save();
return !error.value;
});
interface TreeItem {
id: string;
label: string;
type: 'row' | 'node';
source?: IslandComponentNode;
row?: IslandLayoutRow;
children: TreeItem[];
}
async function done() {
await saving;
if (!error.value) await router.push('/settings/island');
function nodeItem(node: IslandComponentNode): TreeItem {
return {
id: node.id,
label: ISLAND_COMPONENT_LABELS[node.kind],
type: 'node',
source: node,
children: node.children.map(nodeItem),
};
}
const tree = computed<TreeItem[]>(() =>
rows.value.map((row) => ({
id: row.id,
label: row.name,
type: 'row',
row,
children: row.nodes.map(nodeItem),
})),
);
function treeClick(item: TreeItem) {
if (item.type === 'row') selectRow(item.id);
else selectNode(item.id);
}
function allowDrop(dragging: TreeNode, dropping: TreeNode, type: AllowDropType) {
const from = dragging.data as TreeItem,
to = dropping.data as TreeItem;
if (from.type === 'row') return to.type === 'row' && type !== 'inner';
if (to.type === 'row') return type === 'inner';
return type !== 'inner' || (!!to.source && containers.includes(to.source.kind));
}
function dropped() {
function rebuild(item: TreeItem): IslandComponentNode {
return { ...item.source!, children: item.children.map(rebuild) };
}
const updated = tree.value.map((item) => ({ ...item.row!, nodes: item.children.map(rebuild) }));
if (surface.value === 'expanded') scheme.value.expandedRows = updated;
else scheme.value.rows = updated;
void save();
}
let dragging: string | null = null;
function startDrag(event: PointerEvent, nodeId: string) {
event.preventDefault();
event.stopPropagation();
dragging = nodeId;
try {
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
} catch {
/* 合成事件没有活动指针 */
}
}
function dropPreview(event: PointerEvent) {
if (!dragging) return;
const moving = dragging;
dragging = null;
const target = document.elementFromPoint(event.clientX, event.clientY),
targetId = target?.closest<HTMLElement>('[data-node]')?.dataset.node;
const row = rows.value.find((row) => row.id === target?.closest<HTMLElement>('[data-row]')?.dataset.row);
const list = findList(moving),
node = list?.find((node) => node.id === moving);
if (!row || !list || !node || all([node]).some((n) => n.id === targetId)) return;
const to = all(row.nodes).find((n) => n.id === targetId);
list.splice(list.indexOf(node), 1);
if (to && containers.includes(to.kind)) to.children.push(node);
else if (to) {
const targetList = findList(to.id)!;
targetList.splice(targetList.indexOf(to), 0, node);
} else row.nodes.push(node);
selectNode(node.id);
void save();
}
</script>
<template>
<section class="island-editor-page" aria-label="自定义灵动岛编辑器">
<p v-if="error" role="alert">{{ error }}</p>
<CustomIsland
v-if="ready"
:settings="settings"
:appearance="appearance"
:schedules="schedules"
:state="state"
editing
embedded
@save="save"
@done="done"
/>
<section class="page island-editor-page" aria-label="自定义灵动岛设置">
<header class="page-header page-heading">
<div>
<h2 class="page-title">自定义灵动岛</h2>
<p>管理方案、排列内容并调整组件。更改自动保存到本机。</p>
</div>
<div class="heading-actions">
<el-tag :type="error ? 'danger' : pending ? 'warning' : 'info'" effect="plain" role="status">
{{ error ? '保存失败' : pending ? '正在保存…' : saved ? '已保存' : '本机方案' }}
</el-tag>
<el-button @click="router.push('/settings/island')">返回设置</el-button>
<el-button type="primary" :loading="!!pending" :disabled="!ready" @click="apply">
应用并返回
</el-button>
</div>
</header>
<el-alert v-if="error" type="error" :closable="false" :title="error">
<el-button link type="primary" @click="ready ? save() : load()">重试</el-button>
</el-alert>
<template v-if="ready">
<el-card shadow="never" class="scheme-card">
<div class="scheme-toolbar">
<el-form-item label="当前方案">
<el-select v-model="settings.activeSchemeId" aria-label="当前方案" @change="switchScheme">
<el-option
v-for="item in settings.schemes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-button @click="cloneScheme">复制方案</el-button>
<el-button @click="importInput?.click()">导入</el-button>
<el-button @click="exportScheme">导出</el-button>
<el-button type="danger" plain :disabled="settings.schemes.length < 2" @click="deleteScheme">
删除方案
</el-button>
<input
ref="importInput"
type="file"
accept="application/json,.json"
hidden
@change="importScheme"
/>
</div>
</el-card>
<div class="editor-columns">
<div class="layout-column">
<el-card shadow="never" class="preview-card">
<template #header>
<div class="card-heading">
<strong>实时预览</strong>
<el-radio-group v-model="surface" size="small" @change="resetSelection">
<el-radio-button value="compact">常态内容</el-radio-button>
<el-radio-button value="expanded">无通知展开时</el-radio-button>
</el-radio-group>
</div>
</template>
<div class="preview-canvas" @pointerup="dropPreview" @pointercancel="dragging = null">
<div
class="island-preview"
:class="{ separated: scheme.separated }"
:style="{
zoom: scheme.scale * zoom,
'--preview-gap': `${scheme.spacing}px`,
'--preview-radius': `${scheme.radius}px`,
'--secondary': `${(appearance.fontSize / 13) * 14}px`,
'--emphasized': `${(appearance.fontSize / 13) * 18}px`,
'--large': `${(appearance.fontSize / 13) * 20}px`,
fontSize: `${(appearance.fontSize / 13) * 16}px`,
}"
>
<section
v-for="row in rows"
:key="row.id"
class="preview-row"
:class="{ selected: panel === 'row' && row.id === activeRow.id }"
:data-row="row.id"
:style="{ gap: `${row.gap}px` }"
@click.self="selectRow(row.id)"
>
<ComponentNode
v-for="node in row.nodes"
:key="node.id"
:node="node"
:state="state"
:now="now"
:schedules="schedules"
editing
:selected-id="selectedId"
alert-node-id=""
@select="selectNode"
@drag="startDrag"
/>
<span v-if="!row.nodes.length" class="empty-preview" @click="selectRow(row.id)">
此行暂无组件
</span>
</section>
</div>
</div>
<div class="preview-toolbar">
<span>预览缩放</span>
<el-slider
v-model="zoom"
:min="0.3"
:max="1.5"
:step="0.1"
:format-tooltip="(v: number) => `${Math.round(v * 100)}%`"
/>
<el-button size="small" @click="zoom = 1">重置</el-button>
</div>
</el-card>
<el-card shadow="never" class="structure-card">
<template #header>
<div class="card-heading">
<strong>内容结构</strong>
<div>
<el-button size="small" @click="addRow">新增行</el-button>
<el-button size="small" type="primary" @click="libraryOpen = true">添加组件</el-button>
</div>
</div>
</template>
<p class="hint">拖动可调整顺序,或将组件移入分组、轮播等容器。点选后在右侧设置。</p>
<el-tree
:data="tree"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
:current-node-key="selectedId || rowId"
draggable
:allow-drop="allowDrop"
@node-click="treeClick"
@node-drop="dropped"
>
<template #default="{ data }">
<span class="tree-label">
<span>{{ data.label }}</span>
<el-tag v-if="data.type === 'row' && data.row.main" size="small" type="info">主要行</el-tag>
</span>
</template>
</el-tree>
</el-card>
</div>
<el-card shadow="never" class="properties-card">
<template #header>
<el-radio-group v-model="panel" size="small">
<el-radio-button value="scheme">方案</el-radio-button>
<el-radio-button value="row">行</el-radio-button>
<el-radio-button value="component" :disabled="!selected">组件</el-radio-button>
</el-radio-group>
</template>
<IslandComponentSettings
v-if="panel === 'component' && selected"
:node="selected"
@change="save"
@remove="removeNode"
@duplicate="copyNode"
@wrap="wrap"
/>
<el-form v-else-if="panel === 'row'" label-position="top">
<h3>组件行</h3>
<el-form-item label="当前行">
<el-select :model-value="activeRow.id" @change="selectRow">
<el-option v-for="row in rows" :key="row.id" :value="row.id" :label="row.name" />
</el-select>
</el-form-item>
<el-form-item label="行名称"><el-input v-model="activeRow.name" @change="save" /></el-form-item>
<el-form-item label="组件间距">
<el-input-number v-model="activeRow.gap" :min="0" :max="80" @change="save" />
</el-form-item>
<el-form-item label="允许显示提醒">
<el-switch v-model="activeRow.allowNotifications" @change="save" />
</el-form-item>
<el-button :disabled="activeRow.main" @click="setMain">
{{ activeRow.main ? '已设为主要行' : '设为主要行' }}
</el-button>
<p class="hint">提醒优先使用主要行;关闭提醒后使用下一条允许提醒的行。</p>
<el-button type="danger" plain :disabled="rows.length < 2" @click="removeRow">删除此行</el-button>
</el-form>
<el-form v-else label-position="top">
<h3>方案设置</h3>
<el-form-item label="方案名称"><el-input v-model="scheme.name" @change="save" /></el-form-item>
<el-form-item label="界面缩放">
<el-input-number
v-model="scheme.scale"
:min="METRICS.scaleMin"
:max="METRICS.scaleMax"
:step="0.05"
@change="save"
/>
</el-form-item>
<el-form-item label="行间距">
<el-slider v-model="scheme.spacing" :min="0" :max="METRICS.rowSpacingMax" @change="save" />
</el-form-item>
<el-form-item label="常态圆角">
<el-slider v-model="scheme.radius" :min="0" :max="METRICS.radiusMax" @change="save" />
</el-form-item>
<el-form-item label="各行分离显示">
<el-switch v-model="scheme.separated" @change="save" />
</el-form-item>
<p class="hint">字体、透明度、位置和动画与灵动岛共用设置。</p>
<el-button link type="primary" @click="router.push('/settings/island')">
前往灵动岛外观设置
</el-button>
</el-form>
</el-card>
</div>
</template>
<el-skeleton v-else :rows="8" animated />
<el-dialog v-model="libraryOpen" title="添加组件" width="min(540px, 94vw)">
<el-form-item label="添加到">
<el-select :model-value="activeRow.id" @change="selectRow">
<el-option v-for="row in rows" :key="row.id" :value="row.id" :label="row.name" />
</el-select>
</el-form-item>
<section v-for="group in groups" :key="group.label" class="component-group">
<h3>{{ group.label }}</h3>
<div class="component-options">
<el-button v-for="kind in group.kinds" :key="kind" class="component-option" @click="add(kind)">
<ComponentIcon :kind="kind" />
<span>{{ ISLAND_COMPONENT_LABELS[kind] }}</span>
</el-button>
</div>
</section>
</el-dialog>
</section>
</template>
<style>
<style scoped>
.island-editor-page {
height: 100%;
min-height: 430px;
display: grid;
gap: 16px;
min-width: 0;
color: var(--ch-text);
}
.page-heading,
.heading-actions,
.scheme-toolbar,
.card-heading {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.page-heading,
.card-heading {
justify-content: space-between;
}
h2,
h3 {
margin: 0 0 6px;
}
.page-heading p,
.hint {
color: var(--ch-text-secondary);
line-height: 1.6;
margin: 6px 0;
font-size: 12px;
}
.scheme-toolbar .el-form-item {
margin: 0 auto 0 0;
}
.scheme-toolbar .el-select {
width: 200px;
}
.editor-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) 288px;
gap: 16px;
align-items: start;
}
.layout-column {
display: grid;
gap: 16px;
min-width: 0;
}
.preview-card :deep(.el-card__body) {
padding: 0;
}
.preview-canvas {
min-height: 208px;
overflow: auto;
display: grid;
place-items: center;
padding: 30px 18px;
background-color: var(--ch-layer-alt);
background-image: radial-gradient(var(--ch-border-strong) 1px, transparent 1px);
background-size: 16px 16px;
}
.island-preview {
display: flex;
flex-direction: column;
gap: var(--preview-gap);
color: #ffffffeb;
width: max-content;
max-width: none;
padding: 12px;
border-radius: var(--preview-radius);
background: #202024;
box-shadow: var(--ch-shadow-card);
}
.island-editor-page .layout-editor {
position: relative;
inset: auto;
flex: 1;
min-height: 430px;
width: 100%;
border-radius: 0;
.preview-row {
display: flex;
align-items: center;
min-height: 40px;
border-radius: var(--preview-radius);
}
.preview-row.selected {
outline: 2px solid var(--ch-accent);
outline-offset: 4px;
}
.island-preview.separated {
background: transparent;
padding: 0;
box-shadow: none;
grid-template-columns: 160px minmax(200px, 1fr) 248px;
grid-template-rows: 64px minmax(0, 1fr);
}
.island-editor-page .layout-editor .canvas {
padding: 110px 16px 80px;
.separated .preview-row {
padding: 12px;
background: #202024;
}
.island-editor-page .layout-editor .library,
.island-editor-page .layout-editor .settings {
min-height: 0;
padding: 14px 12px;
.empty-preview {
color: #ffffffb8;
padding: 8px 14px;
cursor: pointer;
font-size: 13px;
}
.island-editor-page .layout-editor .editor-header {
padding: 10px 14px;
gap: 12px;
.preview-toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
color: var(--ch-text-secondary);
font-size: 12px;
border-top: 1px solid var(--ch-border);
}
.island-editor-page .layout-editor .editor-title p,
.island-editor-page .layout-editor .save-state {
display: none;
.preview-toolbar .el-slider {
flex: 1;
max-width: 160px;
}
.island-editor-page .layout-editor .scheme-picker {
margin-left: auto;
.card-heading > div {
display: flex;
gap: 6px;
}
@media (max-width: 1100px) {
.island-editor-page .layout-editor {
grid-template-columns: 130px minmax(140px, 1fr) 220px;
.tree-label {
min-width: 0;
display: flex;
gap: 10px;
align-items: center;
font-size: 13px;
}
.properties-card .el-select {
width: 100%;
}
.component-group + .component-group {
margin-top: 20px;
}
.component-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.component-option {
height: 60px;
margin: 0;
}
.component-option :deep(span) {
display: flex;
align-items: center;
gap: 8px;
}
.component-option :deep(svg) {
width: 20px;
height: 20px;
}
@media (max-width: 900px) {
.editor-columns {
grid-template-columns: minmax(0, 1fr) 250px;
}
.island-editor-page .layout-editor .component-grid {
.scheme-toolbar .el-form-item {
flex-basis: 100%;
}
}
@media (max-width: 700px) {
.editor-columns {
grid-template-columns: 1fr;
}
.island-editor-page .layout-editor .scheme-picker > span {
display: none;
.heading-actions {
width: 100%;
}
.component-options {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
+7
View File
@@ -117,6 +117,13 @@ export interface IslandClassStatePayload {
}
export interface DesktopBridge {
downloadCourseware(id: string): Promise<void>;
revealCourseware(id: string): Promise<void>;
syncCourseware(): Promise<void>;
getCoursewareTransfer(): Promise<import('@classhelper/shared').CoursewareTransfer | null>;
onCoursewareTransfer(
handler: (value: import('@classhelper/shared').CoursewareTransfer | null) => void,
): () => void;
islandEditLayout(): Promise<void>;
onIslandEditorOpen(handler: () => void): () => void;
getUpdateProgress(): Promise<ContractReleaseProgressDto>;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
"version": "1.3.3",
"version": "1.4.0",
"private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
@@ -0,0 +1,35 @@
CREATE TABLE "Courseware" (
"id" TEXT NOT NULL PRIMARY KEY, "ownerId" TEXT NOT NULL, "name" TEXT NOT NULL,
"size" INTEGER NOT NULL, "category" TEXT NOT NULL, "extension" TEXT NOT NULL,
"archiveDate" TEXT NOT NULL, "storagePath" TEXT NOT NULL, "sha256" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Courseware_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "Courseware_storagePath_key" ON "Courseware"("storagePath");
CREATE INDEX "Courseware_ownerId_createdAt_idx" ON "Courseware"("ownerId", "createdAt");
CREATE TABLE "CoursewareVisibility" (
"coursewareId" TEXT NOT NULL, "classId" TEXT NOT NULL, PRIMARY KEY ("coursewareId", "classId"),
FOREIGN KEY ("coursewareId") REFERENCES "Courseware"("id") ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "CoursewareVisibility_classId_idx" ON "CoursewareVisibility"("classId");
CREATE TABLE "CoursewarePush" (
"id" TEXT NOT NULL PRIMARY KEY, "coursewareId" TEXT NOT NULL, "classId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("coursewareId") REFERENCES "Courseware"("id") ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "CoursewarePush_classId_createdAt_idx" ON "CoursewarePush"("classId", "createdAt");
CREATE TABLE "CoursewareReceipt" (
"pushId" TEXT NOT NULL, "deviceId" TEXT NOT NULL, "completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("pushId", "deviceId"),
FOREIGN KEY ("pushId") REFERENCES "CoursewarePush"("id") ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY ("deviceId") REFERENCES "ClientDevice"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- 仅为旧默认组和内置教学岗位补充新能力;自定义组仍需管理员按需授权。
INSERT INTO "PermissionGroupItem" ("id", "groupId", "key")
SELECT 'cw-read-' || "id", "id", 'courseware:read' FROM "PermissionGroup"
WHERE "defaultRole" IN ('ADMIN', 'TEACHER') OR "id" IN ('builtin-head', 'builtin-teacher');
INSERT INTO "PermissionGroupItem" ("id", "groupId", "key")
SELECT 'cw-write-' || "id", "id", 'courseware:write' FROM "PermissionGroup"
WHERE "defaultRole" IN ('ADMIN', 'TEACHER') OR "id" IN ('builtin-head', 'builtin-teacher');
+51
View File
@@ -34,6 +34,7 @@ datasource db {
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
model User {
coursewares Courseware[]
id String @id @default(cuid())
/// 登录名:教师即**工号**,班级端即班级码
username String @unique
@@ -201,6 +202,8 @@ model TermWeek {
/// `grade`(高一/高二/高三)表示**当前年级**,学年升级时批量改它即可 ——
/// 升级只动这一个字段,班级记录、学生名单、作业与成绩全部沿用,**不归档**。
model Class {
coursewares CoursewareVisibility[]
coursewarePushes CoursewarePush[]
id String @id @default(cuid())
/// 班级称呼:由 enrollmentYear + classIndex 生成,如「2026级1班」
name String
@@ -501,6 +504,7 @@ model AppSetting {
/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控
/// 指令按「查库 + 心跳拉取」的范式下发,不新增长连接握手。
model ClientDevice {
coursewareReceipts CoursewareReceipt[]
id String @id @default(cuid())
/// 该设备当前登录的班级(未登录时为空)
classId String?
@@ -717,3 +721,50 @@ model PromotionRun {
createdAt DateTime @default(now())
finishedAt DateTime?
}
/// 课件只在完整接收并计算摘要后入库;存储路径不对客户端暴露。
model Courseware {
id String @id @default(cuid())
ownerId String
name String
size Int
category String
extension String
archiveDate String
storagePath String @unique
sha256 String
createdAt DateTime @default(now())
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
visibility CoursewareVisibility[]
pushes CoursewarePush[]
@@index([ownerId, createdAt])
}
model CoursewareVisibility {
coursewareId String
classId String
courseware Courseware @relation(fields: [coursewareId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@id([coursewareId, classId])
@@index([classId])
}
/// 下发持久化,班级离线时不丢失;每台设备完成下载后单独回执。
model CoursewarePush {
id String @id @default(cuid())
coursewareId String
classId String
createdAt DateTime @default(now())
courseware Courseware @relation(fields: [coursewareId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
receipts CoursewareReceipt[]
@@index([classId, createdAt])
}
model CoursewareReceipt {
pushId String
deviceId String
completedAt DateTime @default(now())
push CoursewarePush @relation(fields: [pushId], references: [id], onDelete: Cascade)
device ClientDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@id([pushId, deviceId])
}
+1
View File
@@ -78,6 +78,7 @@ async function resetDatabase(): Promise<void> {
await prisma.clientCommand.deleteMany();
await prisma.clientDevice.deleteMany();
await prisma.clientRelease.deleteMany();
await prisma.courseware.deleteMany();
await prisma.aiAudit.deleteMany();
await prisma.userPermissionGroup.deleteMany();
await prisma.permissionGroupItem.deleteMany();
@@ -0,0 +1,330 @@
import type { AddressInfo } from 'node:net';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import http from 'node:http';
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { createApp } from '../src/app.js';
import { prisma } from '../src/lib/db.js';
import { signToken } from '../src/lib/jwt.js';
import { exportSnapshot, assertValidSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
import { ensureDefaultGroups } from '../src/lib/permissions.js';
import { registerEmitter } from '../src/realtime/bus.js';
import {
COURSEWARE_MAX_BYTES,
SOCKET_EVENTS,
coursewareCategory,
dayKeyLocal,
type CoursewareDto,
} from '@classhelper/shared';
if (
!process.env.DATABASE_URL?.includes('v14-verification') ||
!process.env.COURSEWARE_DIR?.includes('v14-verification')
)
throw new Error('课件验收只能使用临时库和文件目录');
let count = 0;
function check(name: string, value: unknown) {
assert.ok(value, name);
console.log(`通过 ${++count}:${name}`);
}
const emitted: { rooms: string[]; event: string; payload: unknown }[] = [];
registerEmitter((rooms, event, payload) => emitted.push({ rooms, event, payload }));
const staff = await Promise.all(
['课件教师甲', '课件教师乙', '仅班主任', '临时管理员'].map((name, i) =>
prisma.user.create({
data: {
name,
username: `cw-${Date.now()}-${i}`,
passwordHash: '仅签发临时令牌',
role: i === 3 ? 'ADMIN' : 'TEACHER',
},
}),
),
);
const tokens = staff.map((user) =>
signToken({
sub: user.id,
username: user.username,
name: user.name,
role: user.role as 'ADMIN' | 'TEACHER',
classId: null,
}),
);
const classes = await Promise.all(
[0, 1, 2].map((i) =>
prisma.class.create({
data: {
name: `课件验证班${i + 1}`,
grade: '验证',
code: `CW${Date.now()}${i}`,
teacherId: staff[2]!.id,
},
}),
),
);
await prisma.course.createMany({
data: classes.map((c, i) => ({ name: '语文', classId: c.id, teacherId: staff[i === 2 ? 1 : 0]!.id })),
});
const classTokens = classes.map((c) =>
signToken({
sub: c.id,
classId: c.id,
username: c.code,
name: c.name,
role: 'CLASS_DEVICE',
classSession: true,
}),
);
await ensureDefaultGroups();
let server = http.createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = () => `http://127.0.0.1:${(server.address() as AddressInfo).port}/api`;
async function request(route: string, token = tokens[0], body?: unknown, method = body ? 'POST' : 'GET') {
const res = await fetch(`${base()}${route}`, {
method,
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
return { status: res.status, ...(await res.json()) };
}
const bytes = Buffer.from('ClassHelper 课件流式上传验收\n'.repeat(3000));
async function upload(
options: Partial<{ name: string; size: number; classIds: string[]; mode: string }> = {},
token = tokens[0],
data = bytes,
) {
const meta = {
name: '第1课 演示.pptx',
size: data.length,
classIds: [classes[0]!.id],
mode: 'cloud',
...options,
};
const res = await fetch(
`${base()}/courseware/upload?metadata=${encodeURIComponent(JSON.stringify(meta))}`,
{
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' },
body: data,
signal: AbortSignal.timeout(10000),
},
);
return { status: res.status, ...(await res.json()) };
}
const list = async (token: string) => (await request('/courseware', token)).data as CoursewareDto[];
const heartbeat = async (i: number, key: string) =>
request('/clients/heartbeat', classTokens[i], {
deviceKey: key,
clientVersion: '1.4.0',
platform: 'verification',
});
const pending = async (i: number, key: string) =>
request(`/courseware/transfers?deviceKey=${key}`, classTokens[i]);
async function files(dir = process.env.COURSEWARE_DIR!): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
return (
await Promise.all(
entries.map((entry) =>
entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)],
),
)
).flat();
}
try {
check('未登录不能列出课件', (await request('/courseware', '')).status === 401);
const ownClasses = (await request('/courseware/classes')).data;
check(
'只能选择自己的授课班级',
ownClasses.length === 2 && !ownClasses.some((c: { id: string }) => c.id === classes[2]!.id),
);
check(
'仅班主任而无任课关系不能选择班级',
(await request('/courseware/classes', tokens[2])).data.length === 0,
);
check('管理员上传同样只能选择本人任课班级', (await upload({}, tokens[3])).status === 403);
check('班级端不能上传', (await upload({}, classTokens[0])).status === 403);
check('拒绝非任课班级上传', (await upload({ classIds: [classes[2]!.id] })).status === 403);
check('拒绝目录穿越文件名', (await upload({ name: '../secret.pptx' })).status === 422);
check('拒绝空文件', (await upload({ size: 0 }, tokens[0], Buffer.alloc(0))).status === 422);
check('拒绝超过 512 MB 的课件', (await upload({ size: COURSEWARE_MAX_BYTES + 1 })).status === 422);
check('拒绝错误文件长度', (await upload({ size: 1 })).status === 400);
check('推送不能不选班级', (await upload({ mode: 'push', classIds: [] })).status === 422);
const created = await upload();
const item = created.data as CoursewareDto;
check(
'完整上传后创建课件元数据',
created.status === 201 && item.size === bytes.length && item.name === '第1课 演示.pptx',
);
check(
'摘要、日期与类型正确',
item.sha256 === createHash('sha256').update(bytes).digest('hex') &&
item.archiveDate === dayKeyLocal(new Date()) &&
item.category === 'presentation',
);
check('API 不暴露服务端路径', !('storagePath' in item));
const stored = await prisma.courseware.findUniqueOrThrow({ where: { id: item.id } });
check(
'文件按日期和类型保存',
stored.storagePath.startsWith(path.join(item.archiveDate, 'presentation')) &&
(await fs.readFile(path.join(process.env.COURSEWARE_DIR!, stored.storagePath))).equals(bytes),
);
check(
'班级仅看到自己可见的课件',
(await list(classTokens[0]!)).length === 1 && (await list(classTokens[1]!)).length === 0,
);
check('其他教师不能访问本人文件', (await request(`/courseware/${item.id}`, tokens[1])).status === 403);
check(
'其他班级不能绕过列表下载',
(await request(`/courseware/${item.id}/download`, classTokens[1])).status === 403,
);
const downloaded = await fetch(`${base()}/courseware/${item.id}/download`, {
headers: { authorization: `Bearer ${classTokens[0]}` },
});
check(
'中文文件按附件下载且内容一致',
downloaded.headers.get('content-disposition')?.includes('attachment') &&
Buffer.from(await downloaded.arrayBuffer()).equals(bytes),
);
check('下载响应禁止共享缓存', downloaded.headers.get('cache-control')?.includes('no-store'));
await heartbeat(0, 'courseware-device-1');
await heartbeat(0, 'courseware-device-2');
await heartbeat(1, 'courseware-device-3');
check('仅存云端不会自动发送任务', (await pending(0, 'courseware-device-1')).data.length === 0);
check('未登记设备不能拉取传输', (await pending(0, 'unknown-device')).status === 403);
check('不能使用其他班级设备标识', (await pending(1, 'courseware-device-1')).status === 403);
const privateItem = (await upload({ classIds: [], name: '私有讲义.pdf' })).data as CoursewareDto;
check('仅自己可见文件不向班级披露', !(await list(classTokens[0]!)).some((c) => c.id === privateItem.id));
check(
'无法推送到课件可见范围以外',
(await request(`/courseware/${item.id}/push`, tokens[0], { classIds: [classes[1]!.id] })).status === 400,
);
check(
'他人不能改可见范围',
(await request(`/courseware/${item.id}/visibility`, tokens[1], { classIds: [] }, 'PATCH')).status === 403,
);
const pushed = (
await upload({ mode: 'push', name: '课堂练习.docx', classIds: classes.slice(0, 2).map((c) => c.id) })
).data as CoursewareDto;
check(
'推送模式先存云端实体',
!!(await prisma.courseware.findUnique({ where: { id: pushed.id } })) &&
(await list(tokens[0]!)).some((c) => c.id === pushed.id),
);
check(
'班级端不泄露其他可见班级',
(await list(classTokens[0]!)).every((c) => c.classes.every((v) => v.id === classes[0]!.id)),
);
const task = (await pending(0, 'courseware-device-1')).data[0];
check('推送任务可由离线班级下次上线拉取', task.courseware.id === pushed.id);
check(
'实时提示只发送到所选班级房间',
emitted
.filter((e) => e.event === SOCKET_EVENTS.coursewareChanged)
.every((e) =>
e.rooms.every((room) => [classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}`)),
),
);
await new Promise<void>((resolve) => server.close(() => resolve()));
server = http.createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
check('重启服务端后仍保留未完成传输', (await pending(0, 'courseware-device-1')).data[0].id === task.id);
const ackRoute = `/courseware/transfers/${task.id}/ack`;
check(
'其他班级不能回执任务',
(await request(ackRoute, classTokens[1], { deviceKey: 'courseware-device-3' })).status === 404,
);
check(
'接收完成后可回执',
(await request(ackRoute, classTokens[0], { deviceKey: 'courseware-device-1' })).status === 200,
);
check(
'重复回执幂等',
(await request(ackRoute, classTokens[0], { deviceKey: 'courseware-device-1' })).status === 200,
);
check(
'回执仅清除本设备任务',
(await pending(0, 'courseware-device-1')).data.length === 0 &&
(await pending(0, 'courseware-device-2')).data.length === 1,
);
await request(`/courseware/${pushed.id}/visibility`, tokens[0], { classIds: [classes[0]!.id] }, 'PATCH');
check(
'撤回班级可见范围同时取消其待传任务',
(await pending(1, 'courseware-device-3')).data.length === 0 &&
!(await list(classTokens[1]!)).some((c) => c.id === pushed.id),
);
check(
'撤回后不能通过直链下载',
(await request(`/courseware/${pushed.id}/download`, classTokens[1])).status === 403,
);
await request(`/courseware/${pushed.id}/push`, tokens[0], { classIds: [classes[0]!.id] });
check('已归档课件可再次推送', (await pending(0, 'courseware-device-1')).data.length === 1);
const defaultGroup = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
await prisma.permissionGroupItem.deleteMany({
where: { groupId: defaultGroup.id, key: 'courseware:write' },
});
check('收回写权限立即禁止上传', (await upload()).status === 403);
await prisma.permissionGroupItem.create({ data: { groupId: defaultGroup.id, key: 'courseware:write' } });
const snapshot = await exportSnapshot(prisma, 'sqlite');
check(
'备份包含课件、可见范围、推送和回执',
snapshot.data.Courseware.length === 3 &&
snapshot.data.CoursewareReceipt.length === 1 &&
snapshot.data.CoursewarePush.length > 0 &&
snapshot.data.CoursewareVisibility.length > 0,
);
const oldSnapshot = structuredClone(snapshot);
for (const key of ['Courseware', 'CoursewarePush', 'CoursewareReceipt', 'CoursewareVisibility'])
delete (oldSnapshot.data as Record<string, unknown>)[key];
assertValidSnapshot(oldSnapshot);
check('允许读取升级前的旧版快照', true);
await request(`/courseware/${pushed.id}`, tokens[0], undefined, 'DELETE');
check(
'删除课件级联清理推送任务',
(await pending(0, 'courseware-device-1')).data.length === 0 &&
(await pending(0, 'courseware-device-2')).data.length === 0,
);
check('删除实体后无残余文件', (await files()).length === 2);
const before = (await files()).length;
await new Promise<void>((resolve) => {
const metadata = { name: '中断上传.zip', size: 100000, classIds: [], mode: 'cloud' };
const req = http.request(
`${base()}/courseware/upload?metadata=${encodeURIComponent(JSON.stringify(metadata))}`,
{
method: 'POST',
headers: {
authorization: `Bearer ${tokens[0]}`,
'content-type': 'application/octet-stream',
'content-length': '100000',
},
},
);
req.on('error', () => resolve());
req.write(Buffer.alloc(1000));
setTimeout(() => req.destroy(), 250);
});
await new Promise((resolve) => setTimeout(resolve, 250));
check(
'上传中断自动清理临时文件',
(await files()).length === before && !(await files()).some((file) => file.endsWith('.part')),
);
check(
'文件分类覆盖演示、文档、媒体与压缩包',
coursewareCategory('A.PPTX') === 'presentation' &&
coursewareCategory('a.pdf') === 'document' &&
coursewareCategory('v.mp4') === 'video' &&
coursewareCategory('a.7z') === 'archive',
);
await restoreSnapshot(prisma, 'sqlite', snapshot);
check(
'恢复数据库后保留课件共享和回执数据',
(await prisma.courseware.count()) === 3 && (await prisma.coursewareReceipt.count()) === 1,
);
console.log(`=== 课件隔离验收:${count}/${count} 项通过 ===`);
} finally {
server.closeAllConnections();
server.close();
await once(server, 'close').catch(() => undefined);
await prisma.$disconnect();
}
+4
View File
@@ -3222,6 +3222,10 @@ async function main() {
'UserPermissionGroup',
'AppSetting',
'ClientDevice',
'Courseware',
'CoursewareVisibility',
'CoursewarePush',
'CoursewareReceipt',
'ClientCommand',
'ClientRelease',
'AiAudit',
+3
View File
@@ -1,3 +1,4 @@
import { COURSEWARE_TRANSFER_TIMEOUT_MS } from '@classhelper/shared';
import fs from 'node:fs';
import path from 'node:path';
import { createServer } from 'node:http';
@@ -61,6 +62,8 @@ await refreshDeveloperMode();
const app = createApp();
const httpServer = createServer(app);
// 大课件允许较慢的内网上传;请求头仍沿用 Node 的独立超时限制。
httpServer.requestTimeout = COURSEWARE_TRANSFER_TIMEOUT_MS;
const io = initRealtime(httpServer);
/** 写入 PID 文件(安装包的 start.cmd / stop.cmd 依赖它精确控制进程) */
+7 -1
View File
@@ -59,6 +59,10 @@ export const SNAPSHOT_TABLES = [
'NotificationRead',
'Grade',
'ClientDevice',
'Courseware',
'CoursewareVisibility',
'CoursewarePush',
'CoursewareReceipt',
'ClientCommand',
'ClientRelease',
'AiAudit',
@@ -132,6 +136,8 @@ export function assertValidSnapshot(value: unknown): asserts value is DatabaseSn
}
for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data as Record<string, unknown>)[table];
// 旧版本快照没有课件表;允许恢复,文件实体仍需单独备份。
if (rows === undefined && table.startsWith('Courseware')) continue;
if (!Array.isArray(rows)) {
throw new Error(`快照缺少表 ${table} 的数据`);
}
@@ -174,7 +180,7 @@ export async function restoreSnapshot(
}
}
for (const table of SNAPSHOT_TABLES) {
const rows = snapshot.data[table].map((row) => {
const rows = (snapshot.data[table] ?? []).map((row) => {
if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
const copy = { ...(row as Record<string, unknown>) };
delete copy.notificationChannel;
+1 -1
View File
@@ -22,7 +22,7 @@ router.use(authenticate());
*
* 权限分成两把:`ai:manage`(设置,默认只有管理员)与 `ai:use`(用一句话办事,默认教师也有)。
* **`/interpret` 只解析、绝不落库**:真正发布由前端确认后调既有端点,权限与上课时段保护
* 都在那条路径上(AGENTS.md §5 第 5/14 条)。
* 都在那条路径上(AGENTS.md 的现行工程约定)。
*/
router.get('/settings', requirePermission('ai:manage'), async (_req, res) => {
sendOk(res, await aiService.getAiSettings(), '获取大模型设置成功');
+1 -1
View File
@@ -24,7 +24,7 @@ import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
* 设计边界(照 approved 计划):
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
* 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md §5 第 5/14 条);
* 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md 的现行工程约定);
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
@@ -30,7 +30,7 @@ router.post(
*
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
* 通过 `GET /:id/classes/:classId/content` 只读查看。
* 「未毕业而升级的班级不归档」—— 升级走 `POST /api/classes/promote`,只改年级。
* 年度升级走 `POST /api/term/promotion/run`,毕业班归档,其余升一级;本模块读取归档结果。
*/
/** GET /api/archives - 届别列表 */
@@ -27,7 +27,7 @@ import type {
/**
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
*
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
* 范式(AGENTS.md 的现行工程约定):**DB 记状态 + 客户端定时拉取** ——
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
* 由客户端下一次心跳(30 秒内)带走。
*/
@@ -0,0 +1,109 @@
import { Router } from 'express';
import { sendOk, sendCreated } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireRole,
requirePermission,
requireStaffPermission,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { coursewareDeviceQuery, coursewareScopeSchema, coursewareUploadQuery } from './courseware.schemas.js';
import type { CoursewareUploadRequest } from '@classhelper/shared';
import * as service from './courseware.service.js';
const router = Router();
router.use(authenticate());
const staff = [requireRole('ADMIN', 'TEACHER'), requirePermission('courseware:write')];
router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
sendOk(res, await service.listCourseware(getAuthUser(req))),
);
router.get('/classes', ...staff, async (req, res) =>
sendOk(res, await service.teachingClasses(getAuthUser(req))),
);
router.get(
'/transfers',
requireRole('CLASS_DEVICE'),
validate({ query: coursewareDeviceQuery }),
async (req, res) =>
sendOk(
res,
await service.pendingTransfers(getAuthUser(req), validatedQuery<{ deviceKey: string }>(req).deviceKey),
),
);
router.post(
'/transfers/:id/ack',
requireRole('CLASS_DEVICE'),
validate({ params: idParamSchema, body: coursewareDeviceQuery }),
async (req, res) => {
await service.acknowledgeTransfer(
getAuthUser(req),
validatedParams<{ id: string }>(req).id,
validatedBody<{ deviceKey: string }>(req).deviceKey,
);
sendOk(res, null);
},
);
router.post('/upload', ...staff, validate({ query: coursewareUploadQuery }), async (req, res) =>
sendCreated(
res,
await service.uploadCourseware(
getAuthUser(req),
validatedQuery<{ metadata: CoursewareUploadRequest }>(req).metadata,
req,
),
),
);
router.get(
'/:id/download',
requireStaffPermission('courseware:read'),
validate({ params: idParamSchema }),
async (req, res, next) => {
const row = await service.readable(getAuthUser(req), validatedParams<{ id: string }>(req).id);
res.setHeader('Cache-Control', 'private, no-store');
res.setHeader('Content-Type', 'application/octet-stream');
res.download(row.storagePath, row.name, { root: service.coursewareRoot, dotfiles: 'allow' }, (error) => {
if (error && !res.headersSent) next(error);
});
},
);
router.patch(
'/:id/visibility',
...staff,
validate({ params: idParamSchema, body: coursewareScopeSchema }),
async (req, res) =>
sendOk(
res,
await service.setVisibility(
getAuthUser(req),
validatedParams<{ id: string }>(req).id,
validatedBody<{ classIds: string[] }>(req).classIds,
),
),
);
router.post(
'/:id/push',
...staff,
validate({ params: idParamSchema, body: coursewareScopeSchema }),
async (req, res) => {
await service.pushCourseware(
getAuthUser(req),
validatedParams<{ id: string }>(req).id,
validatedBody<{ classIds: string[] }>(req).classIds,
);
sendOk(res, null);
},
);
router.delete('/:id', ...staff, validate({ params: idParamSchema }), async (req, res) => {
await service.deleteCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id);
sendOk(res, null);
});
router.get(
'/:id',
requireStaffPermission('courseware:read'),
validate({ params: idParamSchema }),
async (req, res) =>
sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)),
);
export const coursewareModule = defineModule({ name: 'courseware', basePath: '/courseware', router });
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { COURSEWARE_MAX_BYTES } from '@classhelper/shared';
export const coursewareScopeSchema = z.object({ classIds: z.array(z.string().min(1).max(100)).max(100) });
export const coursewareUploadSchema = coursewareScopeSchema
.extend({
name: z
.string()
.trim()
.min(1)
.max(180)
.refine(
(name) => !/[<>:"/\\|?*\u0000-\u001f]/.test(name) && !name.endsWith('.') && !name.endsWith(' '),
'文件名含无效字符',
),
size: z.number().int().min(1).max(COURSEWARE_MAX_BYTES),
mode: z.enum(['cloud', 'push']),
})
.refine((input) => input.mode !== 'push' || input.classIds.length > 0, '推送时请选择授课班级');
export const coursewareUploadQuery = z.object({
metadata: z
.string()
.transform((value, ctx): unknown => {
try {
return JSON.parse(value);
} catch {
ctx.addIssue({ code: 'custom', message: '上传信息格式错误' });
return z.NEVER;
}
})
.pipe(coursewareUploadSchema),
});
export const coursewareDeviceQuery = z.object({ deviceKey: z.string().min(1).max(100) });
@@ -0,0 +1,228 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { randomUUID, createHash } from 'node:crypto';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { Request } from 'express';
import {
COURSEWARE_MAX_BYTES,
coursewareCategory,
dayKeyLocal,
SOCKET_EVENTS,
type CoursewareDto,
type CoursewareUploadRequest,
} from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { assertCanManageSubjectContent, requireClassDeviceClassId } from '../../lib/access.js';
import type { TokenPayload } from '../../lib/jwt.js';
import type { Prisma } from '../../generated/prisma/client.js';
import { emitToClass } from '../../realtime/bus.js';
export const coursewareRoot = path.resolve(
process.env.COURSEWARE_DIR || path.join(env.serverRoot, '..', 'data', 'courseware'),
);
const include = {
owner: { select: { id: true, name: true } },
visibility: { include: { class: { select: { id: true, name: true } } } },
} as const;
type RecordWithRelations = Prisma.CoursewareGetPayload<{ include: typeof include }>;
function dto(row: RecordWithRelations): CoursewareDto {
return {
id: row.id,
name: row.name,
size: row.size,
category: row.category as CoursewareDto['category'],
extension: row.extension,
archiveDate: row.archiveDate,
sha256: row.sha256,
createdAt: row.createdAt.toISOString(),
teacher: row.owner,
classes: row.visibility.map((item) => item.class),
};
}
export async function teachingClasses(user: TokenPayload) {
return prisma.class.findMany({
where: { archivedYearId: null, courses: { some: { teacherId: user.sub } } },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
}
async function validateClasses(user: TokenPayload, ids: string[]) {
const classIds = [...new Set(ids)];
const allowed = new Set((await teachingClasses(user)).map((item) => item.id));
if (classIds.some((id) => !allowed.has(id))) throw ApiError.forbidden('只能选择自己任教的在读班级');
for (const classId of classIds) {
const course = await prisma.course.findFirst({
where: { classId, teacherId: user.sub },
select: { id: true },
});
await assertCanManageSubjectContent(user, classId, course?.id, '发布课件');
}
return classIds;
}
export async function readable(user: TokenPayload, id: string) {
const row = await prisma.courseware.findUnique({ where: { id }, include });
if (!row) throw ApiError.notFound('课件不存在');
if (user.role === 'CLASS_DEVICE') {
if (!row.visibility.some((item) => item.classId === requireClassDeviceClassId(user)))
throw ApiError.forbidden('课件不在本班可见范围内');
} else if (row.ownerId !== user.sub && user.role !== 'ADMIN')
throw ApiError.forbidden('只能访问自己的课件');
return row;
}
async function owned(user: TokenPayload, id: string) {
const row = await readable(user, id);
if (row.ownerId !== user.sub) throw ApiError.forbidden('只能修改自己的课件');
return row;
}
export async function getCourseware(user: TokenPayload, id: string) {
const item = dto(await readable(user, id));
if (user.role === 'CLASS_DEVICE')
item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user));
return item;
}
export async function listCourseware(user: TokenPayload) {
const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null;
const rows = await prisma.courseware.findMany({
where: classId
? { visibility: { some: { classId } } }
: user.role === 'ADMIN'
? {}
: { ownerId: user.sub },
include,
orderBy: { createdAt: 'desc' },
});
// 班级端只暴露本班的可见关系。
return rows.map((row) => {
const item = dto(row);
if (classId) item.classes = item.classes.filter((c) => c.id === classId);
return item;
});
}
export async function uploadCourseware(user: TokenPayload, input: CoursewareUploadRequest, req: Request) {
const classIds = await validateClasses(user, input.classIds);
if (!req.is('application/octet-stream')) throw ApiError.badRequest('请使用二进制文件上传');
if (req.headers['content-length'] && Number(req.headers['content-length']) !== input.size)
throw ApiError.badRequest('文件长度与上传信息不符');
const category = coursewareCategory(input.name),
archiveDate = dayKeyLocal(new Date());
const extension = path.extname(input.name).slice(1).toLowerCase();
const relative = path.join(archiveDate, category, randomUUID());
const file = path.join(coursewareRoot, relative),
partial = `${file}.part`;
await fsp.mkdir(path.dirname(file), { recursive: true });
let bytes = 0;
const hash = createHash('sha256');
try {
await pipeline(
req,
new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
if (bytes > COURSEWARE_MAX_BYTES || bytes > input.size)
return callback(ApiError.badRequest('课件超过上传大小限制'));
hash.update(chunk);
callback(null, chunk);
},
}),
fs.createWriteStream(partial, { flags: 'wx' }),
);
if (bytes !== input.size) throw ApiError.badRequest('文件上传不完整,请重试');
await fsp.rename(partial, file);
// 上传耗时较长,完成后重新验证任课关系。
await validateClasses(user, classIds);
const row = await prisma.courseware.create({
data: {
ownerId: user.sub,
name: input.name,
size: bytes,
category,
extension,
archiveDate,
storagePath: relative,
sha256: hash.digest('hex'),
visibility: { create: classIds.map((classId) => ({ classId })) },
pushes: input.mode === 'push' ? { create: classIds.map((classId) => ({ classId })) } : undefined,
},
include,
});
for (const classId of classIds) emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id: row.id });
return dto(row);
} catch (error) {
await Promise.all([fsp.rm(partial, { force: true }), fsp.rm(file, { force: true })]);
throw error;
}
}
export async function setVisibility(user: TokenPayload, id: string, ids: string[]) {
await owned(user, id);
const classIds = await validateClasses(user, ids);
const old = await prisma.coursewareVisibility.findMany({ where: { coursewareId: id } });
const row = await prisma.$transaction(async (tx) => {
await tx.coursewarePush.deleteMany({ where: { coursewareId: id, classId: { notIn: classIds } } });
await tx.coursewareVisibility.deleteMany({ where: { coursewareId: id } });
return tx.courseware.update({
where: { id },
data: { visibility: { create: classIds.map((classId) => ({ classId })) } },
include,
});
});
for (const classId of new Set([...classIds, ...old.map((v) => v.classId)]))
emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id });
return dto(row);
}
export async function pushCourseware(user: TokenPayload, id: string, ids: string[]) {
const row = await owned(user, id);
const classIds = await validateClasses(user, ids);
if (!classIds.length || classIds.some((classId) => !row.visibility.some((v) => v.classId === classId)))
throw ApiError.badRequest('推送班级必须在课件可见范围内');
await prisma.$transaction(
classIds.map((classId) => prisma.coursewarePush.create({ data: { coursewareId: id, classId } })),
);
for (const classId of classIds) emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id });
}
export async function deleteCourseware(user: TokenPayload, id: string) {
const row = await owned(user, id);
await prisma.courseware.delete({ where: { id } });
await fsp.rm(path.join(coursewareRoot, row.storagePath), { force: true });
for (const v of row.visibility) emitToClass(v.classId, SOCKET_EVENTS.coursewareChanged, { id });
}
export async function deviceFor(user: TokenPayload, deviceKey: string) {
const classId = requireClassDeviceClassId(user);
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
if (!device || device.classId !== classId) throw ApiError.forbidden('设备未登记或不属于当前班级');
return { ...device, classId };
}
export async function pendingTransfers(user: TokenPayload, deviceKey: string) {
const device = await deviceFor(user, deviceKey);
const rows = await prisma.coursewarePush.findMany({
where: {
classId: device.classId,
receipts: { none: { deviceId: device.id } },
courseware: { visibility: { some: { classId: device.classId } } },
},
include: { courseware: { include } },
orderBy: { createdAt: 'asc' },
take: 50,
});
return rows.map((row) => ({
id: row.id,
courseware: {
...dto(row.courseware),
classes: dto(row.courseware).classes.filter((c) => c.id === device.classId),
},
}));
}
export async function acknowledgeTransfer(user: TokenPayload, pushId: string, deviceKey: string) {
const device = await deviceFor(user, deviceKey);
const push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } });
if (!push) throw ApiError.notFound('传输任务不存在');
await readable(user, push.coursewareId);
await prisma.coursewareReceipt.upsert({
where: { pushId_deviceId: { pushId, deviceId: device.id } },
create: { pushId, deviceId: device.id },
update: {},
});
}
@@ -77,7 +77,7 @@ router.post(
* POST /api/homeworks/bulk - 一次给多个班级布置同一份作业(逐班结果)
*
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md §5 第 20 条)。
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
* 批量不是绕过科目门的后门(§5 第 5 条)。
*/
@@ -149,7 +149,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
* - **没选科目** → 本班班主任或管理员;
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
* 见 AGENTS.md §5 第 20 条),班级端录入的作业归属该班班主任。
* 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
*/
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
+2
View File
@@ -1,3 +1,4 @@
import { coursewareModule } from './courseware/courseware.module.js';
import {
classPlansModule,
timeLayoutsModule,
@@ -33,6 +34,7 @@ import type { ApiModule } from './module.types.js';
* 临时下线:把 enabled 置为 false。
*/
export const apiModules: ApiModule[] = [
coursewareModule,
authModule,
aiModule,
classesModule,
@@ -18,11 +18,7 @@ import * as teacherService from './teachers.service.js';
const router = Router();
router.use(authenticate());
/**
* 教师账号管理:**全部接口仅管理员**。
* ClassHelper 班级端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
* 因此录入/修改/重置密码/删除都只开放给 ADMIN(路由层 + 前端菜单双重收敛)。
*/
/** 教师列表使用 teacher:read/teacher:manage,写入统一要求 teacher:manage;额外业务约束见 service。 */
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
router.get(
@@ -35,7 +31,7 @@ router.get(
},
);
/** POST /api/teachers - 录入教师账号(仅管理员) */
/** POST /api/teachers - 录入教师账号(需 teacher:manage) */
router.post(
'/',
requirePermission('teacher:manage'),
@@ -104,11 +104,12 @@ export async function resetTeacherPassword(
export async function deleteTeacher(userId: string): Promise<void> {
const teacher = await findTeacherOrThrow(userId);
const [ownedClasses, courses, homeworks, notifications] = await Promise.all([
const [ownedClasses, courses, homeworks, notifications, coursewares] = await Promise.all([
prisma.class.count({ where: { teacherId: teacher.id } }),
prisma.course.count({ where: { teacherId: teacher.id } }),
prisma.homework.count({ where: { createdBy: teacher.id } }),
prisma.notification.count({ where: { createdBy: teacher.id } }),
prisma.courseware.count({ where: { ownerId: teacher.id } }),
]);
if (ownedClasses > 0) {
throw ApiError.conflict(
@@ -127,5 +128,7 @@ export async function deleteTeacher(userId: string): Promise<void> {
);
}
if (coursewares > 0) throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
await prisma.user.delete({ where: { id: teacher.id } });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/shared",
"version": "1.3.3",
"version": "1.4.0",
"private": true,
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
"type": "module",
+6 -7
View File
@@ -170,12 +170,11 @@ export const DEFAULT_LETTER_LEVELS: readonly string[] = ['A', 'B', 'C', 'D'];
/**
* ClassHelper 班级端在线判定窗口(毫秒)。
*
* 插件默认 60 秒打一次心跳,因此「最近 60 秒内有心跳 = 在线」是最贴合的口径;
* 判定与展示(教师网页的「ClassHelper 在线状态」)共用这一个常量,避免两处各写一个数。
* @deprecated 旧联动在线窗口兼容导出,当前设备在线状态使用 CLIENT_ONLINE_WINDOW_MS。
*/
export const CLASS_HELPER_ONLINE_WINDOW_MS = 60_000;
/** 教师网页「ClassHelper 在线状态」的自动刷新间隔(秒) */
/** @deprecated 旧联动状态页刷新间隔,现行设备页不使用。 */
export const CLASS_HELPER_STATUS_REFRESH_SECONDS = 30;
/** 通知优先级 */
@@ -199,8 +198,7 @@ export const PRIORITY_TAG_TYPES: Record<NotificationPriority, 'info' | 'primary'
/**
* 优先级权重(越大越重要)。
*
* 灵动岛有多条通知时按它排序("默认按重要程度排列"),插件合并同 id 消息时也用它取更高优先级 ——
* 两边必须同一份口径,否则会出现"列表里的顺序和合并后的优先级不一致"。
* 灵动岛列表排序和同 id 消息合并共用,保证展示顺序与优先级合并一致。
*/
export const PRIORITY_RANK: Record<NotificationPriority, number> = {
LOW: 0,
@@ -480,6 +478,7 @@ export const DEFAULT_WEEK_COUNT = 20;
/** WebSocket 事件名(服务端广播 / 客户端监听保持一致) */
export const SOCKET_EVENTS = {
coursewareChanged: 'courseware:changed',
notificationNew: 'notification:new',
notificationDeleted: 'notification:deleted',
homeworkNew: 'homework:new',
@@ -489,7 +488,7 @@ export const SOCKET_EVENTS = {
scheduleUpdated: 'schedule:updated',
clientReleaseProgress: 'client:release-progress',
classUpdated: 'class:updated',
/** "叫人":老师点名让某位同学去找他(定向到 user:{studentId} 房间) */
/** "叫人":老师点名让某位同学去找他(定向到班级会话 session:{classId} 房间) */
callNew: 'call:new',
connected: 'connected',
} as const;
@@ -599,7 +598,7 @@ export const MAX_PAGE_SIZE = 200;
/* ------------------------------------------------------------------ 更新检查 */
/**
* 更新来源:本项目用 **GitHub Releases** 分发交付物(服务端 / 客户端安装包、便携版、插件 .cipx),
* 更新来源:本项目用 **GitHub Releases** 分发交付物(服务端 / 客户端安装包与便携版),
* 因此"有没有新版本"就是问 GitHub 上最新的那个 Release。
*
* 仓库是**公开**的,匿名调用即可(不需要 token);但匿名限流是 60 次/小时/IP,
+63
View File
@@ -0,0 +1,63 @@
/** 课件契约:文件先保存到服务端,再向可见班级下发下载任务。 */
export const COURSEWARE_MAX_BYTES = 512 * 1024 * 1024;
export const COURSEWARE_TRANSFER_TIMEOUT_MS = 30 * 60_000;
export const COURSEWARE_ISLAND_SIZE = { width: 424, height: 132 } as const;
export const COURSEWARE_CATEGORIES = {
presentation: '演示文稿',
document: '文档',
spreadsheet: '表格',
image: '图片',
video: '视频',
audio: '音频',
archive: '压缩包',
other: '其他',
} as const;
export type CoursewareCategory = keyof typeof COURSEWARE_CATEGORIES;
export function coursewareCategory(name: string): CoursewareCategory {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (['ppt', 'pptx', 'odp', 'key'].includes(ext)) return 'presentation';
if (['pdf', 'doc', 'docx', 'txt', 'md', 'odt', 'rtf'].includes(ext)) return 'document';
if (['xls', 'xlsx', 'csv', 'ods'].includes(ext)) return 'spreadsheet';
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return 'image';
if (['mp4', 'webm', 'avi', 'mov', 'mkv', 'wmv'].includes(ext)) return 'video';
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio';
if (['zip', '7z', 'rar', 'tar', 'gz'].includes(ext)) return 'archive';
return 'other';
}
export interface CoursewareClassDto {
id: string;
name: string;
}
export interface CoursewareDto {
id: string;
name: string;
size: number;
category: CoursewareCategory;
extension: string;
archiveDate: string;
sha256: string;
createdAt: string;
teacher: { id: string; name: string };
classes: CoursewareClassDto[];
}
export interface CoursewareUploadRequest {
name: string;
size: number;
classIds: string[];
mode: 'cloud' | 'push';
}
export interface CoursewarePushDto {
id: string;
courseware: CoursewareDto;
}
export interface CoursewareTransfer {
id: string;
name: string;
category: CoursewareCategory;
teacherName: string;
receivedBytes: number;
totalBytes: number;
progress: number;
stage: 'downloading' | 'verifying' | 'completed' | 'failed';
error?: string;
}
+1
View File
@@ -9,3 +9,4 @@ export * from './utils.js';
export * from './class-plan.js';
export * from './island-layout.js';
export type * from './types.js';
export * from './courseware.js';
+13 -3
View File
@@ -106,7 +106,7 @@ export function canManageSubjectContent(input: {
isSubjectTeacherOfCourse: boolean;
/**
* 是否允许 ClassHelper 班级端代写。
* 教室机器可以录**作业**(老师在教室随手录一条很常见,见 AGENTS.md §5 第 20 条),
* 教室机器可以录**作业**(老师在教室随手录一条很常见,见 AGENTS.md 的现行工程约定),
* 但**成绩**不给 —— 成绩是面向家长/学生的评价数据,仍由教师在 Web 端录入。
*/
allowClassDevice?: boolean;
@@ -160,9 +160,11 @@ export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind
* - 桌面客户端:功能入口显隐。
*
* 键名用 `领域:动作` 的形式,新增功能要**先在这里加键**,再去两端用。
* **不要**在页面或服务里硬编码角色判断来代替它(AGENTS.md §5 第 3 条)。
* **不要**在页面或服务里硬编码角色判断来代替它(AGENTS.md 的现行工程约定)。
*/
export const PERMISSION_KEYS = [
'courseware:read',
'courseware:write',
// 班级与人员
'class:read',
'class:manage',
@@ -189,7 +191,7 @@ export const PERMISSION_KEYS = [
'import:teachers',
'import:schedule',
'import:class-teachers',
// 设备与联动
// 客户端设备维护
'client:read',
'client:manage',
'client:update',
@@ -214,6 +216,8 @@ export type PermissionKey = (typeof PERMISSION_KEYS)[number];
/** 默认角色组之外的教师岗位组;空年级范围必须由管理员配置,不能自动授权全校。 */
const TEACHING_KEYS: PermissionKey[] = [
'courseware:read',
'courseware:write',
'homework:write',
'grade:write',
'notification:publish',
@@ -249,6 +253,8 @@ export const BUILTIN_TEACHER_GROUPS = [
/** 权限键的中文名(权限组管理页与文档展示,别再各写一份) */
export const PERMISSION_LABELS: Record<PermissionKey, string> = {
'courseware:read': '查看与下载课件',
'courseware:write': '上传、设置可见范围与推送自己的课件',
'class:read': '查看班级与班级列表',
'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)',
'roster:read': '查看学生名单',
@@ -309,6 +315,8 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per
{
title: '内容发布',
keys: [
'courseware:read',
'courseware:write',
'homework:read',
'homework:write',
'notification:read',
@@ -359,6 +367,8 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per
* (`canManageSubjectContent(..., { allowClassDevice })` 等)决定,不受权限组影响。
*/
export const PERMISSION_DEFAULT_ROLES: Record<PermissionKey, readonly UserRole[]> = {
'courseware:read': ['ADMIN', 'TEACHER'],
'courseware:write': ['ADMIN', 'TEACHER'],
'class:read': ['ADMIN', 'TEACHER'],
'class:manage': ['ADMIN'],
'roster:read': ['ADMIN', 'TEACHER'],
+5 -2
View File
@@ -1,3 +1,4 @@
import type { CoursewareTransfer } from './courseware.js';
import type {
PermissionScope as ContractPermissionScope,
ReleaseProgressDto as ContractReleaseProgressDto,
@@ -1156,6 +1157,7 @@ export type IslandStyle = 'black' | 'glass' | 'tinted';
/** 灵动岛当前状态(主进程持有,渲染进程与冒烟测试读取) */
export interface IslandState {
coursewareTransfer?: CoursewareTransfer | null;
mode: IslandMode;
lessonReminder?: IslandLessonReminder | null;
/** 正在展示的通知(展开态或胶囊态) */
@@ -1172,7 +1174,7 @@ export interface IslandState {
* `closing` 是"用户点了知道了 / 标为已读"之后的**收回动画**阶段:状态在同一帧就已经落地
* (`active` 清空、`queued` 清空),主进程只是把形态切回胶囊让渲染进程把卡片收回去,
* 待 `CLOSE_ANIMATION_MS` 之后再淡出隐藏。它不是跨进程握手,渲染进程也不需要任何回执
* (见 AGENTS.md §5 第 34 条)。
* (见 AGENTS.md 的现行工程约定)。
*/
reason: 'new' | 'after-class' | 'urgent' | 'call' | 'closing' | null;
/**
@@ -1208,6 +1210,7 @@ export interface DashboardSummary {
/** 服务端 -> 客户端事件名 */
export interface ServerToClientEvents {
'courseware:changed': (payload: { id: string }) => void;
'client:release-progress': (payload: ContractReleaseProgressDto) => void;
'notification:deleted': (payload: { id: string; classId: string }) => void;
'notification:new': (payload: NotificationDto) => void;
@@ -1515,7 +1518,7 @@ export interface AssignUserGroupsRequest {
/**
* `GET /api/permissions/me`:当前账号的生效权限。
*
* 两端用它决定"入口显不显示",而**不是**再写一套权限规则(AGENTS.md §5 第 3 条):
* 两端用它决定"入口显不显示",而**不是**再写一套权限规则(AGENTS.md 的现行工程约定):
* 真正的拦截始终在服务端。
*/
export interface MyPermissionsDto {
+2 -2
View File
@@ -531,7 +531,7 @@ export function isNewerVersion(
* 客户端安装包的**文件名约定**。
*
* 与 GitHub Release 上上传的 ASCII 名、以及官网下载页的离线兜底直链
* **必须一致**(AGENTS.md §4.4:改名要三处一起改)。
* **必须一致**(AGENTS.md 的现行工程约定:改名要三处一起改)。
*/
export function clientReleaseFileName(version: string): string {
return `ClassHelper-${normalizeVersion(version)}-x64-client-setup.exe`;
@@ -541,7 +541,7 @@ export function clientReleaseFileName(version: string): string {
* 服务端从 GitHub 拉取客户端安装包的直链。
*
* 服务端替教室机器"代下"再缓存(教室常常没有外网)—— Web 端**不能**直连 GitHub
* (CSP 的 connect-src 只有 self / ws / CORS_ORIGIN,见 AGENTS.md §5 第 52 条)。
* (CSP 的 connect-src 只有 self / ws / CORS_ORIGIN,见 AGENTS.md 的现行工程约定)。
*/
export function clientReleaseDownloadUrl(version: string): string {
const tag = `v${normalizeVersion(version)}`;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/web-admin",
"version": "1.3.3",
"version": "1.4.0",
"private": true,
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
+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`
* 都是服务端算好的结果。