feat(ui): 补齐课程提醒进度并内嵌编辑器与重构后台导航
This commit is contained in:
1 parent
3379eb975c
commit
536379036a
34 files changed
+1559
-455
No files matched your search
@@ -63,7 +63,9 @@ const fonts = {
|
||||
<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>
|
||||
@@ -95,6 +97,10 @@ const fonts = {
|
||||
<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">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, type CSSProperties } from 'vue';
|
||||
import {
|
||||
ISLAND_COMPONENT_LABELS,
|
||||
resolveClassStatus,
|
||||
type IslandComponentNode,
|
||||
type IslandState,
|
||||
type ScheduleDto,
|
||||
@@ -23,16 +24,35 @@ function select(event: MouseEvent) {
|
||||
emit('select', props.node.id);
|
||||
}
|
||||
const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind));
|
||||
const current = computed(() => {
|
||||
const d = new Date(props.now),
|
||||
t = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
return props.schedules.find((s) => s.startTime <= t && s.endTime > t);
|
||||
});
|
||||
const next = computed(() => {
|
||||
const d = new Date(props.now),
|
||||
t = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
return props.schedules.find((s) => s.startTime > t);
|
||||
});
|
||||
const today = computed(() =>
|
||||
props.schedules
|
||||
.filter((s) => s.dayOfWeek === (new Date(props.now).getDay() || 7))
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime)),
|
||||
);
|
||||
const status = computed(() => resolveClassStatus(today.value, new Date(props.now)));
|
||||
const current = computed(() => today.value.find((s) => s.id === status.value.current?.scheduleId));
|
||||
const next = computed(() => today.value.find((s) => s.id === status.value.next?.scheduleId));
|
||||
function timeAt(time: string) {
|
||||
const d = new Date(props.now);
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
d.setHours(h!, m!, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
const elapsed = computed(() =>
|
||||
current.value
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(props.now - timeAt(current.value.startTime)) /
|
||||
Math.max(1, timeAt(current.value.endTime) - timeAt(current.value.startTime)),
|
||||
),
|
||||
)
|
||||
: 0,
|
||||
);
|
||||
const remaining = computed(() =>
|
||||
current.value ? Math.max(0, Math.ceil((timeAt(current.value.endTime) - props.now) / 1000)) : 0,
|
||||
);
|
||||
const hidden = computed(
|
||||
() =>
|
||||
!props.editing &&
|
||||
@@ -75,21 +95,27 @@ const content = computed(() => {
|
||||
.replaceAll('{course}', current.value?.course?.name || '暂无课程')
|
||||
: n.settings.text;
|
||||
if (n.kind === 'countdown') {
|
||||
const time = n.settings.target === 'school' ? props.schedules.at(-1)?.endTime : next.value?.startTime;
|
||||
const ending = n.settings.target === 'end' || (n.settings.target === 'auto' && !!current.value);
|
||||
const time =
|
||||
n.settings.target === 'school'
|
||||
? today.value.at(-1)?.endTime
|
||||
: ending
|
||||
? current.value?.endTime
|
||||
: next.value?.startTime;
|
||||
if (!time) return '今日课程已结束';
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
const end = new Date(d);
|
||||
end.setHours(h!, m!, 0, 0);
|
||||
const left = Math.max(0, Math.ceil((end.getTime() - props.now) / 1000));
|
||||
return `${n.settings.target === 'school' ? '距放学' : '距上课'} ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
||||
return `${n.settings.target === 'school' ? '距放学' : ending ? '距下课' : '距上课'} ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const lessons = computed(() =>
|
||||
props.node.settings.display === 'today'
|
||||
? props.schedules.slice(0, props.node.settings.count)
|
||||
: [props.node.settings.display === 'next' ? next.value : current.value].filter((s): s is ScheduleDto =>
|
||||
Boolean(s),
|
||||
? today.value.slice(0, props.node.settings.count)
|
||||
: [props.node.settings.display === 'next' ? next.value : current.value || next.value].filter(
|
||||
(s): s is ScheduleDto => Boolean(s),
|
||||
),
|
||||
);
|
||||
const style = computed<CSSProperties>(() => {
|
||||
@@ -166,8 +192,22 @@ const style = computed<CSSProperties>(() => {
|
||||
<div v-for="s in lessons" :key="s.id" class="lesson-line">
|
||||
<strong>{{ s.course?.name }}</strong>
|
||||
<small v-if="node.settings.showPeriod" class="lesson-time">{{ s.startTime }}–{{ s.endTime }}</small>
|
||||
<small v-if="node.settings.showProgress && current?.id === s.id" class="lesson-remaining">
|
||||
距下课 {{ Math.floor(remaining / 60) }}:{{ String(remaining % 60).padStart(2, '0') }}
|
||||
</small>
|
||||
</div>
|
||||
<span v-if="!lessons.length">暂无课程</span>
|
||||
<div
|
||||
v-if="current && node.settings.showProgress && node.settings.display !== 'next'"
|
||||
class="lesson-progress"
|
||||
role="progressbar"
|
||||
:aria-valuenow="Math.round(elapsed * 100)"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="100"
|
||||
:aria-label="`${current.course?.name}课程进度,距下课${remaining}秒`"
|
||||
>
|
||||
<i :style="{ transform: `scaleX(${elapsed})` }" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
|
||||
<slot v-if="node.id === alertNodeId" name="alerts" />
|
||||
@@ -197,7 +237,7 @@ const style = computed<CSSProperties>(() => {
|
||||
.countdown {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedule > div {
|
||||
.schedule > .lesson-line {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -215,9 +255,29 @@ const style = computed<CSSProperties>(() => {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedule > div + div {
|
||||
.lesson-progress {
|
||||
position: absolute;
|
||||
inset: auto 4px 0;
|
||||
height: 2px;
|
||||
background: #ffffff24;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lesson-progress i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--island-accent, #60a5fa);
|
||||
transform-origin: left;
|
||||
transition: transform 1s linear;
|
||||
}
|
||||
.schedule > .lesson-line + .lesson-line {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.lesson-remaining {
|
||||
font-size: var(--secondary, 14px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.separator > span {
|
||||
display: block;
|
||||
width: 1px;
|
||||
|
||||
@@ -22,6 +22,7 @@ const props = defineProps<{
|
||||
editing: boolean;
|
||||
appearance: IslandAppearance;
|
||||
schedules: ScheduleDto[];
|
||||
embedded?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
save: [settings: IslandLayoutSettings];
|
||||
@@ -115,6 +116,10 @@ function switchScheme() {
|
||||
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 });
|
||||
@@ -358,6 +363,21 @@ let observer: ResizeObserver | undefined,
|
||||
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(),
|
||||
@@ -387,6 +407,8 @@ 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();
|
||||
});
|
||||
@@ -400,6 +422,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div
|
||||
:class="editing ? 'layout-editor' : 'custom-window'"
|
||||
:data-embedded="embedded || undefined"
|
||||
@pointerdown.capture="onPointerDown"
|
||||
@pointermove="panMove"
|
||||
@pointerup="
|
||||
@@ -482,7 +505,7 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main :class="{ canvas: editing }" @pointerdown="panStart">
|
||||
<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')">
|
||||
@@ -513,8 +536,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="previewStage"
|
||||
class="preview-stage"
|
||||
:style="editing ? { transform: `translate(${pan.x}px,${pan.y}px) scale(${canvasScale})` } : {}"
|
||||
:style="editing ? { transform: `translate(${pan.x}px,${pan.y}px) scale(${previewScale})` } : {}"
|
||||
>
|
||||
<div
|
||||
ref="shell"
|
||||
|
||||
@@ -254,8 +254,8 @@ function measure() {
|
||||
}
|
||||
function resizeCanvas(width = innerWidth, height = innerHeight) {
|
||||
available.value = {
|
||||
width: Math.max(40, width - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
height: Math.max(40, height - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
width: Math.max(40, Math.min(width, innerWidth) - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
height: Math.max(40, Math.min(height, innerHeight) - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
};
|
||||
measure();
|
||||
}
|
||||
@@ -263,6 +263,7 @@ const resize = () => resizeCanvas();
|
||||
let stopViewport: (() => void) | undefined;
|
||||
function visibility() {
|
||||
visible.value = !document.hidden;
|
||||
resize();
|
||||
void nextTick(reportHit);
|
||||
}
|
||||
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
|
||||
@@ -285,7 +286,11 @@ watch(
|
||||
onMounted(() => {
|
||||
resize();
|
||||
stopViewport = window.island?.onViewport((v) => resizeCanvas(v.width, v.height));
|
||||
observer = new ResizeObserver(measure);
|
||||
observer = new ResizeObserver((entries) => {
|
||||
if (entries.some((entry) => entry.target === document.documentElement)) resize();
|
||||
else measure();
|
||||
});
|
||||
observer.observe(document.documentElement);
|
||||
if (compact.value) observer.observe(compact.value);
|
||||
if (details.value) observer.observe(details.value);
|
||||
if (lessonSource.value) observer.observe(lessonSource.value);
|
||||
@@ -327,6 +332,7 @@ onUnmounted(() => {
|
||||
call: state.active?.kind === 'call',
|
||||
'resident-separated': scheme.separated && !expanded && !lesson,
|
||||
'resident-lesson': !!lesson,
|
||||
'message-surface': expanded && messages.length > 0 && !lesson,
|
||||
}"
|
||||
:style="style"
|
||||
:role="expanded ? 'region' : 'button'"
|
||||
|
||||
@@ -389,6 +389,7 @@ const islandClass = computed(() => ({
|
||||
'hidden-placeholder':
|
||||
mode.value === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder,
|
||||
'ci-lesson-card': !!state.value.lessonReminder,
|
||||
'message-surface': !!state.value.active && mode.value !== 'hidden' && !state.value.lessonReminder,
|
||||
sliver: mode.value === 'hidden' && appearance.value.idleSliver,
|
||||
urgent: isUrgent.value,
|
||||
call: isCall.value,
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
defaultIslandLayouts,
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
type IslandAppearance,
|
||||
type IslandLayoutSettings,
|
||||
type IslandState,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import FixedIsland from './IslandApp.vue';
|
||||
import CustomIsland from './CustomIsland.vue';
|
||||
import './custom-island.css';
|
||||
import CustomIslandRuntime from './CustomIslandRuntime.vue';
|
||||
const settings = ref(defaultIslandLayouts()),
|
||||
editing = ref(false),
|
||||
ready = ref(false);
|
||||
const schedules = ref<ScheduleDto[]>([]);
|
||||
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
const nativeVisible = ref(false);
|
||||
const alertHost = shallowRef<HTMLElement | null>(null);
|
||||
const resident = () => settings.value.layout === 'custom' && !editing.value;
|
||||
const resident = () => settings.value.layout === 'custom';
|
||||
const state = ref<IslandState>({
|
||||
mode: 'hidden',
|
||||
active: null,
|
||||
@@ -30,13 +27,6 @@ const state = ref<IslandState>({
|
||||
maxCardHeight: 1024,
|
||||
updatedAt: 0,
|
||||
});
|
||||
async function save(value: IslandLayoutSettings) {
|
||||
settings.value = await window.island!.saveLayout(JSON.parse(JSON.stringify(value)));
|
||||
}
|
||||
async function done() {
|
||||
editing.value = false;
|
||||
await window.island?.setEditing(false);
|
||||
}
|
||||
let heartbeat: ReturnType<typeof setInterval>;
|
||||
onUnmounted(() => clearInterval(heartbeat));
|
||||
onMounted(async () => {
|
||||
@@ -44,7 +34,6 @@ onMounted(async () => {
|
||||
const bridge = window.island;
|
||||
if (bridge) {
|
||||
bridge.onLayout((value) => (settings.value = value));
|
||||
bridge.onEditing((value) => (editing.value = value));
|
||||
bridge.onSchedules((value) => (schedules.value = value));
|
||||
bridge.onState((value) => (state.value = value));
|
||||
bridge.onAppearance((value) => (appearance.value = value));
|
||||
@@ -54,7 +43,6 @@ onMounted(async () => {
|
||||
settings.value = await bridge.getLayout();
|
||||
schedules.value = await bridge.getSchedules();
|
||||
state.value = await bridge.getState();
|
||||
editing.value = await bridge.getEditing();
|
||||
}
|
||||
await nextTick();
|
||||
ready.value = true;
|
||||
@@ -69,26 +57,5 @@ onMounted(async () => {
|
||||
:appearance="appearance"
|
||||
:native-visible="nativeVisible"
|
||||
/>
|
||||
<CustomIsland
|
||||
v-else-if="settings.layout === 'custom'"
|
||||
:settings="settings"
|
||||
:state="state"
|
||||
:editing="editing"
|
||||
:appearance="appearance"
|
||||
:schedules="schedules"
|
||||
@save="save"
|
||||
@done="done"
|
||||
@host="alertHost = $event"
|
||||
/>
|
||||
<Teleport
|
||||
v-if="ready"
|
||||
:disabled="resident() || settings.layout === 'fixed' || !alertHost"
|
||||
:to="alertHost || 'body'"
|
||||
>
|
||||
<FixedIsland
|
||||
v-show="!resident() && (settings.layout === 'fixed' || !!alertHost)"
|
||||
:embedded="settings.layout === 'custom'"
|
||||
:suspended="resident()"
|
||||
/>
|
||||
</Teleport>
|
||||
<FixedIsland v-if="ready" v-show="!resident()" :suspended="resident()" />
|
||||
</template>
|
||||
@@ -12,17 +12,39 @@ const root = ref<HTMLElement>();
|
||||
const width = ref(480);
|
||||
const mask = computed(() => props.now < props.reminder.maskUntil);
|
||||
const title = computed(
|
||||
() => ({ prepare: '即将上课', 'on-class': '上课', break: '课间休息' })[props.reminder.kind],
|
||||
() =>
|
||||
({
|
||||
prepare: '即将上课',
|
||||
'on-class': '上课',
|
||||
break: '课间休息',
|
||||
ending: '即将下课',
|
||||
'in-class': '正在上课',
|
||||
})[props.reminder.kind],
|
||||
);
|
||||
const leftSeconds = computed(() =>
|
||||
Math.max(0, Math.ceil((props.reminder.lessonStartsAt - props.now) / 1000)),
|
||||
Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
((props.reminder.kind === 'ending' || props.reminder.kind === 'in-class'
|
||||
? props.reminder.lessonEndsAt
|
||||
: props.reminder.lessonStartsAt)! -
|
||||
props.now) /
|
||||
1000,
|
||||
),
|
||||
),
|
||||
);
|
||||
const progress = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(props.reminder.endsAt - props.now) / Math.max(1, props.reminder.endsAt - props.reminder.startedAt),
|
||||
props.reminder.kind === 'in-class' || props.reminder.kind === 'ending'
|
||||
? (props.now - props.reminder.lessonStartsAt) /
|
||||
Math.max(
|
||||
1,
|
||||
(props.reminder.lessonEndsAt ?? props.reminder.endsAt) - props.reminder.lessonStartsAt,
|
||||
)
|
||||
: (props.reminder.endsAt - props.now) / Math.max(1, props.reminder.endsAt - props.reminder.startedAt),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -107,19 +129,36 @@ onUnmounted(() => {
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="ci-lesson-info">
|
||||
<span v-if="reminder.kind === 'prepare'" class="ci-countdown">
|
||||
距上课还剩
|
||||
<span
|
||||
v-if="reminder.kind === 'prepare' || reminder.kind === 'ending' || reminder.kind === 'in-class'"
|
||||
class="ci-countdown"
|
||||
>
|
||||
{{ reminder.kind === 'prepare' ? '距上课还剩' : '距下课还剩' }}
|
||||
<strong>{{ leftSeconds }}</strong>
|
||||
秒
|
||||
</span>
|
||||
<span v-else class="ci-countdown">课间休息</span>
|
||||
<span class="ci-lesson-course">
|
||||
<span>下节课是:</span>
|
||||
<span>
|
||||
{{
|
||||
reminder.kind === 'ending' || reminder.kind === 'on-class' || reminder.kind === 'in-class'
|
||||
? '本节课:'
|
||||
: '下节课是:'
|
||||
}}
|
||||
</span>
|
||||
<strong>{{ course }}</strong>
|
||||
<small>{{ time }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ci-lesson-progress" :style="{ transform: `scaleX(${progress})` }" />
|
||||
<div
|
||||
class="ci-lesson-progress"
|
||||
role="progressbar"
|
||||
:aria-valuenow="Math.round(progress * 100)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-label="课程与提醒进度"
|
||||
:style="{ transform: `scaleX(${progress})` }"
|
||||
/>
|
||||
<svg class="ci-mask" :viewBox="`0 0 ${width} 40`" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polygon v-for="(points, i) in polygons" :key="i" :points="points" />
|
||||
</svg>
|
||||
|
||||
@@ -80,6 +80,10 @@ body {
|
||||
--wn-bg: #0b0b0f;
|
||||
--wn-border: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
/* 阅读老师消息时使用实色底,玻璃风格也不透出桌面。 */
|
||||
.island-card.message-surface {
|
||||
--wn-bg: #202020;
|
||||
}
|
||||
|
||||
.island-card.hidden-placeholder {
|
||||
visibility: hidden;
|
||||
|
||||
@@ -250,3 +250,6 @@
|
||||
fill: transparent;
|
||||
stroke: none;
|
||||
}
|
||||
.resident-window .resident-card.message-surface {
|
||||
--wn-bg: #202020;
|
||||
}
|
||||
@@ -227,6 +227,13 @@ if (!gotLock) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
island.setEditLayoutHandler(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
mainWindow.webContents.send('island:open-editor');
|
||||
});
|
||||
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
|
||||
island.setMarkReadHandler((id) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
@@ -171,7 +171,7 @@ class IslandController {
|
||||
!this.previewId &&
|
||||
!(
|
||||
this.state.active &&
|
||||
(this.state.mode === 'expanded' ||
|
||||
(this.explicitShowId === this.state.active.id ||
|
||||
(this.state.active.priority === 'URGENT' && this.state.mode !== 'hidden'))
|
||||
),
|
||||
);
|
||||
@@ -183,7 +183,13 @@ class IslandController {
|
||||
this.refreshLessonReminder();
|
||||
}
|
||||
private refreshLessonReminder(): void {
|
||||
const next = this.lessonTracker.update(this.schedules, new Date(this.lessonTestTime ?? Date.now()));
|
||||
let next = this.lessonTracker.update(
|
||||
this.schedules,
|
||||
new Date(this.lessonTestTime ?? Date.now()),
|
||||
getConfig().islandLayout.lessonReminders,
|
||||
);
|
||||
// 自定义布局由课程组件持续显示进度,时间点提醒才临时接管岛体。
|
||||
if (this.customLayout && next?.kind === 'in-class') next = null;
|
||||
if (JSON.stringify(next) === JSON.stringify(this.lessonCue)) return;
|
||||
this.lessonCue = next;
|
||||
// 触摸窗口先放得下新横条,再下发内容;混合宽高变化也只能增大画布。
|
||||
@@ -209,6 +215,15 @@ class IslandController {
|
||||
private boundsFrame: number | null = null;
|
||||
/** 外观设置(设置页可调,主进程持久化) */
|
||||
private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE };
|
||||
/** 阅读消息时同时关闭原生窗口透明度,防止实色 CSS 仍被窗口整体淡化。 */
|
||||
private get displayOpacity(): number {
|
||||
const readingMessage =
|
||||
this.state.active &&
|
||||
this.state.mode !== 'hidden' &&
|
||||
(!this.customLayout || this.state.mode === 'expanded') &&
|
||||
!this.lessonShowing;
|
||||
return readingMessage ? 1 : this.appearance.opacity;
|
||||
}
|
||||
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
|
||||
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
|
||||
private blurCollapseEnabled = true;
|
||||
@@ -289,6 +304,7 @@ class IslandController {
|
||||
return this.editingLayout;
|
||||
}
|
||||
syncCustomLayout(): void {
|
||||
this.refreshLessonReminder();
|
||||
if (this.residentEnabled !== this.residentWasEnabled) {
|
||||
this.residentFaded = false;
|
||||
this.residentWasEnabled = this.residentEnabled;
|
||||
@@ -317,42 +333,12 @@ class IslandController {
|
||||
this.syncWindow();
|
||||
}, 320);
|
||||
}
|
||||
private editLayoutHandler: (() => void) | null = null;
|
||||
setEditLayoutHandler(handler: () => void): void {
|
||||
this.editLayoutHandler = handler;
|
||||
}
|
||||
editLayout(enabled = true): void {
|
||||
if (enabled) this.residentFaded = false;
|
||||
if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer);
|
||||
this.layoutSettleTimer = null;
|
||||
this.settlingLayout = false;
|
||||
this.editingLayout = enabled;
|
||||
this.syncResidentShape();
|
||||
if (enabled) saveConfig({ islandLayout: { ...getConfig().islandLayout, layout: 'custom' } });
|
||||
this.win?.webContents.send('island:layout', getConfig().islandLayout);
|
||||
this.win?.webContents.send('island:editing', enabled);
|
||||
if (this.win && !this.win.isDestroyed()) {
|
||||
this.win.hide();
|
||||
if (enabled) {
|
||||
const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea;
|
||||
this.win.setBounds(area);
|
||||
this.win.setOpacity(1);
|
||||
if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual')) {
|
||||
this.win.show();
|
||||
this.win.focus();
|
||||
}
|
||||
this.setInteractive(true, '布局编辑');
|
||||
} else {
|
||||
const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea;
|
||||
this.customSize = {
|
||||
width: Math.max(40, Math.min(this.customDesiredSize.width, area.width - 2 * ISLAND_SHADOW_PAD)),
|
||||
height: Math.max(24, Math.min(this.customDesiredSize.height, area.height - 2 * ISLAND_SHADOW_PAD)),
|
||||
};
|
||||
this.win.webContents.send('island:viewport', this.customSize);
|
||||
this.applyWindowLayout();
|
||||
if (this.customLayout) {
|
||||
// 退出编辑先保持隐藏,等行工具栏移除、渲染器防抖测量和主进程防抖完成。
|
||||
// 避免把编辑器的占位高度带回常态岛;收缩仍全部发生在不可见期间。
|
||||
this.settleCustomLayout();
|
||||
} else this.syncWindow();
|
||||
}
|
||||
}
|
||||
if (enabled) this.editLayoutHandler?.();
|
||||
}
|
||||
reportCustomSize(value: { width: number; height: number }): void {
|
||||
if (
|
||||
@@ -1393,7 +1379,7 @@ class IslandController {
|
||||
if (this.residentEnabled) this.applyFocusable();
|
||||
this.updateResidentPointer();
|
||||
this.win.setOpacity(
|
||||
this.appearance.opacity * (this.residentFaded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1),
|
||||
this.displayOpacity * (this.residentFaded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1),
|
||||
);
|
||||
if (
|
||||
!this.win.isVisible() &&
|
||||
@@ -1408,7 +1394,7 @@ class IslandController {
|
||||
if (this.lessonShowing) {
|
||||
this.cancelFade();
|
||||
if (!this.win.isVisible()) this.applyWindowLayout();
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
this.win.setOpacity(this.displayOpacity);
|
||||
if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual'))
|
||||
this.win.showInactive();
|
||||
this.syncHitFromCursor();
|
||||
@@ -1441,7 +1427,7 @@ class IslandController {
|
||||
this.setInteractive(false, '隐藏态');
|
||||
if (this.appearance.idleSliver) {
|
||||
this.cancelFade();
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
this.win.setOpacity(this.displayOpacity);
|
||||
if (!this.win.isVisible()) this.win.showInactive();
|
||||
// 细缝也要"可见即可点":按真实光标位置决定是否接收鼠标
|
||||
this.syncHitFromCursor();
|
||||
@@ -1454,8 +1440,8 @@ class IslandController {
|
||||
// 岛应该可见:**必须**先取消可能还在跑的淡出 —— 否则那次淡出会继续把透明度降到 0
|
||||
// 并 hide() 掉窗口,造成"状态是胶囊/展开、窗口却被隐藏"的错位(岛看着在、点不动或直接消失)。
|
||||
this.cancelFade();
|
||||
if (this.win.getOpacity() !== this.appearance.opacity) {
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
if (this.win.getOpacity() !== this.displayOpacity) {
|
||||
this.win.setOpacity(this.displayOpacity);
|
||||
}
|
||||
if (!this.win.isVisible()) {
|
||||
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示
|
||||
@@ -1628,7 +1614,7 @@ class IslandController {
|
||||
|
||||
private fadeIn(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const target = this.appearance.opacity;
|
||||
const target = this.displayOpacity;
|
||||
if (!this.appearance.animations) {
|
||||
this.fadeToken += 1;
|
||||
this.win.setOpacity(target);
|
||||
@@ -1721,7 +1707,7 @@ class IslandController {
|
||||
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
|
||||
if (this.win.isVisible()) {
|
||||
// 显示中:立即应用透明度(隐藏时由 fadeIn 负责)
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
this.win.setOpacity(this.displayOpacity);
|
||||
}
|
||||
}
|
||||
this.emitAppearance();
|
||||
@@ -1828,7 +1814,7 @@ class IslandController {
|
||||
!overControl;
|
||||
if (faded === this.residentFaded) return;
|
||||
this.residentFaded = faded;
|
||||
this.win.setOpacity(this.appearance.opacity * (faded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1));
|
||||
this.win.setOpacity(this.displayOpacity * (faded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1));
|
||||
}
|
||||
|
||||
/** 当前岛体矩形(供冒烟验证) */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
dayKeyLocal,
|
||||
resolveClassStatus,
|
||||
DEFAULT_LESSON_REMINDERS,
|
||||
type LessonReminderSettings,
|
||||
type IslandLessonReminder,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
@@ -13,7 +15,11 @@ import {
|
||||
export class LessonReminderTracker {
|
||||
private current: IslandLessonReminder | null = null;
|
||||
|
||||
update(schedules: ScheduleDto[], now: Date): IslandLessonReminder | null {
|
||||
update(
|
||||
schedules: ScheduleDto[],
|
||||
now: Date,
|
||||
settings: LessonReminderSettings = DEFAULT_LESSON_REMINDERS,
|
||||
): IslandLessonReminder | null {
|
||||
const at = now.getTime();
|
||||
const keyOf = (s: ScheduleDto, kind: IslandLessonReminder['kind']) =>
|
||||
`${dayKeyLocal(now)}:${s.id}:${s.startTime}:${s.endTime}:${kind}`;
|
||||
@@ -32,10 +38,11 @@ export class LessonReminderTracker {
|
||||
.sort((a, b) => b.endTime.localeCompare(a.endTime))[0];
|
||||
let kind: IslandLessonReminder['kind'] | null = null;
|
||||
let lesson: ScheduleDto | undefined;
|
||||
if (current && at - timeAt(current.startTime) < 5_000) {
|
||||
if (current && at - timeAt(current.startTime) < 5_000 && settings.onClassEnabled) {
|
||||
kind = 'on-class';
|
||||
lesson = current;
|
||||
} else if (
|
||||
settings.breakEnabled &&
|
||||
!current &&
|
||||
ended &&
|
||||
at - timeAt(ended.endTime) < (next ? 12_000 : 5_000) &&
|
||||
@@ -43,9 +50,24 @@ export class LessonReminderTracker {
|
||||
) {
|
||||
kind = 'break';
|
||||
lesson = ended;
|
||||
} else if (!current && next && timeAt(next.startTime) - at <= 60_000) {
|
||||
} else if (
|
||||
current &&
|
||||
settings.endingEnabled &&
|
||||
timeAt(current.endTime) - at <= settings.endingSeconds * 1000
|
||||
) {
|
||||
kind = 'ending';
|
||||
lesson = current;
|
||||
} else if (
|
||||
!current &&
|
||||
next &&
|
||||
settings.prepareEnabled &&
|
||||
timeAt(next.startTime) - at <= settings.prepareSeconds * 1000
|
||||
) {
|
||||
kind = 'prepare';
|
||||
lesson = next;
|
||||
} else if (current && settings.classProgressEnabled) {
|
||||
kind = 'in-class';
|
||||
lesson = current;
|
||||
}
|
||||
if (!lesson || !kind) return (this.current = null);
|
||||
const id = keyOf(lesson, kind);
|
||||
@@ -60,13 +82,23 @@ export class LessonReminderTracker {
|
||||
endTime: lesson.endTime,
|
||||
startedAt: entered,
|
||||
maskUntil:
|
||||
kind === 'prepare'
|
||||
? Math.min(start, entered + 3000)
|
||||
: kind === 'break' && next
|
||||
? entered + 2000
|
||||
: entered + 5000,
|
||||
endsAt: kind === 'prepare' ? start : kind === 'on-class' ? start + 5000 : end + (next ? 12_000 : 5000),
|
||||
kind === 'ending' || kind === 'in-class'
|
||||
? entered
|
||||
: kind === 'prepare'
|
||||
? Math.min(start, entered + 3000)
|
||||
: kind === 'break' && next
|
||||
? entered + 2000
|
||||
: entered + 5000,
|
||||
endsAt:
|
||||
kind === 'ending' || kind === 'in-class'
|
||||
? end
|
||||
: kind === 'prepare'
|
||||
? start
|
||||
: kind === 'on-class'
|
||||
? start + 5000
|
||||
: end + (next ? 12_000 : 5000),
|
||||
lessonStartsAt: start,
|
||||
lessonEndsAt: end,
|
||||
nextCourseName: kind === 'break' ? next?.course?.name : undefined,
|
||||
nextStartTime: kind === 'break' ? next?.startTime : undefined,
|
||||
nextEndTime: kind === 'break' ? next?.endTime : undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { island } from './island.js';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
@@ -11,6 +12,10 @@ export async function verifyLayoutSmoke(
|
||||
await island.init(null);
|
||||
island.setTestInputPassthrough(true);
|
||||
const win = island.getWindow()!;
|
||||
const editor = BrowserWindow.getAllWindows().find(
|
||||
(w) => w !== win && !w.isDestroyed() && /\/index\.html(?:#|$)/.test(w.webContents.getURL()),
|
||||
)!;
|
||||
let editingBounds = win.getBounds();
|
||||
const pause = () => new Promise<void>((r) => setTimeout(r, 400));
|
||||
try {
|
||||
await pause();
|
||||
@@ -27,27 +32,40 @@ export async function verifyLayoutSmoke(
|
||||
);
|
||||
});
|
||||
await check('自定义编辑模式可进入并添加组件行', async () => {
|
||||
island.editLayout();
|
||||
saveConfig({ islandLayout: { ...defaultIslandLayouts(), layout: 'custom' } });
|
||||
island.syncCustomLayout();
|
||||
await pause();
|
||||
return win.webContents.executeJavaScript(
|
||||
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.includes('在下方新建'));b?.click();return Boolean(document.querySelector('.layout-editor')); })()`,
|
||||
editingBounds = win.getBounds();
|
||||
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(true)');
|
||||
await pause();
|
||||
return (
|
||||
(await editor.webContents.executeJavaScript(
|
||||
'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')); })()`,
|
||||
)
|
||||
);
|
||||
});
|
||||
await check('触摸拖动可跨行并持久化', async () => {
|
||||
await pause();
|
||||
await win.webContents.executeJavaScript(`(() => {
|
||||
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('缺少拖动目标');
|
||||
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()};
|
||||
})()`);
|
||||
await pause();
|
||||
return getConfig().islandLayout.schemes[0]?.rows[1]?.nodes.some((n) => n.kind === 'clock') === true;
|
||||
const moved =
|
||||
getConfig().islandLayout.schemes[0]?.rows[1]?.nodes.some((n) => n.kind === 'clock') === true;
|
||||
if (!moved) throw new Error(JSON.stringify(dragGeometry));
|
||||
return true;
|
||||
});
|
||||
await check('组件可以包裹进容器且设置独立保存', async () => {
|
||||
await win.webContents.executeJavaScript(
|
||||
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})); })()`,
|
||||
);
|
||||
await pause();
|
||||
@@ -58,38 +76,58 @@ export async function verifyLayoutSmoke(
|
||||
);
|
||||
});
|
||||
await check('组件设置按内容外观高级切换,整体外观单独显示', async () => {
|
||||
const initial = await win.webContents.executeJavaScript(`(() => {
|
||||
const initial = await editor.webContents.executeJavaScript(`(() => {
|
||||
const panel=document.querySelector('.settings');
|
||||
return panel?.textContent.includes('显示内容') && !panel.textContent.includes('字体大小') && !panel.textContent.includes('背景颜色');
|
||||
})()`);
|
||||
await win.webContents.executeJavaScript(`document.querySelectorAll('.panel-tabs button')[1].click()`);
|
||||
await editor.webContents.executeJavaScript(
|
||||
`document.querySelectorAll('.panel-tabs button')[1].click()`,
|
||||
);
|
||||
await pause();
|
||||
const appearance = await win.webContents.executeJavaScript(`(() => {
|
||||
const appearance = await editor.webContents.executeJavaScript(`(() => {
|
||||
const panel=document.querySelector('.settings');
|
||||
return panel?.textContent.includes('字体大小') && !panel.textContent.includes('组件操作');
|
||||
})()`);
|
||||
await win.webContents.executeJavaScript(`document.querySelectorAll('.panel-tabs button')[2].click()`);
|
||||
await editor.webContents.executeJavaScript(
|
||||
`document.querySelectorAll('.panel-tabs button')[2].click()`,
|
||||
);
|
||||
await pause();
|
||||
const advanced = await win.webContents.executeJavaScript(`(() => {
|
||||
const advanced = await editor.webContents.executeJavaScript(`(() => {
|
||||
const panel=document.querySelector('.settings');
|
||||
return panel?.textContent.includes('隐藏条件') && !panel.textContent.includes('字体大小');
|
||||
})()`);
|
||||
await win.webContents.executeJavaScript(`document.querySelectorAll('.panel-tabs button')[0].click()`);
|
||||
await editor.webContents.executeJavaScript(
|
||||
`document.querySelectorAll('.panel-tabs button')[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);
|
||||
})()`);
|
||||
});
|
||||
if (process.env.ISLAND_SHOTS_DIR) {
|
||||
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-editor.png'),
|
||||
(await win.webContents.capturePage()).toPNG(),
|
||||
(await editor.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
}
|
||||
await check('退出编辑后配置可从 getConfig 读回', async () => {
|
||||
island.editLayout(false);
|
||||
await editor.webContents.executeJavaScript('document.querySelector(".done-button")?.click()');
|
||||
await pause();
|
||||
return !island.getEditingLayout() && getConfig().islandLayout.schemes[0]!.rows.length === 2;
|
||||
return (
|
||||
!island.getEditingLayout() &&
|
||||
getConfig().islandLayout.schemes[0]!.rows.length === 2 &&
|
||||
!editor.webContents.getURL().includes('/editor') &&
|
||||
win.getBounds().width === editingBounds.width
|
||||
);
|
||||
});
|
||||
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(false)');
|
||||
await check('自定义岛统一使用固定画布且常态组件居中适配', async () => {
|
||||
await pause();
|
||||
return win.webContents.executeJavaScript(`(() => {
|
||||
@@ -145,6 +183,8 @@ export async function verifyLayoutSmoke(
|
||||
});
|
||||
await verifyResidentSmoke(check);
|
||||
} finally {
|
||||
if (editor && !editor.isDestroyed())
|
||||
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(false)');
|
||||
saveConfig({ islandLayout: original });
|
||||
island.syncCustomLayout();
|
||||
win.hide();
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function verifyResidentSmoke(
|
||||
changed?.courseName === '临时换课' &&
|
||||
changed.maskUntil === prepare.maskUntil &&
|
||||
onClass?.kind === 'on-class' &&
|
||||
!noLate &&
|
||||
noLate?.kind === 'in-class' &&
|
||||
breaking?.kind === 'break' &&
|
||||
breaking.nextCourseName === '语文' &&
|
||||
!after &&
|
||||
@@ -119,6 +119,32 @@ export async function verifyResidentSmoke(
|
||||
!removed
|
||||
);
|
||||
});
|
||||
await check('即将下课倒数边界与四个提醒通道独立生效,旧配置自动补默认值', () => {
|
||||
const settings = { ...defaultIslandLayouts().lessonReminders, classProgressEnabled: false };
|
||||
const tracker = new LessonReminderTracker();
|
||||
const before = tracker.update(lessonFixtures, dateAt(9, 43, 59), settings);
|
||||
const ending = tracker.update(lessonFixtures, dateAt(9, 44), settings);
|
||||
const disabled = tracker.update(lessonFixtures, dateAt(9, 44, 1), { ...settings, endingEnabled: false });
|
||||
const prepareDisabled = tracker.update(lessonFixtures, dateAt(8, 59), {
|
||||
...settings,
|
||||
prepareEnabled: false,
|
||||
});
|
||||
const startDisabled = tracker.update(lessonFixtures, dateAt(9, 0), {
|
||||
...settings,
|
||||
onClassEnabled: false,
|
||||
});
|
||||
const breakDisabled = tracker.update(lessonFixtures, dateAt(9, 45), { ...settings, breakEnabled: false });
|
||||
return (
|
||||
!before &&
|
||||
ending?.kind === 'ending' &&
|
||||
ending.endsAt === dateAt(9, 45).getTime() &&
|
||||
!disabled &&
|
||||
!prepareDisabled &&
|
||||
!startDisabled &&
|
||||
!breakDisabled &&
|
||||
normalizeIslandLayouts({ layout: 'custom' }).lessonReminders.endingEnabled
|
||||
);
|
||||
});
|
||||
scheme.rows[0]!.nodes = [
|
||||
islandNode('clock', 'resident-clock'),
|
||||
islandNode('separator', 'resident-separator'),
|
||||
@@ -557,7 +583,59 @@ export async function verifyResidentSmoke(
|
||||
}
|
||||
await setTime(9, 0, 6);
|
||||
await pause(700);
|
||||
if (island.getState().lessonReminder) return false;
|
||||
if (
|
||||
layout === 'custom'
|
||||
? island.getState().lessonReminder
|
||||
: island.getState().lessonReminder?.kind !== 'in-class'
|
||||
)
|
||||
return false;
|
||||
await setTime(9, 22, 30);
|
||||
if (layout === 'custom') {
|
||||
const progressLayout = JSON.parse(JSON.stringify(fixture));
|
||||
progressLayout.schemes[0].rows[0].nodes = [islandNode('schedule', 'lesson-progress-course')];
|
||||
saveConfig({ islandLayout: progressLayout });
|
||||
island.syncCustomLayout();
|
||||
}
|
||||
await pause(1100);
|
||||
const half = await win.webContents.executeJavaScript(`(() => {
|
||||
const root=document.querySelector('${layout === 'custom' ? '.resident-compact' : '.ci-fixed-lesson'}');
|
||||
return Boolean(root&&root.textContent.includes('数学')&&root.textContent.includes('距下课')&&Number(root.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow'))===50);
|
||||
})()`);
|
||||
if (!half)
|
||||
throw new Error(
|
||||
layout +
|
||||
'课中进度错误:' +
|
||||
(await win.webContents.executeJavaScript(
|
||||
'document.querySelector(".resident-compact, .ci-fixed-lesson")?.textContent',
|
||||
)),
|
||||
);
|
||||
await shot('lesson-progress-' + layout);
|
||||
if (layout === 'custom') {
|
||||
const countdownLayout = JSON.parse(JSON.stringify(fixture));
|
||||
countdownLayout.schemes[0].rows[0].nodes = [islandNode('countdown', 'lesson-progress-countdown')];
|
||||
saveConfig({ islandLayout: countdownLayout });
|
||||
island.syncCustomLayout();
|
||||
await pause(1100);
|
||||
if (
|
||||
!(await win.webContents.executeJavaScript(
|
||||
`document.querySelector('.resident-compact .countdown')?.textContent.includes('距下课 22:30')`,
|
||||
))
|
||||
)
|
||||
throw new Error('自动倒计时未切到本节下课');
|
||||
}
|
||||
if (layout === 'custom') {
|
||||
saveConfig({ islandLayout: { ...fixture, layout } });
|
||||
island.syncCustomLayout();
|
||||
}
|
||||
await setTime(9, 44);
|
||||
await pause(700);
|
||||
if (island.getState().lessonReminder?.kind !== 'ending')
|
||||
throw new Error(layout + '下课倒数状态错误:' + JSON.stringify(island.getState().lessonReminder));
|
||||
const ending = await win.webContents.executeJavaScript(
|
||||
`document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'}')?.textContent.includes('距下课还剩')`,
|
||||
);
|
||||
if (!ending) throw new Error(layout + '下课倒数未渲染');
|
||||
await shot('lesson-ending-' + layout);
|
||||
await setTime(9, 45);
|
||||
island.setClassState({ inClass: false });
|
||||
await pause(700);
|
||||
@@ -671,36 +749,60 @@ export async function verifyResidentSmoke(
|
||||
island.setTouchMode(false);
|
||||
}
|
||||
});
|
||||
await check('常驻和无通知展开内容可独立编辑并持久化', async () => {
|
||||
await check('编辑入口交给主窗口,浮岛不会变成额外的全屏编辑窗口', async () => {
|
||||
const before = win.getBounds();
|
||||
let opened = false;
|
||||
island.setEditLayoutHandler(() => {
|
||||
opened = true;
|
||||
});
|
||||
island.editLayout();
|
||||
await pause();
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => {const button=[...document.querySelectorAll('.surface-tabs button')].find(b=>b.textContent.includes('无通知'));button?.click();})()`,
|
||||
);
|
||||
await pause(200);
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.querySelector('[data-node="resident-expanded-text"]')?.click()`,
|
||||
);
|
||||
await pause(200);
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => {const input=document.querySelector('.settings textarea');if(!input)throw Error('未找到展开文本设置');input.value='放学前检查';input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));})()`,
|
||||
);
|
||||
await pause();
|
||||
const saved = getConfig().islandLayout.schemes[0]!;
|
||||
const independent =
|
||||
saved.rows[0]!.nodes.find((n) => n.id === 'resident-compact-text')?.settings.text === '常驻概览' &&
|
||||
saved.expandedRows[0]!.nodes.find((n) => n.id === 'resident-expanded-text')?.settings.text ===
|
||||
'放学前检查';
|
||||
await shot('resident-editor');
|
||||
island.editLayout(false);
|
||||
await pause();
|
||||
const roundtrip = normalizeIslandLayouts(JSON.parse(JSON.stringify(getConfig().islandLayout)));
|
||||
await pause(100);
|
||||
return (
|
||||
independent &&
|
||||
roundtrip.residentEnabled &&
|
||||
roundtrip.schemes[0]!.expandedRows[0]!.nodes.some((n) => n.settings.text === '放学前检查')
|
||||
opened &&
|
||||
!island.getEditingLayout() &&
|
||||
JSON.stringify(before) === JSON.stringify(win.getBounds()) &&
|
||||
!(await win.webContents.executeJavaScript('Boolean(document.querySelector(".layout-editor"))'))
|
||||
);
|
||||
});
|
||||
await check('通知、作业、叫人均使用不透明背景和原生窗口,收起后恢复常驻透明度', async () => {
|
||||
const appearance = island.getAppearance();
|
||||
try {
|
||||
island.setAppearance({ style: 'glass', opacity: 0.45, animations: false });
|
||||
island.setClassState({ inClass: false, schedules: [] });
|
||||
for (const layout of ['custom', 'fixed'] as const) {
|
||||
saveConfig({ islandLayout: { ...fixture, layout } });
|
||||
island.syncCustomLayout();
|
||||
await pause(400);
|
||||
for (const kind of ['notification', 'homework', 'call'] as const) {
|
||||
island.handleAction({ action: 'dismiss-all' });
|
||||
island.pushNotification({
|
||||
...message('opaque-' + layout + '-' + kind, '老师发来的消息', 'NORMAL'),
|
||||
kind,
|
||||
});
|
||||
island.handleAction({ action: 'expand' });
|
||||
await pause(300);
|
||||
const solid = await win.webContents.executeJavaScript(`(() => {
|
||||
const card=document.querySelector('${layout === 'custom' ? '.resident-card' : '.island-card'}');
|
||||
if (!card?.classList.contains('message-surface')) return false;
|
||||
const probe=document.createElement('span');probe.style.background='var(--wn-bg)';card.append(probe);
|
||||
const color=getComputedStyle(probe).backgroundColor;probe.remove();
|
||||
return color==='rgb(32, 32, 32)'&&!!card.querySelector('.classic-notifications');
|
||||
})()`);
|
||||
if (!solid || Math.abs(win.getOpacity() - 1) > 0.01 || island.getAppearance().opacity !== 0.45)
|
||||
return false;
|
||||
if (kind === 'notification') await shot('message-opaque-' + layout);
|
||||
}
|
||||
island.handleAction({ action: 'dismiss-all' });
|
||||
await pause(300);
|
||||
if (layout === 'custom' && Math.abs(win.getOpacity() - 0.45) > 0.02) return false;
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
island.setAppearance(appearance);
|
||||
saveConfig({ islandLayout: fixture });
|
||||
island.syncCustomLayout();
|
||||
}
|
||||
});
|
||||
await check('关闭常驻并选择经典岛后恢复原有隐藏方式', async () => {
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
saveConfig({ islandLayout: defaultIslandLayouts() });
|
||||
|
||||
@@ -2161,7 +2161,7 @@ async function runIslandChecks(
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const collapseResidue = { shots: 0, max: 0 };
|
||||
const collapseResidue = { shots: 0, stableShots: 0, max: 0 };
|
||||
await islandWindow?.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const button = document.querySelector('.island-card.expanded .icon-btn');
|
||||
@@ -2170,23 +2170,46 @@ async function runIslandChecks(
|
||||
})()`,
|
||||
);
|
||||
for (let attempt = 0; attempt < 22; attempt += 1) {
|
||||
const before = await readIslandGeometryFrom(islandWindow!);
|
||||
const image = await grabScreen();
|
||||
const geometry = await readIslandGeometryFrom(islandWindow!);
|
||||
if (image && geometry) {
|
||||
if (image && geometry && before) {
|
||||
collapseResidue.shots += 1;
|
||||
// desktopCapturer 返回采样期间的帧;后读 DOM 已可能收拢,不能把合法的上一动画帧算作残影。
|
||||
// 动画期间排除前后矩形的并集;仍要求至少四张稳态帧,稳态只排除当前岛体。
|
||||
const left = Math.min(before.island.x, geometry.island.x);
|
||||
const top = Math.min(before.island.y, geometry.island.y);
|
||||
const right = Math.max(
|
||||
before.island.x + before.island.width,
|
||||
geometry.island.x + geometry.island.width,
|
||||
);
|
||||
const bottom = Math.max(
|
||||
before.island.y + before.island.height,
|
||||
geometry.island.y + geometry.island.height,
|
||||
);
|
||||
if (
|
||||
Math.abs(before.island.width - geometry.island.width) < 1 &&
|
||||
Math.abs(before.island.height - geometry.island.height) < 1
|
||||
)
|
||||
collapseResidue.stableShots += 1;
|
||||
const bitmap = image.toBitmap();
|
||||
const shotScale = image.getSize().width / Math.max(1, displayForCollapse.size.width);
|
||||
collapseResidue.max = Math.max(
|
||||
collapseResidue.max,
|
||||
ringResidue(bitmap, shotScale, geometry.island, geometry.window),
|
||||
ringResidue(
|
||||
bitmap,
|
||||
shotScale,
|
||||
{ x: left, y: top, width: right - left, height: bottom - top },
|
||||
geometry.window,
|
||||
),
|
||||
);
|
||||
}
|
||||
await sleep(30);
|
||||
}
|
||||
record(
|
||||
'触摸模式收起:窗口带内不出现残影(垫底板逐帧检查,窗口让出的区域不留上一帧)',
|
||||
collapseResidue.shots >= 4 && collapseResidue.max <= 400,
|
||||
`${collapseResidue.shots} 帧采样,窗口带内岛像素峰值=${collapseResidue.max}(该带本该全是底板色)`,
|
||||
collapseResidue.stableShots >= 4 && collapseResidue.max <= 400,
|
||||
`${collapseResidue.shots} 帧采样(稳态 ${collapseResidue.stableShots} 帧),窗口带内岛像素峰值=${collapseResidue.max}(该带本该全是底板色)`,
|
||||
);
|
||||
island.setTouchMode(false);
|
||||
await sleep(200);
|
||||
@@ -3269,8 +3292,8 @@ async function runIslandChecks(
|
||||
await sleep(400);
|
||||
const opacityActual = islandWindow?.getOpacity() ?? -1;
|
||||
record(
|
||||
'个性设置:透明度生效(窗口 setOpacity)',
|
||||
Math.abs(opacityActual - 0.62) < 0.03,
|
||||
'消息展示时保持不透明,同时保留用户透明度设置',
|
||||
Math.abs(opacityActual - 1) < 0.03 && island.getAppearance().opacity === 0.62,
|
||||
`设置=0.62 实际=${opacityActual.toFixed(2)}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
|
||||
*/
|
||||
const bridge: DesktopBridge = {
|
||||
islandEditLayout: () => ipcRenderer.invoke('island:edit-layout'),
|
||||
onIslandEditorOpen: (handler) => {
|
||||
const listener = () => handler();
|
||||
ipcRenderer.on('island:open-editor', listener);
|
||||
return () => ipcRenderer.removeListener('island:open-editor', listener);
|
||||
},
|
||||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||||
// 冒烟凭据:只在冒烟模式下透传,供渲染进程的冒烟钩子构造测试数据用。
|
||||
// **不走代码字面量**——本文件与渲染产物都会进 app.asar,写死账号等于随安装包把教师口令发出去。
|
||||
|
||||
@@ -1303,9 +1303,48 @@ export async function islandScheduleSyncSelfTest(): Promise<SmokeCheckResult> {
|
||||
}
|
||||
}
|
||||
|
||||
let restoreIslandEditorSmoke: (() => Promise<void>) | undefined;
|
||||
/** 主窗口内编辑器验收使用纯本地 API 替身,结束后恢复登录态、路由与方法。 */
|
||||
async function islandEditorSmoke(open: boolean): Promise<void> {
|
||||
if (!open) {
|
||||
await restoreIslandEditorSmoke?.();
|
||||
restoreIslandEditorSmoke = undefined;
|
||||
return;
|
||||
}
|
||||
const [{ api }, { useAuthStore }, { router }] = await Promise.all([
|
||||
import('../api/http.js'),
|
||||
import('../stores/auth.js'),
|
||||
import('../router/index.js'),
|
||||
]);
|
||||
const auth = useAuthStore(),
|
||||
token = auth.token,
|
||||
path = router.currentRoute.value.fullPath,
|
||||
originalGet = api.get;
|
||||
const onboardingDone = (await window.desktop!.getConfig()).onboardingDone;
|
||||
await window.desktop!.saveConfig({ onboardingDone: true });
|
||||
api.get = (async (url: string) => {
|
||||
if (url.includes('unread-count')) return { count: 0 };
|
||||
if (url.includes('active-week')) return { days: [] };
|
||||
if (url.includes('term')) return { currentWeek: 1, maxWeek: 40 };
|
||||
return [];
|
||||
}) as typeof api.get;
|
||||
auth.applyToken('local-editor-smoke');
|
||||
const { useOnboardingStore } = await import('../stores/onboarding.js');
|
||||
useOnboardingStore().close();
|
||||
restoreIslandEditorSmoke = async () => {
|
||||
await router.push('/login');
|
||||
auth.applyToken(token);
|
||||
await router.push(path);
|
||||
api.get = originalGet;
|
||||
await window.desktop!.saveConfig({ onboardingDone });
|
||||
};
|
||||
await router.push('/settings/island/editor');
|
||||
}
|
||||
|
||||
/** 注册到 window,供主进程冒烟脚本调用 */
|
||||
export function registerSmokeHooks(): void {
|
||||
window.__classhelperSmoke__ = {
|
||||
islandEditorSmoke,
|
||||
islandScheduleSyncSelfTest,
|
||||
cacheSelfTest,
|
||||
cacheIsolationSelfTest,
|
||||
|
||||
@@ -20,6 +20,10 @@ const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
const ui = useUiStore();
|
||||
const stopEditorNavigation = window.desktop?.onIslandEditorOpen(
|
||||
() => void router.push('/settings/island/editor'),
|
||||
);
|
||||
onUnmounted(() => stopEditorNavigation?.());
|
||||
|
||||
/**
|
||||
* 主侧边栏。
|
||||
|
||||
@@ -65,6 +65,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/settings/IslandSettingsView.vue'),
|
||||
meta: { title: '灵动岛' },
|
||||
},
|
||||
{
|
||||
path: 'settings/island/editor',
|
||||
name: 'settings-island-editor',
|
||||
component: () => import('../views/settings/IslandEditorView.vue'),
|
||||
meta: { title: '自定义灵动岛' },
|
||||
},
|
||||
{
|
||||
path: 'settings/reminder',
|
||||
name: 'settings-reminder',
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
defaultIslandLayouts,
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
type IslandState,
|
||||
type IslandAppearance,
|
||||
} from '@classhelper/shared';
|
||||
import CustomIsland from '../../../island/CustomIsland.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>({
|
||||
mode: 'hidden',
|
||||
active: null,
|
||||
queued: [],
|
||||
inClass: false,
|
||||
currentPeriodEnd: null,
|
||||
reason: null,
|
||||
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)) });
|
||||
}
|
||||
ready.value = true;
|
||||
refresh = setInterval(() => {
|
||||
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 = '保存失败,请重试';
|
||||
});
|
||||
}
|
||||
async function done() {
|
||||
await saving;
|
||||
if (!error.value) await router.push('/settings/island');
|
||||
}
|
||||
</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>
|
||||
</template>
|
||||
<style>
|
||||
.island-editor-page {
|
||||
height: 100%;
|
||||
min-height: 430px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.island-editor-page .layout-editor {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
flex: 1;
|
||||
min-height: 430px;
|
||||
width: 100%;
|
||||
border-radius: 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;
|
||||
}
|
||||
.island-editor-page .layout-editor .library,
|
||||
.island-editor-page .layout-editor .settings {
|
||||
min-height: 0;
|
||||
padding: 14px 12px;
|
||||
}
|
||||
.island-editor-page .layout-editor .editor-header {
|
||||
padding: 10px 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
.island-editor-page .layout-editor .editor-title p,
|
||||
.island-editor-page .layout-editor .save-state {
|
||||
display: none;
|
||||
}
|
||||
.island-editor-page .layout-editor .scheme-picker {
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.island-editor-page .layout-editor {
|
||||
grid-template-columns: 130px minmax(140px, 1fr) 220px;
|
||||
}
|
||||
.island-editor-page .layout-editor .component-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.island-editor-page .layout-editor .scheme-picker > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { confirmAction } from '../../composables/confirm.js';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||
@@ -23,9 +24,10 @@ import {
|
||||
*/
|
||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
const savingIsland = ref(false);
|
||||
const router = useRouter();
|
||||
function editLayout() {
|
||||
layoutMode.value = 'custom';
|
||||
void window.desktop?.islandEditLayout();
|
||||
void router.push('/settings/island/editor');
|
||||
}
|
||||
const layoutMode = ref<'fixed' | 'custom'>('fixed');
|
||||
const residentEnabled = ref(false);
|
||||
@@ -161,7 +163,7 @@ onMounted(async () => {
|
||||
@click="chooseLayout('fixed')"
|
||||
>
|
||||
<strong>经典灵动岛</strong>
|
||||
<span>有消息时展开,保持熟悉的提醒样式</span>
|
||||
<span>显示课程进度、上下课提醒与班级消息</span>
|
||||
</button>
|
||||
<button
|
||||
class="layout-choice"
|
||||
@@ -208,7 +210,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
<p class="text-muted">紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。</p>
|
||||
<p class="text-muted">
|
||||
上课前 60 秒显示准备提醒和倒计时,上下课时提示课程状态;课程时间显示在名称右侧。
|
||||
课前提示与倒计时、课中进度、即将下课倒数及上下课提醒,可在「提醒」页分别设置。课程时间显示在名称右侧。
|
||||
</p>
|
||||
<el-form label-width="110px" label-position="left">
|
||||
<el-form-item :label="`${layoutMode === 'custom' ? '通知高度' : '高度'} ${island.height}px`">
|
||||
|
||||
@@ -2,7 +2,26 @@
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { classGradeQueryApi } from '../../api/index.js';
|
||||
import { useAuthStore } from '../../stores/auth.js';
|
||||
import { DEFAULT_LESSON_REMINDERS, type LessonReminderSettings } from '@classhelper/shared';
|
||||
import { ElMessage } from 'element-plus';
|
||||
const auth = useAuthStore();
|
||||
const lesson = ref({ ...DEFAULT_LESSON_REMINDERS });
|
||||
onMounted(async () => {
|
||||
const config = await window.desktop?.getConfig();
|
||||
if (config) lesson.value = { ...config.islandLayout.lessonReminders };
|
||||
});
|
||||
async function saveLesson(patch: Partial<LessonReminderSettings>) {
|
||||
lesson.value = { ...lesson.value, ...patch };
|
||||
const config = await window.desktop?.getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
await window.desktop?.saveConfig({
|
||||
islandLayout: { ...config.islandLayout, lessonReminders: { ...lesson.value } },
|
||||
});
|
||||
} catch {
|
||||
ElMessage.error('提醒设置保存失败');
|
||||
}
|
||||
}
|
||||
/* ------------------------------------------------------------ 班级端成绩查询状态(只读)
|
||||
*
|
||||
* 「是否允许班级端查看成绩」是**服务端口径**:管理员在 Web 端「班级管理」里开关,
|
||||
@@ -34,7 +53,51 @@ onMounted(loadGradeQuery);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-card shadow="never" class="lesson-channel-settings">
|
||||
<template #header>上下课提醒</template>
|
||||
<p class="text-muted">
|
||||
沿用 ClassIsland 的遮罩、课前倒计时、上下课提示及下节课预告;课中显示课程进度,临近下课显示倒数。
|
||||
</p>
|
||||
<div
|
||||
v-for="channel in [
|
||||
{ key: 'prepareEnabled', title: '准备上课提醒' },
|
||||
{ key: 'onClassEnabled', title: '上课提醒' },
|
||||
{ key: 'endingEnabled', title: '即将下课倒数' },
|
||||
{ key: 'classProgressEnabled', title: '经典岛课中常显课程进度' },
|
||||
{ key: 'breakEnabled', title: '下课提醒与下节课预告' },
|
||||
] as const"
|
||||
:key="channel.key"
|
||||
class="lesson-setting-row"
|
||||
>
|
||||
<span>{{ channel.title }}</span>
|
||||
<el-switch
|
||||
:model-value="lesson[channel.key]"
|
||||
:aria-label="channel.title"
|
||||
@change="(v: boolean | string | number) => saveLesson({ [channel.key]: v === true })"
|
||||
/>
|
||||
</div>
|
||||
<div class="lesson-setting-row">
|
||||
<span>课前提前(秒)</span>
|
||||
<el-input-number
|
||||
:model-value="lesson.prepareSeconds"
|
||||
:min="5"
|
||||
:max="1800"
|
||||
:step="5"
|
||||
@change="(v: number | undefined) => saveLesson({ prepareSeconds: v ?? 60 })"
|
||||
/>
|
||||
</div>
|
||||
<div class="lesson-setting-row">
|
||||
<span>下课倒数提前(秒)</span>
|
||||
<el-input-number
|
||||
:model-value="lesson.endingSeconds"
|
||||
:min="5"
|
||||
:max="600"
|
||||
:step="5"
|
||||
@change="(v: number | undefined) => saveLesson({ endingSeconds: v ?? 60 })"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="mt-16">
|
||||
<template #header>通知提醒</template>
|
||||
<p class="text-muted">
|
||||
通知与叫人通过 ClassHelper 客户端和灵动岛提醒。普通通知在上课期间暂存,下课后补弹;紧急通知立即提醒。
|
||||
@@ -62,6 +125,17 @@ onMounted(loadGradeQuery);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lesson-setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--ch-border);
|
||||
}
|
||||
.lesson-setting-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.channel-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface IslandClassStatePayload {
|
||||
|
||||
export interface DesktopBridge {
|
||||
islandEditLayout(): Promise<void>;
|
||||
onIslandEditorOpen(handler: () => void): () => void;
|
||||
getUpdateProgress(): Promise<ContractReleaseProgressDto>;
|
||||
onUpdateProgress(handler: (progress: ContractReleaseProgressDto) => void): () => void;
|
||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
||||
@@ -253,6 +254,7 @@ declare global {
|
||||
island?: IslandRendererBridge;
|
||||
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
|
||||
__classhelperSmoke__?: {
|
||||
islandEditorSmoke(open: boolean): Promise<void>;
|
||||
islandScheduleSyncSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
cacheIsolationSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
|
||||
Reference in new issue
Block a user