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>;
|
||||
|
||||
Reference in new issue
Block a user