feat: 重做灵动岛编辑器与课表档案时间表编辑

灵动岛
- 重构自定义方案编辑器:组件库常驻、拖拽添加与排序、方案切换/新建/复制/删除、编辑模式预览与缩放,容器在编辑时展开全部子组件。
- 新增天气简报组件:主进程固定请求 Open-Meteo,只接受数值经纬度,失败退避并保留本次运行缓存;冒烟模式不访问外网。
- 倒计时支持目标名称与日期;课程表组件新增「今日时间点」,按时间点类型折叠/展开,课中不再常驻倒计时。
- preload 暴露 getIslandWeather 与时间点订阅,契约同步 desktop.d.ts。

课表档案
- 时间点默认课程:布局返回 defaultCourse,日课表没有条目时回退;新增 apply-default 覆盖所有引用课表;全校时间表不携带默认课程。
- 新增 ClassIsland 式时间表编辑器(列表/时间轴视图、拖动把柄、按类型添加、默认时长与缩放)与「课表信息」表单。
- 课表页默认进入单表编辑,底部保留列表/周/月视图;时间表支持新路由删除。
- 「课间」统一改称「课间休息」。

验证
- pnpm typecheck、pnpm lint 通过。
- verify:e2e 隔离验收 248/248 项通过(含默认课程回退、覆盖写入与时间表删除)。
- ui-smoke 覆盖单表编辑、课表信息与时间轴视图,并更新回归截图。
This commit is contained in:
LoveChengke committed 2026-10-25 16:41:29 +08:00
1 parent 938285bb9c
commit 682818dd4a
41 files changed
+4349 -1095

No files matched your search

