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