feat(courseware,docs): 完善课件传输与岛编辑并统一全仓文档
- 增加课件上传、班级可见范围、设备补收与传输进度 - 整合自定义灵动岛编辑器和相关客户端交互 - 同步三端契约、数据库迁移、部署配置与隔离回归 - 按源码统一文档与官网说明,移除过时任务书和审查记录 - 保持产品版本为 1.4.0
This commit is contained in:
1 parent
536379036a
commit
c7f215a09c
127 files changed
+4334
-8482
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
/** 立即上报一次(设置页「立即上报」按钮) */
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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,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');
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -3222,6 +3222,10 @@ async function main() {
|
||||
'UserPermissionGroup',
|
||||
'AppSetting',
|
||||
'ClientDevice',
|
||||
'Courseware',
|
||||
'CoursewareVisibility',
|
||||
'CoursewarePush',
|
||||
'CoursewareReceipt',
|
||||
'ClientCommand',
|
||||
'ClientRelease',
|
||||
'AiAudit',
|
||||
|
||||
@@ -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 依赖它精确控制进程) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(), '获取大模型设置成功');
|
||||
|
||||
@@ -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, '布置作业', {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/shared",
|
||||
"version": "1.3.3",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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`。
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -36,7 +36,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
/**
|
||||
* 当前账号的**生效权限**(`GET /api/permissions/me`)。
|
||||
*
|
||||
* 只用来决定"入口显不显示",**不是**另一套权限规则(AGENTS.md §5 第 3 条)——
|
||||
* 只用来决定"入口显不显示",**不是**另一套权限规则(AGENTS.md 的现行工程约定)——
|
||||
* 真正的拦截始终在服务端。页面刷新后这里是空的,由 `AdminLayout` 挂载时调
|
||||
* {@link ensurePermissions} 补上。
|
||||
*/
|
||||
|
||||
@@ -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 会把每个分组的子项卸载进浮层
|
||||
* (一次结构切换)并让整条菜单重排一遍 —— 既做不出连续形变,也是"点汉堡卡一下"的根因。
|
||||
|
||||
@@ -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>
|
||||
@@ -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`
|
||||
* 都是服务端算好的结果。
|
||||
|
||||
Reference in new issue
Block a user