@@ -3,10 +3,12 @@ import type { IslandComponentKind } from '@classhelper/shared';
defineProps<{ kind: IslandComponentKind }>();
const paths: Record<IslandComponentKind, string> = {
clock: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM12 7v5l3 2',
weather:
'M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5',
date: 'M6 5h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM8 3v4m8-4v4M4 10h16M8 14h2m4 0h2m-8 3h2',
schedule: 'M9 5h11M9 12h11M9 19h11M4 5h1m-1 7h1m-1 7h1',
countdown: 'M6 3h12M6 21h12M8 3v4l4 5-4 5v4m8-18v4l-4 5 4 5v4',
text: 'M5 6V4h14v2M12 4v16m-4 0h8',
text: 'M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm1 5h10M7 12h7m-7 4h10',
separator: 'M12 4v16M5 8v8m14-8v8',
notification: 'M9 19a3 3 0 0 0 6 0M6 10a6 6 0 0 1 12 0v4l2 3H4l2-3v-4Z',
homework: 'M9 4H6a2 2 0 0 0-2 2v14h16V6a2 2 0 0 0-2-2h-3M9 3h6v4H9V3Zm-1 9h8m-8 4h5',
@@ -5,13 +5,16 @@ import {
resolveClassStatus,
type IslandComponentNode,
type IslandState,
type IslandTimePointDto,
type ScheduleDto,
} from '@classhelper/shared';
import WeatherBrief from './WeatherBrief.vue';
const props = defineProps<{
node: IslandComponentNode;
state: IslandState;
now: number;
schedules: ScheduleDto[];
timePoints: IslandTimePointDto[];
editing: boolean;
selectedId: string;
alertNodeId: string;
@@ -38,20 +41,69 @@ function timeAt(time: string) {
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 dayPoints = computed<IslandTimePointDto[]>(() => {
const source = props.timePoints.length
? props.timePoints
: today.value.map<IslandTimePointDto>((s) => ({
id: s.id,
timeType: 'LESSON',
name: '',
courseName: s.course?.name ?? null,
startTime: s.startTime,
endTime: s.endTime,
isHideDefault: false,
}));
return [...source].sort((a, b) => a.startTime.localeCompare(b.startTime));
});
const currentPoint = computed(
() =>
dayPoints.value.find(
(point) =>
point.timeType !== 'DIVIDER' &&
point.timeType !== 'ACTION' &&
props.now >= timeAt(point.startTime) &&
props.now < timeAt(point.endTime),
) ?? null,
);
const currentLesson = computed(() => (currentPoint.value?.timeType === 'LESSON' ? currentPoint.value : null));
/** 课程表组件按 ClassIsland 主界面的折叠/展开规则挑选时间点。 */
const schedulePoints = computed<IslandTimePointDto[]>(() => {
const display = props.node.settings.display;
if (display === 'next')
return dayPoints.value
.filter((point) => point.timeType === 'LESSON' && timeAt(point.startTime) > props.now)
.slice(0, 1);
if (display === 'current') {
if (currentPoint.value) return [currentPoint.value];
return dayPoints.value
.filter((point) => point.timeType === 'LESSON' && timeAt(point.startTime) > props.now)
.slice(0, 1);
}
const visible = dayPoints.value.filter(
(point) =>
point.timeType === 'DIVIDER' ||
point.id === currentPoint.value?.id ||
(point.timeType === 'LESSON' && !point.isHideDefault),
);
const count = Math.max(1, props.node.settings.count);
if (visible.length <= count) return visible;
const index = visible.findIndex((point) => point.id === currentPoint.value?.id);
const start = Math.max(0, Math.min(index - Math.floor(count / 2), visible.length - count));
return visible.slice(start, start + count);
});
const elapsed = computed(() => {
const point = currentLesson.value;
if (!point) return 0;
const start = timeAt(point.startTime);
const end = timeAt(point.endTime);
return Math.max(0, Math.min(1, (props.now - start) / Math.max(1, end - start)));
});
const remaining = computed(() =>
current.value ? Math.max(0, Math.ceil((timeAt(current.value.endTime) - props.now) / 1000)) : 0,
currentLesson.value ? Math.max(0, Math.ceil((timeAt(currentLesson.value.endTime) - props.now) / 1000)) : 0,
);
const hidden = computed(
() =>
@@ -62,11 +114,11 @@ const hidden = computed(
(r) =>
(r === 'in-class' && props.state.inClass) ||
(r === 'no-message' && !props.state.active) ||
(r === 'no-class' && !current.value),
(r === 'no-class' && !currentLesson.value),
)),
);
const visibleChildren = computed(() =>
props.node.kind === 'slide'
props.node.kind === 'slide' && !props.editing
? props.node.children.filter(
(_, i) =>
i ===
@@ -95,6 +147,18 @@ const content = computed(() => {
.replaceAll('{course}', current.value?.course?.name || '暂无课程')
: n.settings.text;
if (n.kind === 'countdown') {
if (n.settings.target === 'date') {
if (!n.settings.targetDate) return '请设置目标日期';
const [year, month, day] = n.settings.targetDate.split('-').map(Number);
const days = Math.round(
(Date.UTC(year!, month! - 1, day!) - Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())) / 86400000,
);
return days > 0
? `距${n.settings.countdownTitle} ${days} 天`
: days === 0
? `${n.settings.countdownTitle}就在今天`
: `${n.settings.countdownTitle}已过 ${-days} 天`;
}
const ending = n.settings.target === 'end' || (n.settings.target === 'auto' && !!current.value);
const time =
n.settings.target === 'school'
@@ -111,13 +175,6 @@ const content = computed(() => {
}
return '';
});
const lessons = computed(() =>
props.node.settings.display === 'today'
? 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>(() => {
const n = props.node,
a = n.appearance,
@@ -177,6 +234,7 @@ const style = computed<CSSProperties>(() => {
:state="state"
:now="now"
:schedules="schedules"
:time-points="timePoints"
:editing="editing"
:selected-id="selectedId"
:alert-node-id="alertNodeId"
@@ -188,26 +246,33 @@ const style = computed<CSSProperties>(() => {
</ComponentNode>
</template>
<template v-else-if="node.kind === 'separator'"><span aria-hidden="true">│</span></template>
<WeatherBrief v-else-if="node.kind === 'weather'" :settings="node.settings" />
<template v-else-if="node.kind === 'schedule'">
<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 v-for="p in schedulePoints" :key="p.id">
<span v-if="p.timeType === 'DIVIDER'" class="time-divider" aria-hidden="true">│</span>
<div
v-else
class="lesson-line"
:class="{ current: p.id === currentPoint?.id, break: p.timeType === 'BREAK' }"
>
<strong>{{ p.courseName || p.name || '—' }}</strong>
<small v-if="p.id === currentPoint?.id && node.settings.showPeriod" class="lesson-time">
{{ p.startTime }}–{{ p.endTime }}
</small>
<span
v-if="p.id === currentPoint?.id && p.timeType === 'LESSON' && node.settings.showProgress"
class="lesson-progress"
role="progressbar"
:aria-valuenow="Math.round(elapsed * 100)"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="`${p.courseName || '课程'}课程进度,距下课${remaining}秒`"
>
<i :style="{ transform: `scaleX(${elapsed})` }" />
</span>
</div>
</template>
<span v-if="!schedulePoints.length">暂无课程</span>
</template>
<template v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
<slot v-if="node.id === alertNodeId" name="alerts" />
@@ -238,11 +303,13 @@ const style = computed<CSSProperties>(() => {
white-space: nowrap;
}
.schedule > .lesson-line {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
padding-bottom: 3px;
}
.lesson-line strong {
min-width: 0;
@@ -250,6 +317,21 @@ const style = computed<CSSProperties>(() => {
text-overflow: ellipsis;
white-space: nowrap;
}
.lesson-line.current strong {
color: #fff;
}
.lesson-line.break {
opacity: 0.86;
}
.time-divider {
display: block;
width: 1px;
height: 18px;
overflow: hidden;
background: currentColor;
font-size: 0;
opacity: 0.25;
}
.lesson-time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
@@ -257,10 +339,11 @@ const style = computed<CSSProperties>(() => {
}
.lesson-progress {
position: absolute;
inset: auto 4px 0;
inset: auto 0 0;
height: 2px;
background: #ffffff24;
overflow: hidden;
border-radius: 1px;
}
.lesson-progress i {
display: block;
@@ -272,12 +355,6 @@ const style = computed<CSSProperties>(() => {
.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;
@@ -299,6 +376,9 @@ const style = computed<CSSProperties>(() => {
.rolling > :deep(.component-node) {
animation: rolling var(--rolling-duration) linear infinite;
}
.rolling.node-editing > :deep(.component-node) {
animation: none;
}
.selected {
outline: 2px solid #85aaff;
border-radius: 8px;
@@ -6,6 +6,7 @@ import {
type IslandAppearance,
type IslandLayoutSettings,
type IslandState,
type IslandTimePointDto,
type ScheduleDto,
type IslandLessonReminder,
} from '@classhelper/shared';
@@ -20,6 +21,7 @@ const props = defineProps<{
settings: IslandLayoutSettings;
state: IslandState;
schedules: ScheduleDto[];
timePoints: IslandTimePointDto[];
appearance: IslandAppearance;
nativeVisible: boolean;
}>();
@@ -267,10 +269,19 @@ function visibility() {
void nextTick(reportHit);
}
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
watch([expanded, scheme, lesson, () => props.state.listExpanded, messages], measure, {
deep: true,
flush: 'post',
});
watch(
[
expanded,
scheme,
lesson,
() => props.state.listExpanded,
messages,
() => props.timePoints,
() => Math.floor(now.value / 60_000),
],
measure,
{ deep: true, flush: 'post' },
);
watch(
[fit, motion, expanded, lesson, () => props.appearance.radius, () => props.appearance.speed],
() => {
@@ -388,6 +399,7 @@ onUnmounted(() => {
:state="state"
:now="now"
:schedules="schedules"
:time-points="timePoints"
:editing="false"
selected-id=""
alert-node-id=""
@@ -469,6 +481,7 @@ onUnmounted(() => {
:state="state"
:now="now"
:schedules="schedules"
:time-points="timePoints"
:editing="false"
selected-id=""
alert-node-id=""
@@ -5,6 +5,7 @@ import {
DEFAULT_ISLAND_APPEARANCE,
type IslandAppearance,
type IslandState,
type IslandTimePointDto,
type ScheduleDto,
} from '@classhelper/shared';
import FixedIsland from './IslandApp.vue';
@@ -14,6 +15,7 @@ import CustomIslandRuntime from './CustomIslandRuntime.vue';
const settings = ref(defaultIslandLayouts()),
ready = ref(false);
const schedules = ref<ScheduleDto[]>([]);
const timePoints = ref<IslandTimePointDto[]>([]);
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const nativeVisible = ref(false);
const resident = () => settings.value.layout === 'custom';
@@ -36,6 +38,7 @@ onMounted(async () => {
if (bridge) {
bridge.onLayout((value) => (settings.value = value));
bridge.onSchedules((value) => (schedules.value = value));
bridge.onTimePoints((value) => (timePoints.value = value));
bridge.onState((value) => (state.value = value));
bridge.onAppearance((value) => (appearance.value = value));
bridge.onVisibility((value) => (nativeVisible.value = value));
@@ -43,6 +46,7 @@ onMounted(async () => {
appearance.value = await bridge.getAppearance();
settings.value = await bridge.getLayout();
schedules.value = await bridge.getSchedules();
timePoints.value = await bridge.getTimePoints();
state.value = await bridge.getState();
}
await nextTick();
@@ -55,9 +59,18 @@ onMounted(async () => {
:settings="settings"
:state="state"
:schedules="schedules"
:time-points="timePoints"
:appearance="appearance"
:native-visible="nativeVisible"
/>
<FixedIsland v-if="ready" v-show="!resident() && !state.coursewareTransfer" :suspended="resident() || !!state.coursewareTransfer" />
<CoursewareTransfer v-if="state.coursewareTransfer" :transfer="state.coursewareTransfer" :appearance="appearance" />
<FixedIsland
v-if="ready"
v-show="!resident() && !state.coursewareTransfer"
:suspended="resident() || !!state.coursewareTransfer"
/>
<CoursewareTransfer
v-if="state.coursewareTransfer"
:transfer="state.coursewareTransfer"
:appearance="appearance"
/>
</template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
import type { IslandComponentNode, IslandWeather } from '@classhelper/shared';
const props = defineProps<{ settings: IslandComponentNode['settings'] }>();
const weather = ref<IslandWeather | null>(null),
loading = ref(false),
unavailable = ref(false);
const configured = computed(
() => typeof props.settings.latitude === 'number' && typeof props.settings.longitude === 'number',
);
let revision = 0;
async function refresh() {
const current = ++revision;
if (!configured.value) {
loading.value = false;
return;
}
loading.value = true;
try {
const bridge = window.island ?? window.desktop;
if (!bridge) throw new Error('仅桌面客户端可读取天气');
const result = await bridge.getIslandWeather({
latitude: props.settings.latitude!,
longitude: props.settings.longitude!,
});
if (current === revision) {
weather.value = result;
unavailable.value = false;
}
} catch {
if (current === revision) unavailable.value = true;
} finally {
if (current === revision) loading.value = false;
}
}
watch(
() => [props.settings.latitude, props.settings.longitude],
() => {
weather.value = null;
unavailable.value = false;
void refresh();
},
{ immediate: true },
);
const timer = setInterval(() => void refresh(), 60_000);
onUnmounted(() => {
clearInterval(timer);
revision++;
});
const description = computed(() => {
const code = weather.value?.code ?? -1;
if (code === 0) return '晴';
if (code <= 3 && code > 0) return ['少云', '多云', '阴'][code - 1];
if ([45, 48].includes(code)) return '雾';
if ([51, 53, 55, 56, 57].includes(code)) return '毛毛雨';
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return '雨';
if ([71, 73, 75, 77, 85, 86].includes(code)) return '雪';
if ([95, 96, 99].includes(code)) return '雷雨';
return '天气';
});
const advice = computed(() => {
if (!weather.value) return '';
if (weather.value.code >= 95) return '雷雨天气,注意避雷';
if (weather.value.windSpeed >= 40) return '风力较大,注意出行';
return '';
});
</script>
<template>
<span
class="weather-brief"
:title="weather ? `Open-Meteo · ${new Date(weather.fetchedAt).toLocaleString('zh-CN')}\n${advice}` : ''"
>
<template v-if="weather">
{{ settings.locationName }} {{ description }} {{ Math.round(weather.temperature) }}°C
<span v-if="weather.stale || unavailable">· 缓存</span>
<small v-if="advice">{{ advice }}</small>
</template>
<template v-else>
{{ !configured ? '请设置天气地点' : loading ? '天气加载中…' : '天气暂不可用' }}
</template>
</span>
</template>
<style scoped>
.weather-brief {
white-space: nowrap;
}
small {
display: block;
font-size: var(--secondary, 12px);
}
</style>
+2
View File
@@ -6,6 +6,7 @@ import {
listLocalCourseware,
} from './courseware.js';
import { island } from './island.js';
import { getIslandWeather } from './island-weather.js';
import { getUpdateProgress } from './auto-update.js';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
import type { UpdateInfo } from '@classhelper/shared';
@@ -21,6 +22,7 @@ import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
export { IPC_CHANNELS };
export function registerIpcHandlers(): void {
ipcMain.handle('island:weather', (_event, coordinates: unknown) => getIslandWeather(coordinates));
ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id));
ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id));
ipcMain.handle('courseware:local-list', () => listLocalCourseware());
@@ -0,0 +1,73 @@
import type { IslandWeather } from '@classhelper/shared';
const cache = new Map<string, { data: IslandWeather | null; retryAt: number }>();
const pending = new Map<string, Promise<IslandWeather>>();
/** 固定天气服务与数值坐标,不接受渲染层传入 URL。失败退避并保留本次运行的缓存。 */
export async function getIslandWeather(
input: unknown,
request: typeof fetch = fetch,
): Promise<IslandWeather> {
const coordinates = input as { latitude?: unknown; longitude?: unknown } | null;
const latitude = coordinates?.latitude,
longitude = coordinates?.longitude;
if (
typeof latitude !== 'number' ||
!Number.isFinite(latitude) ||
Math.abs(latitude) > 90 ||
typeof longitude !== 'number' ||
!Number.isFinite(longitude) ||
Math.abs(longitude) > 180
)
throw new Error('请设置有效的经纬度');
const key = `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
const stored = cache.get(key);
if (stored && stored.retryAt > Date.now()) {
if (stored.data) return { ...stored.data, stale: Date.now() - stored.data.fetchedAt >= 15 * 60_000 };
throw new Error('天气暂不可用,稍后自动重试');
}
const running = pending.get(key);
if (running) return running;
const job = Promise.resolve().then(async () => {
try {
if (process.env.ELECTRON_SMOKE_TEST === '1' && request === fetch)
throw new Error('自动验收不访问外部天气服务');
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.search = new URLSearchParams({
latitude: String(latitude),
longitude: String(longitude),
current: 'temperature_2m,weather_code,wind_speed_10m',
forecast_days: '1',
}).toString();
const response = await request(url, { signal: AbortSignal.timeout(8000), redirect: 'error' });
if (!response.ok) throw new Error('天气服务暂不可用');
const body = await response.text();
if (body.length > 64_000) throw new Error('天气响应过大');
const current = JSON.parse(body).current;
if (
![current?.temperature_2m, current?.weather_code, current?.wind_speed_10m].every(
(value) => typeof value === 'number' && Number.isFinite(value),
)
)
throw new Error('天气数据格式不正确');
const data: IslandWeather = {
temperature: current.temperature_2m,
code: current.weather_code,
windSpeed: current.wind_speed_10m,
fetchedAt: Date.now(),
stale: false,
};
cache.set(key, { data, retryAt: Date.now() + 15 * 60_000 });
return data;
} catch {
cache.set(key, { data: stored?.data ?? null, retryAt: Date.now() + 60_000 });
if (stored?.data) return { ...stored.data, stale: true };
throw new Error('天气暂不可用,请检查网络或坐标');
} finally {
pending.delete(key);
while (cache.size > 32) cache.delete(cache.keys().next().value!);
}
});
pending.set(key, job);
return job;
}
+25 -7
View File
@@ -1,4 +1,7 @@
import type { ScheduleDto as ContractScheduleDto } from '@classhelper/shared';
import type {
IslandTimePointDto as ContractIslandTimePointDto,
ScheduleDto as ContractScheduleDto,
} from '@classhelper/shared';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, ipcMain, screen } from 'electron';
@@ -138,6 +141,7 @@ export interface IslandPushContext {
export interface IslandClassStatePayload {
schedules?: ContractScheduleDto[];
timePoints?: ContractIslandTimePointDto[];
inClass: boolean;
currentPeriodEnd?: string | null;
week?: number;
@@ -364,14 +368,15 @@ class IslandController {
if (this.customTimer) clearTimeout(this.customTimer);
this.customTimer = null;
// 常驻岛只报告目标几何;窗口画布固定,禁止逐帧 setBounds 和两端尺寸防抖互相追赶。
if (
this.customSize.width !== Math.ceil(value.width) ||
this.customSize.height !== Math.ceil(value.height)
)
const width = Math.ceil(value.width);
const height = Math.ceil(value.height);
const changed = this.customSize.width !== width || this.customSize.height !== height;
if (changed)
this.residentMotionUntil =
Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0);
this.customSize = { width: Math.ceil(value.width), height: Math.ceil(value.height) };
this.syncResidentShape(true);
this.customSize = { width, height };
// 时钟每分钟触发的重复测量尺寸没变,不要清空触摸形状(那 750ms 会漏掉点击)。
if (changed || !this.residentShaped) this.syncResidentShape(true);
return;
}
if (this.customTimer) clearTimeout(this.customTimer);
@@ -618,6 +623,7 @@ class IslandController {
// 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值
this.emitAppearance();
this.win?.webContents.send('island:schedules', this.schedules);
this.win?.webContents.send('island:time-points', this.timePoints);
this.emit();
if (this.residentEnabled) {
this.residentWasEnabled = true;
@@ -634,6 +640,10 @@ class IslandController {
getSchedules(): ContractScheduleDto[] {
return [...this.schedules];
}
private timePoints: ContractIslandTimePointDto[] = [];
getTimePoints(): ContractIslandTimePointDto[] {
return [...this.timePoints];
}
getState(): IslandState {
return {
@@ -860,6 +870,11 @@ class IslandController {
if (this.ready && this.win && !this.win.isDestroyed())
this.win.webContents.send('island:schedules', this.schedules);
}
if (Array.isArray(payload.timePoints)) {
this.timePoints = payload.timePoints.slice(0, 100);
if (this.ready && this.win && !this.win.isDestroyed())
this.win.webContents.send('island:time-points', this.timePoints);
}
const wasInClass = this.state.inClass;
this.state.inClass = payload.inClass;
this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null;
@@ -2320,6 +2335,9 @@ export function registerIslandIpc(): void {
ipcMain.handle('island:get-schedules', (event) =>
event.sender === island.getWindow()?.webContents ? island.getSchedules() : [],
);
ipcMain.handle('island:get-time-points', (event) =>
event.sender === island.getWindow()?.webContents ? island.getTimePoints() : [],
);
ipcMain.handle('island:get-layout', () => getConfig().islandLayout);
ipcMain.handle('island:save-layout', (_event, value: unknown) => {
const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout;
@@ -3,7 +3,13 @@ import { BrowserWindow } from 'electron';
import path from 'node:path';
import { island } from './island.js';
import { getConfig, saveConfig } from './config.js';
import { defaultIslandLayouts, ISLAND_SHADOW_PAD } from '@classhelper/shared';
import {
defaultIslandLayouts,
islandNode,
normalizeIslandLayouts,
ISLAND_SHADOW_PAD,
} from '@classhelper/shared';
import { getIslandWeather } from './island-weather.js';
import { verifyResidentSmoke } from './smoke-resident.js';
export async function verifyLayoutSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
@@ -38,12 +44,30 @@ export async function verifyLayoutSmoke(
editingBounds = win.getBounds();
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(true)');
await pause();
if (process.env.ISLAND_SHOTS_DIR) {
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
const bounds = editor.getContentBounds();
editor.setContentSize(1600, 1000);
for (const theme of ['light', 'dark']) {
await editor.webContents.executeJavaScript(
`document.documentElement.classList.toggle('dark', ${theme === 'dark'})`,
);
await pause();
fs.writeFileSync(
path.join(process.env.ISLAND_SHOTS_DIR, `component-editor-${theme}.png`),
(await editor.webContents.capturePage()).toPNG(),
);
}
await editor.webContents.executeJavaScript(`document.documentElement.classList.remove('dark')`);
editor.setContentSize(bounds.width, bounds.height);
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.trim()==='新增行');b?.click();return Boolean(document.querySelector('.island-editor-page .el-card')) && !document.querySelector('.layout-editor, iframe'); })()`,
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='新增行');b?.click();return document.querySelectorAll('.component-library > .component-options:first-of-type .library-item').length===11 && !document.querySelector('.layout-editor, iframe'); })()`,
)
);
});
@@ -66,12 +90,16 @@ export async function verifyLayoutSmoke(
return true;
});
await check('组件可以包裹进容器且设置独立保存', async () => {
await editor.webContents.executeJavaScript(
`document.querySelector('.node-handle[data-kind=clock]')?.click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`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(); })()`,
`(() => { const option=[...document.querySelectorAll('.el-select-dropdown__item')].find(e=>e.textContent.trim()==='分组容器'); if(!option)throw Error('容器菜单未出现');option.click(); })()`,
);
await pause();
return (
@@ -108,6 +136,10 @@ export async function verifyLayoutSmoke(
return initial && appearance && advanced;
});
await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => {
await editor.webContents.executeJavaScript(
`document.querySelector('.el-drawer__close-btn')?.click();document.querySelector('.surface-actions button:last-child')?.click()`,
);
await pause();
return editor.webContents.executeJavaScript(`(() => {
const canvas=document.querySelector('.preview-canvas')?.getBoundingClientRect();
const footer=document.querySelector('.preview-toolbar')?.getBoundingClientRect();
@@ -118,11 +150,190 @@ export async function verifyLayoutSmoke(
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;
const page=document.querySelector('.island-editor-page'), style=getComputedStyle(page);
const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelector('.component-library .library-title') && document.querySelector('.scheme-controls');
root.classList.toggle('dark',wasDark);return Boolean(ok);
})()`);
});
await check('从常驻组件库拖入容器,阻止将父组件放进自己的后代', async () => {
await editor.webContents.executeJavaScript(
`document.querySelector('.surface-actions button:last-child')?.click()`,
);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const source=document.querySelector('[data-library-kind=text]'), target=document.querySelector('.editor-component.container');
if(!source||!target)throw Error('缺少组件库或容器');
const r=target.getBoundingClientRect(), x=r.left+r.width/2, y=r.top+r.height/2;
source.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:21,clientX:10,clientY:10}));
source.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:21,clientX:x,clientY:y}));
source.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:21,clientX:x,clientY:y}));
})()`);
await pause();
const group = getConfig()
.islandLayout.schemes[0]!.rows.flatMap((r) => r.nodes)
.find((n) => n.kind === 'group');
if (!group?.children.some((n) => n.kind === 'text')) throw Error('组件库内容没有进入容器');
const before = JSON.stringify(getConfig().islandLayout);
await editor.webContents.executeJavaScript(`(() => {
const source=document.querySelector('.editor-component.container > .node-handle'), target=document.querySelector('.editor-component.container .container-children .node-handle');
const r=target.getBoundingClientRect(), x=r.left+r.width/2, y=r.top+r.height/2;
source.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:22,clientX:10,clientY:10}));
source.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:22,clientX:x,clientY:y}));
})()`);
await pause();
return before === JSON.stringify(getConfig().islandLayout);
});
await check('常态与无通知展开内容分别保存,日期倒计时可配置并预览', async () => {
const before = JSON.stringify(getConfig().islandLayout.schemes[0]!.rows);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.surface-tabs button')[1].click();document.querySelector('[data-library-kind=countdown]').click()`,
);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const set=(input,value)=>{input.value=value;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));};
const field=[...document.querySelectorAll('.component-settings .el-form-item')].find(e=>e.textContent.includes('目标名称'));
set(field.querySelector('input'),'运动会');
document.querySelector('.component-settings .el-date-editor input').focus();
})()`);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const picker=document.querySelector('.component-settings .el-date-editor input');
picker.value='2099-10-25';picker.dispatchEvent(new Event('input',{bubbles:true}));
})()`);
await pause();
await editor.webContents.executeJavaScript(`(() => {
const picker=document.querySelector('.component-settings .el-date-editor input');
picker.dispatchEvent(new Event('change',{bubbles:true}));picker.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true}));picker.blur();
})()`);
await pause();
const added = getConfig()
.islandLayout.schemes[0]!.expandedRows.flatMap((r) => r.nodes)
.find((n) => n.kind === 'countdown');
if (added?.settings.targetDate !== '2099-10-25' || added.settings.countdownTitle !== '运动会')
throw Error(JSON.stringify(added?.settings));
await editor.webContents.executeJavaScript(
`document.querySelector('.el-drawer__close-btn')?.click();document.querySelector('.surface-actions button:last-child')?.click()`,
);
await pause();
const preview = await editor.webContents.executeJavaScript(
`document.querySelector('.island-preview .countdown')?.textContent.includes('距运动会')`,
);
await editor.webContents.executeJavaScript(
`document.querySelectorAll('.surface-tabs button')[0].click()`,
);
await pause();
return preview && JSON.stringify(getConfig().islandLayout.schemes[0]!.rows) === before;
});
await check('天气坐标校验、并发合并与缓存使用 mock,不访问外网', async () => {
let calls = 0;
const request: typeof fetch = async (input) => {
calls++;
const url = new URL(String(input));
if (url.origin !== 'https://api.open-meteo.com' || url.searchParams.get('latitude') !== '12.34')
throw Error('请求边界错误');
return new Response(
JSON.stringify({ current: { temperature_2m: 23.4, weather_code: 2, wind_speed_10m: 8 } }),
);
};
const results = await Promise.all([
getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request),
getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request),
]);
await getIslandWeather({ latitude: 12.34, longitude: 56.78 }, request);
let invalid = false;
try {
await getIslandWeather({ latitude: 100, longitude: 0 }, request);
} catch {
invalid = true;
}
const weather = islandNode('weather', 'weather-test'),
countdown = islandNode('countdown', 'countdown-test');
weather.settings.latitude = NaN;
countdown.settings.targetDate = '2026-02-31';
const config = defaultIslandLayouts();
config.schemes[0]!.rows[0]!.nodes = [weather, countdown];
const normalized = normalizeIslandLayouts(config).schemes[0]!.rows[0]!.nodes;
return (
calls === 1 &&
results.every((r) => r.temperature === 23.4 && !r.stale) &&
invalid &&
normalized[0]!.settings.latitude === null &&
normalized[1]!.settings.targetDate === ''
);
});
await check('右上角新建、重置和删除方案均可保存并恢复原方案', async () => {
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="新建方案"]').click()`);
await pause();
await editor.webContents.executeJavaScript(
`(() => { const input=document.querySelector('.el-message-box__input input'); input.value='组件方案验收';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('.el-message-box__btns .el-button--primary').click(); })()`,
);
await pause();
if (getConfig().islandLayout.schemes.length !== 2) throw Error('新建方案没有保存');
await editor.webContents.executeJavaScript(
`document.querySelector('[aria-label="重置当前方案"]').click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`document.querySelector('.el-message-box__btns .el-button--primary').click()`,
);
await pause();
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`);
await pause();
await editor.webContents.executeJavaScript(
`[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='删除方案').click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`document.querySelector('.el-message-box__btns .el-button--primary').click()`,
);
await pause();
return (
getConfig().islandLayout.schemes.length === 1 &&
getConfig().islandLayout.activeSchemeId === 'default' &&
getConfig().islandLayout.schemes[0]!.rows.length === 2
);
});
await check('方案 JSON 导入导出保留容器结构并重新生成标识', async () => {
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`);
await pause();
const exported = await editor.webContents.executeJavaScript(`(async () => {
let blob; const originalURL=URL.createObjectURL, originalClick=HTMLAnchorElement.prototype.click;
try {
URL.createObjectURL=value=>{blob=value;return 'blob:editor-smoke';};HTMLAnchorElement.prototype.click=()=>{};
[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='导出方案').click();
} finally { URL.createObjectURL=originalURL;HTMLAnchorElement.prototype.click=originalClick; }
const text=await blob.text(), parsed=JSON.parse(text);
const input=document.querySelector('.island-editor-page input[type=file]'), transfer=new DataTransfer();
transfer.items.add(new File([text],'组件方案.json',{type:'application/json'}));input.files=transfer.files;
input.dispatchEvent(new Event('change',{bubbles:true}));return parsed;
})()`);
await pause();
const layouts = getConfig().islandLayout;
const imported = layouts.schemes.find((s) => s.id === layouts.activeSchemeId)!;
const shape = (nodes: ReturnType<typeof islandNode>[]): string =>
JSON.stringify(
nodes.map((n) => ({ kind: n.kind, children: shape(n.children), settings: n.settings })),
);
const preserved =
exported.format === 'classhelper-island-layout' &&
imported.id !== exported.scheme.id &&
imported.rows.every(
(row, index) =>
row.id !== exported.scheme.rows[index].id &&
shape(row.nodes) === shape(exported.scheme.rows[index].nodes),
);
await editor.webContents.executeJavaScript(`document.querySelector('[aria-label="管理方案"]').click()`);
await pause();
await editor.webContents.executeJavaScript(
`[...document.querySelectorAll('.el-dropdown-menu__item')].find(e=>e.textContent.trim()==='删除方案').click()`,
);
await pause();
await editor.webContents.executeJavaScript(
`document.querySelector('.el-message-box__btns .el-button--primary').click()`,
);
await pause();
return preserved && getConfig().islandLayout.schemes.length === 1;
});
if (process.env.ISLAND_SHOTS_DIR) {
await editor.webContents.executeJavaScript(`document.querySelector('.main').scrollTop=0`);
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
@@ -5,6 +5,7 @@ import {
islandNode,
normalizeIslandLayouts,
ISLAND_SHADOW_PAD,
type IslandTimePointDto,
type ScheduleDto,
} from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
@@ -607,7 +608,9 @@ export async function verifyResidentSmoke(
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);
const text=root?.textContent??'';
const progress=Number(root?.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow'));
return Boolean(root&&text.includes('数学')&&progress===50&&(${layout === 'custom' ? "text.includes('09:00–09:45')&&!text.includes('距下课')" : "text.includes('距下课')"}));
})()`);
if (!half)
throw new Error(
@@ -679,6 +682,87 @@ export async function verifyResidentSmoke(
await pause(800);
}
});
await check('自定义岛课程表按时间点折叠:课间折叠隐藏、当前项展开显示时间段', async () => {
const scheduleLayout = JSON.parse(JSON.stringify(fixture));
const node = islandNode('schedule', 'time-point-schedule');
node.settings.display = 'today';
node.settings.count = 10;
scheduleLayout.schemes[0].rows[0].nodes = [node];
saveConfig({ islandLayout: { ...scheduleLayout, layout: 'custom' } });
island.syncCustomLayout();
const timePoints: IslandTimePointDto[] = [
{
id: 'p-math',
timeType: 'LESSON',
name: '第 1 节',
courseName: '数学',
startTime: '09:00',
endTime: '09:45',
isHideDefault: false,
},
{
id: 'p-break',
timeType: 'BREAK',
name: '课间休息',
courseName: null,
startTime: '09:45',
endTime: '09:55',
isHideDefault: false,
},
{
id: 'p-chinese',
timeType: 'LESSON',
name: '第 2 节',
courseName: '语文',
startTime: '09:55',
endTime: '10:40',
isHideDefault: false,
},
];
const setTime = async (hour: number, minute: number) => {
const at = dateAt(hour, minute).getTime();
await win.webContents.executeJavaScript(`window.__lessonSmokeTime=${at}`);
island.setLessonReminderTestTime(at);
};
try {
await win.webContents.executeJavaScript(
`window.__lessonRealNow=Date.now;Date.now=()=>window.__lessonSmokeTime??window.__lessonRealNow();true`,
);
island.setClassState({ inClass: false, schedules: lessonFixtures, timePoints });
await setTime(9, 20);
await pause(900);
const duringClass = await win.webContents.executeJavaScript(`(() => {
const text=document.querySelector('.resident-compact')?.textContent??'';
return { math: text.includes('数学'), chinese: text.includes('语文'), rest: text.includes('课间休息'), period: text.includes('09:00–09:45'), otherPeriod: text.includes('09:55–10:40') };
})()`);
await setTime(9, 50);
await pause(900);
const duringBreak = await win.webContents.executeJavaScript(`(() => {
const text=document.querySelector('.resident-compact')?.textContent??'';
return { math: text.includes('数学'), chinese: text.includes('语文'), rest: text.includes('课间休息'), period: text.includes('09:45–09:55') };
})()`);
if (!(
duringClass.math &&
duringClass.chinese &&
!duringClass.rest &&
duringClass.period &&
!duringClass.otherPeriod
))
throw new Error('课中时间点折叠错误:' + JSON.stringify(duringClass));
if (!(duringBreak.math && duringBreak.chinese && duringBreak.rest && duringBreak.period))
throw new Error('课间展开错误:' + JSON.stringify(duringBreak));
return true;
} finally {
await win.webContents.executeJavaScript(
`Date.now=window.__lessonRealNow;delete window.__lessonRealNow;delete window.__lessonSmokeTime`,
);
island.setLessonReminderTestTime(null);
island.setClassState({ inClass: false, schedules: [], timePoints: [] });
saveConfig({ islandLayout: fixture });
island.syncCustomLayout();
await pause(800);
}
});
await check('长文本多行与2.5倍字号自动适配,变窄窗口和底部右侧停靠均不溢出', async () => {
const appearance = island.getAppearance();
const dense = JSON.parse(JSON.stringify(fixture));
@@ -743,15 +827,26 @@ export async function verifyResidentSmoke(
island.handleAction({ action: 'expand' });
await pause(1000);
island.handleAction({ action: 'collapse' });
await pause(1000);
// 收起后渲染进程要重新量宽并走 750ms 的触摸形状延时;尺寸稳定前不要断言。
let settled = false;
const deadline = Date.now() + 3500;
while (Date.now() < deadline) {
await pause(100);
const current = island.getHitRect();
if (
current &&
shape.length === 1 &&
shape[0]!.width <= current.width + 18 &&
shape[0]!.height <= current.height + 18
) {
settled = true;
break;
}
}
const hit = island.getHitRect();
return Boolean(
hit &&
shape.length === 1 &&
shape[0]!.width <= hit.width + 18 &&
shape[0]!.height <= hit.height + 18 &&
JSON.stringify(box) === JSON.stringify(win.getBounds()),
);
const ok = Boolean(settled && hit && JSON.stringify(box) === JSON.stringify(win.getBounds()));
if (!ok) throw new Error(JSON.stringify({ hit, shape, bounds: win.getBounds(), box, settled }));
return true;
} finally {
win.setShape = original;
island.setTouchMode(false);
@@ -23,6 +23,7 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
*/
const bridge: DesktopBridge = {
getIslandWeather: (coordinates) => ipcRenderer.invoke('island:weather', coordinates),
downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id),
revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id),
listLocalCourseware: () => ipcRenderer.invoke('courseware:local-list'),
@@ -1,5 +1,7 @@
import type {
IslandLayoutSettings as ContractIslandLayoutSettings,
IslandTimePointDto as ContractIslandTimePointDto,
IslandWeather,
ScheduleDto as ContractScheduleDto,
} from '@classhelper/shared';
import { contextBridge, ipcRenderer } from 'electron';
@@ -10,6 +12,7 @@ import type { IslandAppearance, IslandState } from '@classhelper/shared';
* 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
*/
export interface IslandBridge {
getIslandWeather(coordinates: { latitude: number; longitude: number }): Promise<IslandWeather>;
getLayout(): Promise<ContractIslandLayoutSettings>;
saveLayout(value: ContractIslandLayoutSettings): Promise<ContractIslandLayoutSettings>;
onLayout(handler: (value: ContractIslandLayoutSettings) => void): void;
@@ -24,6 +27,8 @@ export interface IslandBridge {
onViewport(handler: (value: { width: number; height: number }) => void): () => void;
getSchedules(): Promise<ContractScheduleDto[]>;
onSchedules(handler: (value: ContractScheduleDto[]) => void): void;
getTimePoints(): Promise<ContractIslandTimePointDto[]>;
onTimePoints(handler: (value: ContractIslandTimePointDto[]) => void): void;
onState(handler: (state: IslandState) => void): void;
sendAction(
@@ -57,6 +62,7 @@ export interface IslandBridge {
}
const bridge: IslandBridge = {
getIslandWeather: (coordinates) => ipcRenderer.invoke('island:weather', coordinates),
getLayout: () => ipcRenderer.invoke('island:get-layout'),
saveLayout: (value) => ipcRenderer.invoke('island:save-layout', value),
onLayout: (handler) => {
@@ -84,6 +90,10 @@ const bridge: IslandBridge = {
onSchedules: (handler) => {
ipcRenderer.on('island:schedules', (_event, value) => handler(value));
},
getTimePoints: () => ipcRenderer.invoke('island:get-time-points'),
onTimePoints: (handler) => {
ipcRenderer.on('island:time-points', (_event, value) => handler(value));
},
onState: (handler) => {
ipcRenderer.on('island:state', (_event, state: IslandState) => handler(state));
@@ -37,14 +37,63 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const;
<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 === 'countdown'">
<el-form-item 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-option label="指定日期" value="date" />
</el-select>
</el-form-item>
<template v-if="node.settings.target === 'date'">
<el-form-item label="目标名称">
<el-input v-model="node.settings.countdownTitle" :maxlength="64" @change="emit('change')" />
</el-form-item>
<el-form-item label="目标日期">
<el-date-picker
v-model="node.settings.targetDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
@change="emit('change')"
/>
</el-form-item>
</template>
</template>
<template v-else-if="node.kind === 'weather'">
<el-form-item label="地点名称">
<el-input
v-model="node.settings.locationName"
placeholder="例如:学校"
:maxlength="64"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="纬度">
<el-input-number
v-model="node.settings.latitude"
:min="-90"
:max="90"
:precision="4"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="经度">
<el-input-number
v-model="node.settings.longitude"
:min="-180"
:max="180"
:precision="4"
@change="emit('change')"
/>
</el-form-item>
<p class="hint">
填写地点坐标后每 15 分钟更新天气。数据来自
Open-Meteo,断网时显示本次运行的缓存;天气提示不作为官方预警。
</p>
</template>
<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')" />
@@ -57,20 +106,24 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const;
<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="current" />
<el-option label="下一节课" value="next" />
<el-option label="今日课表" value="today" />
<el-option label="今日时间点" value="today" />
</el-select>
</el-form-item>
<el-form-item v-if="node.settings.display === 'today'" label="显示节数">
<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-form-item label="当前时间点显示起止时间">
<el-switch v-model="node.settings.showPeriod" @change="emit('change')" />
</el-form-item>
<el-form-item label="显示上课进度">
<el-form-item label="当前课程显示进度">
<el-switch v-model="node.settings.showProgress" @change="emit('change')" />
</el-form-item>
<p class="hint">
今日时间点只展开当前项并显示时间段;课间休息与默认隐藏的时间点折叠时完全隐藏,轮到时才展开。
下课前一分钟的倒计时由「提醒」设置里的下课提醒负责。
</p>
</template>
<template v-else-if="containers.includes(node.kind as (typeof containers)[number])">
<el-form-item label="排列方向">
@@ -80,7 +133,7 @@ const containers = ['group', 'stack', 'slide', 'rolling'] as const;
</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-input-number v-model="node.settings.gap" :min="0" :max="64" @change="emit('change')" />
</el-form-item>
<el-form-item v-if="node.kind === 'slide'" label="切换间隔(秒)">
<el-input-number
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ISLAND_COMPONENT_LABELS, type IslandComponentNode } from '@classhelper/shared';
import ComponentIcon from '../../island/ComponentIcon.vue';
const props = defineProps<{
node: IslandComponentNode;
selectedId: string;
dropId?: string;
dropPosition?: string;
}>();
const emit = defineEmits<{ select: [id: string]; drag: [event: PointerEvent, id: string] }>();
const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind));
</script>
<template>
<div
class="editor-component"
:class="{
container,
selected: selectedId === node.id,
'drop-before': dropId === node.id && dropPosition === 'before',
'drop-after': dropId === node.id && dropPosition === 'after',
'drop-inside': dropId === node.id && dropPosition === 'inside',
}"
:data-node="node.id"
>
<button
class="component-chip node-handle"
:data-kind="node.kind"
:aria-label="`编辑${ISLAND_COMPONENT_LABELS[node.kind]}`"
@pointerdown="emit('drag', $event, node.id)"
@click.stop="emit('select', node.id)"
>
<ComponentIcon :kind="node.kind" />
<span>{{ ISLAND_COMPONENT_LABELS[node.kind] }}</span>
</button>
<div v-if="container" class="container-children">
<IslandEditorNode
v-for="child in node.children"
:key="child.id"
:node="child"
:selected-id="selectedId"
:drop-id="dropId"
:drop-position="dropPosition"
@select="emit('select', $event)"
@drag="(event, id) => emit('drag', event, id)"
/>
<span v-if="!node.children.length" class="container-empty">拖入组件</span>
</div>
</div>
</template>
<style scoped>
.editor-component {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
border-radius: 5px;
}
.component-chip {
display: flex;
align-items: center;
gap: 5px;
min-height: 36px;
padding: 5px 9px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: inherit;
font: inherit;
white-space: nowrap;
cursor: grab;
touch-action: none;
}
.component-chip svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.component-chip:hover {
background: var(--ch-hover-soft);
}
.component-chip:focus-visible {
outline: 2px solid var(--ch-accent);
outline-offset: 2px;
}
.selected > .component-chip {
background: var(--ch-accent-soft);
border-color: var(--ch-accent);
}
.container {
border: 1px dashed var(--ch-border-strong);
padding: 3px;
gap: 3px;
}
.container-children {
display: flex;
align-items: center;
gap: 3px;
min-width: 60px;
min-height: 34px;
}
.container-empty {
color: var(--ch-text-tertiary);
font-size: 12px;
padding: 0 10px;
}
.drop-inside {
background: var(--ch-accent-soft);
outline: 2px solid var(--ch-accent);
}
.drop-before::before,
.drop-after::after {
content: '';
position: absolute;
inset: 3px auto 3px -4px;
width: 3px;
border-radius: 3px;
background: var(--ch-accent);
}
.drop-after::after {
left: auto;
right: -4px;
}
</style>
@@ -5,6 +5,7 @@ import {
resolveClassStatus,
type ClassPeriod,
type HomeworkDto,
type IslandTimePointDto,
type NotificationDto,
type ScheduleDto,
} from '@classhelper/shared';
@@ -30,6 +31,8 @@ const TICK_INTERVAL_MS = 1_000;
const REFRESH_INTERVAL_MS = 30_000;
let schedules: ScheduleDto[] = [];
/** 今天的完整时间点(含课间/分割线/默认隐藏),自定义岛的课程表组件使用。 */
let dayTimePoints: IslandTimePointDto[] = [];
let currentPeriod: ClassPeriod | null = null;
let inClass = false;
let tickTimer: ReturnType<typeof setInterval> | null = null;
@@ -47,6 +50,51 @@ export function getIslandSchedules(): ScheduleDto[] {
return schedules;
}
export function getIslandTimePoints(): IslandTimePointDto[] {
return dayTimePoints;
}
/** 从 active-week 的今天取完整时间点;课程名优先条目、没有条目才回退默认课程。 */
function buildDayTimePoints(days: ContractActiveClassPlanWeekDto['days']): IslandTimePointDto[] {
const weekday = new Date().getDay() || 7;
const day = days.find((item) => item.weekday === weekday);
const points = day?.timeLayout?.points ?? [];
if (!points.length) return [];
const entries = day?.classPlan?.entries ?? [];
return points.map((point) => {
const entry = entries.find((item) => item.timeLayoutItemId === point.id);
return {
id: point.id,
timeType: point.timeType,
name: point.breakName || point.name || '',
courseName: entry
? entry.isEnabled
? (entry.course?.name ?? null)
: null
: (point.defaultCourse?.name ?? null),
startTime: point.startTime,
endTime: point.endTime,
isHideDefault: point.isHideDefault,
};
});
}
/** 离线/旧服务端只有课表条目时退化成只有上课时间点。 */
function timePointsFromSchedules(list: ScheduleDto[]): IslandTimePointDto[] {
const weekday = new Date().getDay() || 7;
return list
.filter((item) => item.dayOfWeek === weekday)
.map((item) => ({
id: item.id,
timeType: 'LESSON' as const,
name: '',
courseName: item.course?.name ?? null,
startTime: item.startTime,
endTime: item.endTime,
isHideDefault: false,
}));
}
/** 主动刷新课表(课表变更事件触发) */
export async function refreshIslandSchedules(): Promise<void> {
const appStore = useAppStore();
@@ -61,13 +109,16 @@ export async function refreshIslandSchedules(): Promise<void> {
'schedules',
`week:${week}`,
async () => {
const data = (
await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', { classId, week })
).days.flatMap((day) => day.schedules);
const payload = await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', {
classId,
week,
});
const data = payload.days.flatMap((day) => day.schedules);
// 网络结果先上岛,IndexedDB 写入不再挡住可见内容;旧请求与切换账号的结果不得回写。
if (scope !== getCacheScope() || request !== scheduleRequest || !auth.token)
throw new Error('课表刷新已被新请求替代');
schedules = data;
dayTimePoints = buildDayTimePoints(payload.days);
scheduleWeek = week;
recomputeClassState(true);
return data;
@@ -78,12 +129,22 @@ export async function refreshIslandSchedules(): Promise<void> {
if (result.error && !result.updatedAt) return;
if (result.fromCache) {
schedules = result.data;
dayTimePoints = timePointsFromSchedules(result.data);
scheduleWeek = week;
recomputeClassState(true);
}
}
let sentDay = '';
let sentTimePoints = '';
function timePointsKey(): string {
return dayTimePoints
.map(
(point) =>
`${point.id}|${point.timeType}|${point.startTime}|${point.endTime}|${point.name}|${point.courseName ?? ''}|${point.isHideDefault ? 1 : 0}`,
)
.join(',');
}
function recomputeClassState(force = false): void {
const day = new Date().toDateString();
const appStore = useAppStore();
@@ -94,15 +155,19 @@ function recomputeClassState(force = false): void {
currentPeriod?.endTime === status.current?.endTime;
currentPeriod = status.current;
if (!force && day === sentDay && nextInClass === inClass && samePeriod) return;
const pointsKey = timePointsKey();
if (!force && day === sentDay && nextInClass === inClass && samePeriod && pointsKey === sentTimePoints)
return;
sentDay = day;
inClass = nextInClass;
sentTimePoints = pointsKey;
window.desktop?.islandSetClassState({
inClass,
currentPeriodEnd: currentPeriod?.endTime ?? null,
week: appStore.currentWeek,
schedules: schedules.filter((s) => s.dayOfWeek === (new Date().getDay() || 7)),
timePoints: dayTimePoints,
});
}
@@ -389,7 +454,9 @@ export function stopIslandBridge(): void {
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
useRealtimeStore().off('__connected__', onScheduleUpdated);
sentDay = '';
sentTimePoints = '';
schedules = [];
dayTimePoints = [];
currentPeriod = null;
inClass = false;
@@ -401,5 +468,6 @@ export function stopIslandBridge(): void {
currentPeriodEnd: null,
week: useAppStore().currentWeek,
schedules: [],
timePoints: [],
});
}
@@ -0,0 +1,453 @@
.island-editor-page {
display: flex;
flex-direction: column;
flex: 1 0 auto;
min-height: 0;
min-width: 0;
color: var(--ch-text);
padding: 12px 24px 16px;
background: var(--editor-background, var(--ch-bg));
}
:global(.main:has(> .island-editor-page)) {
display: flex;
flex-direction: column;
}
:global(.main:has(> .island-editor-page) > .offline-banner),
.island-editor-page > * {
flex-shrink: 0;
}
:global(html.dark) .island-editor-page {
--editor-background: #262626;
--editor-divider: #3a3a3a;
--editor-card: #2c2c2c;
}
.editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-height: 30px;
margin-bottom: 4px;
}
.save-status {
color: var(--ch-text-tertiary);
font-size: 12px;
margin-right: 8px;
}
.save-status.failed {
color: var(--el-color-danger);
}
.component-heading {
align-items: flex-end;
gap: 24px;
margin-bottom: 20px;
}
.heading-copy {
flex: 1 1 380px;
}
.component-heading .page-title {
font-size: calc(34px * var(--ch-font-scale));
font-weight: 600;
line-height: 1.4;
margin-bottom: 20px;
}
.heading-copy p {
font-size: calc(16px * var(--ch-font-scale));
margin: 0;
line-height: 1.6;
}
.scheme-tools {
flex: 0 1 348px;
min-width: 300px;
}
.scheme-tools label {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 600;
}
.scheme-controls {
display: flex;
gap: 6px;
align-items: center;
}
.scheme-controls .el-select {
min-width: 140px;
flex: 1;
}
.scheme-controls :deep(.el-select__wrapper) {
min-height: 38px;
background: var(--ch-card-subtle);
}
.icon-button {
width: 34px;
height: 36px;
padding: 8px;
margin: 0;
flex-shrink: 0;
font-size: 19px;
}
.surface-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.surface-tabs,
.surface-actions {
display: flex;
gap: 12px;
align-items: center;
}
.surface-actions {
gap: 0;
}
.surface-tabs button {
position: relative;
border: 0;
background: transparent;
color: var(--ch-text-secondary);
font: inherit;
font-size: 12px;
padding: 6px 0 9px;
cursor: pointer;
}
.surface-tabs button.active {
color: var(--ch-text);
}
.surface-tabs button.active::after {
content: '';
position: absolute;
bottom: 0;
left: 10%;
right: 10%;
height: 2px;
background: var(--ch-accent);
border-radius: 2px;
}
.preview-canvas {
min-height: 80px;
overflow: auto;
padding: 10px 0 20px;
}
.component-lines {
min-width: 100%;
width: max-content;
}
.component-line {
display: flex;
align-items: center;
min-height: 54px;
border-radius: 6px;
gap: 12px;
padding: 4px;
border: 1px solid transparent;
}
.component-line + .component-line {
margin-top: 6px;
}
.component-line.active-row {
border-color: var(--ch-border-strong);
}
.row-handle {
align-self: stretch;
width: 24px;
flex-shrink: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--ch-text-secondary);
font: inherit;
font-size: 20px;
cursor: grab;
touch-action: none;
}
.row-handle:hover {
color: var(--ch-accent);
background: var(--ch-hover-soft);
}
.line-components {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 0 28px 0 0;
min-height: 38px;
}
.empty-line {
border: 1px dashed var(--ch-border-strong);
background: transparent;
color: var(--ch-text-tertiary);
padding: 10px 24px;
border-radius: 5px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.preview-canvas.live-preview {
display: grid;
place-items: center;
min-height: 160px;
padding: 24px;
background: var(--ch-layer-alt);
border-radius: 6px;
margin-top: 8px;
}
.island-preview {
display: flex;
flex-direction: column;
gap: var(--preview-gap);
width: max-content;
color: #ffffffeb;
padding: 12px;
border-radius: var(--preview-radius);
background: #202024;
}
.preview-row {
display: flex;
align-items: center;
min-width: 120px;
min-height: 44px;
border-radius: var(--preview-radius);
}
.island-preview.separated {
background: transparent;
padding: 0;
}
.separated .preview-row {
padding: 12px;
background: #202024;
}
.empty-preview {
color: #ffffffb8;
background: transparent;
border: 1px dashed #ffffff60;
border-radius: 5px;
padding: 10px 20px;
cursor: pointer;
font: inherit;
font-size: 13px;
}
.drop-row {
outline: 2px dashed var(--ch-accent);
outline-offset: -2px;
background: var(--ch-accent-soft);
}
.preview-toolbar {
display: flex;
align-items: center;
gap: 14px;
padding: 6px 0 12px;
color: var(--ch-text-secondary);
font-size: 12px;
}
.preview-toolbar .el-slider {
width: 130px;
}
.hint {
color: var(--ch-text-secondary);
line-height: 1.6;
font-size: 12px;
}
.component-library {
border-top: 6px solid var(--editor-divider, var(--ch-divider));
margin: 0 -24px;
padding: 14px 24px 24px;
flex: 1 0 auto;
}
.library-title {
position: relative;
width: max-content;
font-size: 17px;
font-weight: 600;
margin: 0 0 12px;
padding: 0 0 7px;
}
.library-title::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 3px;
background: var(--ch-accent);
}
.component-options {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 20px;
}
.library-item {
min-width: 0;
width: 100%;
display: flex;
gap: 12px;
align-items: center;
text-align: left;
min-height: 76px;
padding: 10px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
cursor: grab;
touch-action: none;
transition:
background var(--ch-dur-fast) var(--ch-ease-out),
border-color var(--ch-dur-fast) var(--ch-ease-out);
}
.library-item:hover {
background: var(--ch-hover-soft);
border-color: var(--ch-border);
}
.library-item:active {
background: var(--ch-accent-soft);
}
.library-item:focus-visible,
.row-handle:focus-visible,
.surface-tabs button:focus-visible {
outline: 2px solid var(--ch-accent);
outline-offset: 2px;
}
.library-item > svg {
width: 36px;
height: 36px;
flex: 0 0 36px;
}
.library-item > span {
min-width: 0;
}
.library-item strong {
display: block;
font-size: calc(18px * var(--ch-font-scale));
font-weight: 500;
line-height: 1.5;
}
.component-description {
display: block;
color: var(--ch-text-secondary);
font-size: calc(14px * var(--ch-font-scale));
line-height: 1.5;
}
.message-library-title {
color: var(--ch-text-tertiary);
font-size: 12px;
font-weight: 400;
margin: 20px 8px 4px;
}
.message-options .library-item {
min-height: 64px;
}
.message-options .library-item strong {
font-size: 15px;
}
.message-options .library-item > svg {
width: 26px;
height: 26px;
flex-basis: 26px;
}
.editor-tip {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
min-height: 60px;
border: 1px solid var(--ch-border-strong);
border-radius: 5px;
background: var(--editor-card, var(--ch-card-subtle));
font-size: 14px;
}
.editor-tip > span {
flex: 1;
line-height: 1.6;
}
.tip-icon {
color: var(--ch-accent);
font-size: 22px;
}
.dismiss-tip {
border: 0;
color: var(--ch-text);
background: transparent;
font-size: 26px;
cursor: pointer;
margin-left: auto;
padding: 0 8px;
}
.drag-ghost {
position: fixed;
z-index: 10000;
pointer-events: none;
padding: 10px 16px;
background: var(--ch-layer-solid);
border: 1px solid var(--ch-accent);
border-radius: 6px;
box-shadow: var(--ch-shadow-flyout);
color: var(--ch-text);
font-size: 14px;
}
.inspector-tabs {
margin-bottom: 20px;
}
.row-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 20px;
}
.row-actions .el-button {
margin: 0;
}
@media (max-width: 1150px) {
.component-options {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.component-heading {
gap: 16px;
}
.heading-copy {
flex-basis: 300px;
}
.scheme-tools {
flex-basis: 300px;
}
}
@media (max-width: 800px) {
.component-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.component-heading .page-title {
margin-bottom: 10px;
}
.scheme-tools {
flex: 1 1 100%;
}
.editor-tip {
flex-wrap: wrap;
}
.preview-toolbar .hint {
display: none;
}
}
@media (max-width: 520px) {
.component-options {
grid-template-columns: 1fr;
}
.island-editor-page {
padding-right: 16px;
padding-left: 16px;
}
.component-library {
margin-right: -16px;
margin-left: -16px;
padding-right: 16px;
padding-left: 16px;
}
.save-status {
display: none;
}
}
File diff suppressed because it is too large. Load diff
+5
View File
@@ -1,5 +1,7 @@
import type {
IslandLayoutSettings as ContractIslandLayoutSettings,
IslandTimePointDto as ContractIslandTimePointDto,
IslandWeather,
ReleaseProgressDto as ContractReleaseProgressDto,
ScheduleDto as ContractScheduleDto,
} from '@classhelper/shared';
@@ -111,6 +113,8 @@ export interface IslandPushContext {
export interface IslandClassStatePayload {
schedules?: ContractScheduleDto[];
/** 今天的完整时间点(含课间/分割线/默认隐藏标记),自定义岛课程表组件使用。 */
timePoints?: ContractIslandTimePointDto[];
inClass: boolean;
currentPeriodEnd?: string | null;
week?: number;
@@ -175,6 +179,7 @@ export interface DesktopBridge {
onUpdateAvailable(handler: (info: UpdateInfo) => void): void;
/* 灵动岛 */
getIslandWeather(coordinates: { latitude: number; longitude: number }): Promise<IslandWeather>;
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
islandRemoveNotification(id: string): void;