feat(island): 对齐 ClassIsland 课表提醒并恢复开合动画
This commit is contained in:
1 parent
e84e43544a
commit
3379eb975c
15 files changed
+853
-65
No files matched your search
@@ -92,7 +92,7 @@ const fonts = {
|
||||
<input type="number" min="1" max="30" v-model.number="node.settings.count" />
|
||||
</label>
|
||||
<label class="toggle-field">
|
||||
<span>显示节次</span>
|
||||
<span>显示课程时间</span>
|
||||
<input type="checkbox" v-model="node.settings.showPeriod" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -163,9 +163,9 @@ const style = computed<CSSProperties>(() => {
|
||||
</template>
|
||||
<template v-else-if="node.kind === 'separator'"><span aria-hidden="true">│</span></template>
|
||||
<template v-else-if="node.kind === 'schedule'">
|
||||
<div v-for="(s, i) in lessons" :key="s.id">
|
||||
<small v-if="node.settings.showPeriod">第 {{ i + 1 }} 节 · {{ s.startTime }}</small>
|
||||
<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>
|
||||
</div>
|
||||
<span v-if="!lessons.length">暂无课程</span>
|
||||
</template>
|
||||
@@ -199,8 +199,21 @@ const style = computed<CSSProperties>(() => {
|
||||
}
|
||||
.schedule > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.lesson-line strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lesson-time {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedule > div + div {
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -150,14 +150,9 @@ const shellStyle = computed<CSSProperties>(() => ({
|
||||
maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`,
|
||||
maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`,
|
||||
color: '#ffffffeb',
|
||||
fontSize: `${props.appearance.fontSize}px`,
|
||||
'--custom-color':
|
||||
props.appearance.style === 'glass'
|
||||
? '#202024cd'
|
||||
: props.appearance.style === 'tinted'
|
||||
? '#0b0b0f'
|
||||
: '#000000',
|
||||
'--custom-radius': `${props.appearance.radius}px`,
|
||||
fontSize: `${(props.appearance.fontSize / 13) * CI.bodyFontSize}px`,
|
||||
'--custom-color': '#20202080',
|
||||
'--custom-radius': `${scheme.value.radius}px`,
|
||||
'--custom-row-gap': `${scheme.value.spacing}px`,
|
||||
...(draft.value.residentEnabled
|
||||
? {
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
type IslandLayoutSettings,
|
||||
type IslandState,
|
||||
type ScheduleDto,
|
||||
type IslandLessonReminder,
|
||||
} from '@classhelper/shared';
|
||||
import ComponentNode from './ComponentNode.vue';
|
||||
import ComponentIcon from './ComponentIcon.vue';
|
||||
import ClassicNotifications from './ClassicNotifications.vue';
|
||||
import { squirclePath } from './squircle.js';
|
||||
import { SpringValue } from './spring.js';
|
||||
import LessonReminderContent from './LessonReminderContent.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
settings: IslandLayoutSettings;
|
||||
@@ -23,6 +25,15 @@ const props = defineProps<{
|
||||
}>();
|
||||
const scheme = computed(() => props.settings.schemes.find((s) => s.id === props.settings.activeSchemeId)!);
|
||||
const expanded = computed(() => props.state.mode === 'expanded');
|
||||
const lesson = computed(() => props.state.lessonReminder ?? null);
|
||||
const lastLesson = ref<IslandLessonReminder | null>(null);
|
||||
watch(
|
||||
lesson,
|
||||
(value) => {
|
||||
if (value) lastLesson.value = value;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const messages = computed(() => {
|
||||
const unique = new Map(
|
||||
[props.state.active, ...props.state.queued].filter((n) => n !== null).map((n) => [n.id, n]),
|
||||
@@ -36,11 +47,16 @@ const messages = computed(() => {
|
||||
const shell = ref<HTMLElement>();
|
||||
const compact = ref<HTMLElement>();
|
||||
const details = ref<HTMLElement>();
|
||||
const lessonSource = ref<HTMLElement>();
|
||||
const now = ref(Date.now());
|
||||
const visible = ref(!document.hidden);
|
||||
const motion = computed(() => props.appearance.animations && visible.value && props.nativeVisible);
|
||||
const available = ref({ width: 940, height: 620 });
|
||||
const natural = ref({ compact: { width: 40, height: 40 }, details: { width: 280, height: 80 } });
|
||||
const natural = ref({
|
||||
compact: { width: 40, height: 40 },
|
||||
details: { width: 280, height: 80 },
|
||||
lesson: { width: 480, height: 40 },
|
||||
});
|
||||
const anchor = computed(() =>
|
||||
props.appearance.position.endsWith('left')
|
||||
? 'left'
|
||||
@@ -59,9 +75,24 @@ function fitted(size: { width: number; height: number }) {
|
||||
}
|
||||
const compactFit = computed(() => fitted(natural.value.compact));
|
||||
const detailsFit = computed(() => fitted(natural.value.details));
|
||||
const fit = computed(() => (expanded.value ? detailsFit.value : compactFit.value));
|
||||
const lessonFit = computed(() => fitted(natural.value.lesson));
|
||||
const fit = computed(() =>
|
||||
lesson.value ? lessonFit.value : expanded.value ? detailsFit.value : compactFit.value,
|
||||
);
|
||||
const morph = ref({ width: 40, height: 40, radius: 20 });
|
||||
const springs = { width: new SpringValue(40), height: new SpringValue(40), radius: new SpringValue(20) };
|
||||
const springs = {
|
||||
width: new SpringValue(40),
|
||||
height: new SpringValue(40),
|
||||
radius: new SpringValue(20),
|
||||
content: new SpringValue(0, { snapThreshold: 0.001 }),
|
||||
lesson: new SpringValue(0, { snapThreshold: 0.001 }),
|
||||
};
|
||||
// 与经典岛一致:常态内容先退净,展开内容按进度平方进入;反向收起沿同一条曲线返回。
|
||||
// 独立进度也保证两页尺寸相同时仍播放过渡,不能靠高度差推导。
|
||||
const progress = ref(0);
|
||||
const lessonProgress = ref(0);
|
||||
const compactAlpha = computed(() => Math.max(0, 1 - progress.value * 1.5) * (1 - lessonProgress.value));
|
||||
const detailsAlpha = computed(() => progress.value * progress.value * (1 - lessonProgress.value));
|
||||
const path = computed(() => squirclePath(morph.value.width, morph.value.height, morph.value.radius));
|
||||
const style = computed<CSSProperties>(() => ({
|
||||
width: `${morph.value.width}px`,
|
||||
@@ -80,9 +111,10 @@ const style = computed<CSSProperties>(() => ({
|
||||
'--resident-scale': fit.value.scale,
|
||||
'--custom-row-gap': `${scheme.value.spacing}px`,
|
||||
'--resident-row-width': `${Math.max(60, available.value.width / scheme.value.scale - 58)}px`,
|
||||
'--secondary': `${props.appearance.fontSize * 0.78}px`,
|
||||
'--emphasized': `${props.appearance.fontSize * 1.1}px`,
|
||||
'--large': `${props.appearance.fontSize * 1.3}px`,
|
||||
'--secondary': `${(props.appearance.fontSize / 13) * 14}px`,
|
||||
'--emphasized': `${(props.appearance.fontSize / 13) * 18}px`,
|
||||
'--large': `${(props.appearance.fontSize / 13) * 20}px`,
|
||||
'--ci-body-font': `${(props.appearance.fontSize / 13) * 16}px`,
|
||||
'--resident-fade-duration': motion.value ? `${200 / props.appearance.speed}ms` : '0ms',
|
||||
}));
|
||||
let morphFrame = 0;
|
||||
@@ -90,7 +122,7 @@ let morphFallback: ReturnType<typeof setTimeout> | undefined;
|
||||
function animate() {
|
||||
const size = fit.value;
|
||||
const radius = Math.min(
|
||||
expanded.value ? props.appearance.radius * 2.4 : props.appearance.radius,
|
||||
lesson.value ? 8 : expanded.value ? props.appearance.radius : scheme.value.radius,
|
||||
size.width / 2,
|
||||
size.height / 2,
|
||||
);
|
||||
@@ -100,14 +132,21 @@ function animate() {
|
||||
morphFrame = 0;
|
||||
clearTimeout(morphFallback);
|
||||
for (const key of ['width', 'height', 'radius'] as const) springs[key].snap(target[key]);
|
||||
springs.content.snap(expanded.value ? 1 : 0);
|
||||
springs.lesson.snap(lesson.value ? 1 : 0);
|
||||
progress.value = springs.content.value;
|
||||
lessonProgress.value = springs.lesson.value;
|
||||
morph.value = target;
|
||||
void nextTick(reportHit);
|
||||
};
|
||||
if (!motion.value || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
// 应用内动画开关与经典岛保持一致;系统减少动态效果不能再次覆盖用户已开启的动画。
|
||||
if (!motion.value) {
|
||||
snap();
|
||||
return;
|
||||
}
|
||||
for (const key of ['width', 'height', 'radius'] as const) springs[key].setTarget(target[key]);
|
||||
springs.content.setTarget(expanded.value ? 1 : 0);
|
||||
springs.lesson.setTarget(lesson.value ? 1 : 0);
|
||||
clearTimeout(morphFallback);
|
||||
morphFallback = setTimeout(snap, 1200 / props.appearance.speed);
|
||||
if (morphFrame) return;
|
||||
@@ -120,6 +159,8 @@ function animate() {
|
||||
.map((s) => s.step(dt))
|
||||
.some(Boolean);
|
||||
morph.value = { width: springs.width.value, height: springs.height.value, radius: springs.radius.value };
|
||||
progress.value = springs.content.value;
|
||||
lessonProgress.value = springs.lesson.value;
|
||||
if (moving) morphFrame = requestAnimationFrame(frame);
|
||||
else {
|
||||
morphFrame = 0;
|
||||
@@ -200,7 +241,11 @@ function measure() {
|
||||
width: Math.max(40, element.offsetWidth),
|
||||
height: Math.max(40, element.offsetHeight),
|
||||
});
|
||||
natural.value = { compact: read(compact.value), details: read(details.value) };
|
||||
natural.value = {
|
||||
compact: read(compact.value),
|
||||
details: read(details.value),
|
||||
lesson: lessonSource.value ? read(lessonSource.value) : natural.value.lesson,
|
||||
};
|
||||
void nextTick(() => {
|
||||
window.island?.reportSize({ width: fit.value.width, height: fit.value.height });
|
||||
trackHit();
|
||||
@@ -221,9 +266,12 @@ function visibility() {
|
||||
void nextTick(reportHit);
|
||||
}
|
||||
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
|
||||
watch([expanded, scheme, () => props.state.listExpanded, messages], measure, { deep: true, flush: 'post' });
|
||||
watch([expanded, scheme, lesson, () => props.state.listExpanded, messages], measure, {
|
||||
deep: true,
|
||||
flush: 'post',
|
||||
});
|
||||
watch(
|
||||
[fit, motion, () => props.appearance.radius, () => props.appearance.speed],
|
||||
[fit, motion, expanded, lesson, () => props.appearance.radius, () => props.appearance.speed],
|
||||
() => {
|
||||
animate();
|
||||
trackHit();
|
||||
@@ -240,6 +288,7 @@ onMounted(() => {
|
||||
observer = new ResizeObserver(measure);
|
||||
if (compact.value) observer.observe(compact.value);
|
||||
if (details.value) observer.observe(details.value);
|
||||
if (lessonSource.value) observer.observe(lessonSource.value);
|
||||
clock = setInterval(() => (now.value = Date.now()), 1000);
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('visibilitychange', visibility);
|
||||
@@ -276,7 +325,8 @@ onUnmounted(() => {
|
||||
pill: !expanded,
|
||||
urgent: state.active?.priority === 'URGENT',
|
||||
call: state.active?.kind === 'call',
|
||||
'resident-separated': scheme.separated && !expanded,
|
||||
'resident-separated': scheme.separated && !expanded && !lesson,
|
||||
'resident-lesson': !!lesson,
|
||||
}"
|
||||
:style="style"
|
||||
:role="expanded ? 'region' : 'button'"
|
||||
@@ -306,7 +356,11 @@ onUnmounted(() => {
|
||||
<span class="tint" aria-hidden="true" />
|
||||
<div
|
||||
class="resident-page resident-compact-page"
|
||||
:style="{ '--resident-scale': compactFit.scale }"
|
||||
:style="{
|
||||
'--resident-scale': compactFit.scale,
|
||||
opacity: compactAlpha,
|
||||
visibility: compactAlpha > 0 ? 'visible' : 'hidden',
|
||||
}"
|
||||
:class="{ 'page-active': !expanded }"
|
||||
:inert="expanded"
|
||||
:aria-hidden="expanded"
|
||||
@@ -360,7 +414,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div
|
||||
class="resident-page resident-detail-page"
|
||||
:style="{ '--resident-scale': detailsFit.scale }"
|
||||
:style="{
|
||||
'--resident-scale': detailsFit.scale,
|
||||
opacity: detailsAlpha,
|
||||
visibility: detailsAlpha > 0 ? 'visible' : 'hidden',
|
||||
}"
|
||||
:class="{ 'page-active': expanded }"
|
||||
:inert="!expanded"
|
||||
:aria-hidden="!expanded"
|
||||
@@ -414,6 +472,26 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="resident-page resident-lesson-page"
|
||||
:style="{
|
||||
'--resident-scale': lessonFit.scale,
|
||||
opacity: lessonProgress,
|
||||
visibility: lessonProgress > 0 ? 'visible' : 'hidden',
|
||||
}"
|
||||
:aria-hidden="!lesson"
|
||||
:inert="!lesson"
|
||||
>
|
||||
<div ref="lessonSource" class="resident-source">
|
||||
<LessonReminderContent
|
||||
v-if="lastLesson"
|
||||
:reminder="lastLesson"
|
||||
:now="now"
|
||||
:appearance="appearance"
|
||||
:motion="motion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ embedded?: boolean; suspended?: boolean }>();
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_SHADOW_PAD,
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
import { SpringValue } from './spring.js';
|
||||
import { squirclePath } from './squircle.js';
|
||||
import ClassicNotifications from './ClassicNotifications.vue';
|
||||
import LessonReminderContent from './LessonReminderContent.vue';
|
||||
const now = ref(Date.now());
|
||||
let clock: ReturnType<typeof setInterval>;
|
||||
onUnmounted(() => clearInterval(clock));
|
||||
|
||||
/**
|
||||
* 灵动岛渲染进程 —— 视觉与排版参考 WinIsland(Rust/Direct2D 的 Windows 动态岛)。
|
||||
@@ -184,6 +188,7 @@ const EXTRA = ISLAND_SIZE_DELTA;
|
||||
function targetSize(): { width: number; height: number } {
|
||||
const width = appearance.value.width;
|
||||
const height = appearance.value.height;
|
||||
if (state.value.lessonReminder) return { width: width + EXTRA.expanded.width, height: 40 };
|
||||
if (mode.value === 'hidden') {
|
||||
// 有"空闲细缝"时才收成细缝;否则保持胶囊尺寸——
|
||||
// 窗口此时是隐藏的,没必要缩到细缝(否则下次弹出会从细缝"长大",多出入场动画)。
|
||||
@@ -200,6 +205,7 @@ function targetSize(): { width: number; height: number } {
|
||||
|
||||
/** 圆角:胶囊取 h/2(满圆角),展开卡取 min(48*系数, w/2, h/2)(WinIsland expanded_island_radius) */
|
||||
function targetRadius(size: { width: number; height: number }): number {
|
||||
if (state.value.lessonReminder) return 8;
|
||||
if (mode.value !== 'expanded') return Math.min(appearance.value.radius, size.height / 2);
|
||||
const scaled = (48 * appearance.value.radius) / 20;
|
||||
return Math.max(0, Math.min(scaled, size.width / 2, size.height / 2));
|
||||
@@ -338,9 +344,11 @@ const effectiveRadius = computed(() =>
|
||||
const cardPath = computed(() => squirclePath(morph.value.width, morph.value.height, effectiveRadius.value));
|
||||
|
||||
/** 胶囊内容透明度:进度到 2/3 时完全退净(WinIsland MINI_FADE_RATE = 1.5) */
|
||||
const pillAlpha = computed(() => Math.max(0, Math.min(1, 1 - progress.value * 1.5)));
|
||||
const pillAlpha = computed(() =>
|
||||
state.value.lessonReminder ? 0 : Math.max(0, Math.min(1, 1 - progress.value * 1.5)),
|
||||
);
|
||||
/** 展开内容透明度:进度平方(WinIsland expanded_alpha = progress²) */
|
||||
const expandedAlpha = computed(() => progress.value * progress.value);
|
||||
const expandedAlpha = computed(() => (state.value.lessonReminder ? 0 : progress.value * progress.value));
|
||||
|
||||
/**
|
||||
* 岛在**固定窗口**内的定位(照搬 WinIsland:窗口不动,岛在画布内居中/贴边形变)。
|
||||
@@ -378,7 +386,9 @@ const islandStyle = computed(() => {
|
||||
const islandClass = computed(() => ({
|
||||
pill: mode.value === 'pill' || (mode.value === 'hidden' && !appearance.value.idleSliver),
|
||||
expanded: mode.value === 'expanded',
|
||||
'hidden-placeholder': mode.value === 'hidden' && !appearance.value.idleSliver,
|
||||
'hidden-placeholder':
|
||||
mode.value === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder,
|
||||
'ci-lesson-card': !!state.value.lessonReminder,
|
||||
sliver: mode.value === 'hidden' && appearance.value.idleSliver,
|
||||
urgent: isUrgent.value,
|
||||
call: isCall.value,
|
||||
@@ -429,7 +439,8 @@ function reportHitRect(rect: DOMRect | null): void {
|
||||
hitRectTrailingTimer = null;
|
||||
lastHitRectAt = performance.now();
|
||||
const node = document.querySelector('.island-card') as HTMLElement | null;
|
||||
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
|
||||
const fullyHidden =
|
||||
state.value?.mode === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder;
|
||||
const latest = node && !fullyHidden ? node.getBoundingClientRect() : null;
|
||||
const latestPayload = latest
|
||||
? { x: latest.left, y: latest.top, width: latest.width, height: latest.height }
|
||||
@@ -489,7 +500,8 @@ function updateInteractive(clientX: number, clientY: number): void {
|
||||
function refreshInteractive(): void {
|
||||
if (props.suspended) return;
|
||||
const node = document.querySelector('.island-card') as HTMLElement | null;
|
||||
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
|
||||
const fullyHidden =
|
||||
state.value?.mode === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder;
|
||||
reportHitRect(node && !fullyHidden ? node.getBoundingClientRect() : null);
|
||||
if (fullyHidden) {
|
||||
// 完全隐藏:本地复位即可(不下发关闭,窗口隐藏后由主进程轮询兜底关掉命中)
|
||||
@@ -520,6 +532,7 @@ watch(
|
||||
() => syncTargets(true),
|
||||
);
|
||||
onMounted(async () => {
|
||||
clock = setInterval(() => (now.value = Date.now()), 1000);
|
||||
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
|
||||
/**
|
||||
* 触摸屏上报:主进程据此把窗口改为**贴合岛体**并始终接收输入。
|
||||
@@ -653,6 +666,14 @@ onMounted(async () => {
|
||||
:style="{ opacity: expandedAlpha }"
|
||||
class="layer"
|
||||
/>
|
||||
<LessonReminderContent
|
||||
v-if="state.lessonReminder"
|
||||
:reminder="state.lessonReminder"
|
||||
:now="now"
|
||||
:appearance="appearance"
|
||||
:motion="appearance.animations && !props.suspended"
|
||||
class="ci-fixed-lesson"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import type { IslandAppearance, IslandLessonReminder } from '@classhelper/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
reminder: IslandLessonReminder;
|
||||
now: number;
|
||||
appearance: IslandAppearance;
|
||||
motion: boolean;
|
||||
}>();
|
||||
const root = ref<HTMLElement>();
|
||||
const width = ref(480);
|
||||
const mask = computed(() => props.now < props.reminder.maskUntil);
|
||||
const title = computed(
|
||||
() => ({ prepare: '即将上课', 'on-class': '上课', break: '课间休息' })[props.reminder.kind],
|
||||
);
|
||||
const leftSeconds = computed(() =>
|
||||
Math.max(0, Math.ceil((props.reminder.lessonStartsAt - props.now) / 1000)),
|
||||
);
|
||||
const progress = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(props.reminder.endsAt - props.now) / Math.max(1, props.reminder.endsAt - props.reminder.startedAt),
|
||||
),
|
||||
),
|
||||
);
|
||||
const course = computed(() =>
|
||||
props.reminder.kind === 'break' ? props.reminder.nextCourseName : props.reminder.courseName,
|
||||
);
|
||||
const time = computed(() =>
|
||||
props.reminder.kind === 'break'
|
||||
? `${props.reminder.nextStartTime || ''}–${props.reminder.nextEndTime || ''}`
|
||||
: `${props.reminder.startTime}–${props.reminder.endTime}`,
|
||||
);
|
||||
const regions = ref([0, 0, 0, 0, 0]);
|
||||
let frame = 0;
|
||||
// ClassIsland SlantedMaskControl:5 个斜切区域、30°偏移、两侧向中央打开,中央向两侧关闭。
|
||||
const polygons = computed(() => {
|
||||
const offset = Math.tan(Math.PI / 6) * 40;
|
||||
const total = width.value + offset;
|
||||
const edges = [0, 0.1, 0.3, 0.7, 0.9, 1];
|
||||
return regions.value.map((p, i) => {
|
||||
const center = (total * (edges[i]! + edges[i + 1]!)) / 2;
|
||||
const half = ((total * (edges[i + 1]! - edges[i]!) + 0.5) * p) / 2;
|
||||
return `${center - half},0 ${center + half},0 ${center + half - offset},40 ${center - half - offset},40`;
|
||||
});
|
||||
});
|
||||
function animateMask() {
|
||||
cancelAnimationFrame(frame);
|
||||
const target = mask.value ? 1 : 0;
|
||||
if (!props.motion) {
|
||||
regions.value = [target, target, target, target, target];
|
||||
return;
|
||||
}
|
||||
const from = [...regions.value];
|
||||
const started = performance.now();
|
||||
const delays = mask.value ? [0, 90, 120, 90, 0] : [120, 90, 0, 90, 120];
|
||||
const update = (at: number) => {
|
||||
const elapsed = (at - started) * props.appearance.speed;
|
||||
regions.value = from.map((value, i) => {
|
||||
const p = Math.max(0, Math.min(1, (elapsed - delays[i]!) / 360));
|
||||
const eased = target ? 1 - (1 - p) ** 4 : p * p;
|
||||
return value + (target - value) * eased;
|
||||
});
|
||||
if (elapsed < 480) frame = requestAnimationFrame(update);
|
||||
};
|
||||
frame = requestAnimationFrame(update);
|
||||
}
|
||||
watch(
|
||||
[mask, () => props.reminder.id, () => props.motion, () => props.appearance.speed],
|
||||
() => {
|
||||
if (mask.value) regions.value = [0, 0, 0, 0, 0];
|
||||
animateMask();
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
let observer: ResizeObserver | undefined;
|
||||
onMounted(() => {
|
||||
observer = new ResizeObserver(() => {
|
||||
if (root.value) width.value = root.value.offsetWidth;
|
||||
});
|
||||
if (root.value) observer.observe(root.value);
|
||||
void nextTick(animateMask);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="ci-lesson"
|
||||
:class="{ 'ci-mask-active': mask }"
|
||||
:data-kind="reminder.kind"
|
||||
:style="{
|
||||
'--ci-speed': appearance.speed,
|
||||
'--ci-duration': motion ? `${200 / appearance.speed}ms` : '0ms',
|
||||
'--ci-title-duration': motion ? `${250 / appearance.speed}ms` : '0ms',
|
||||
'--ci-scale-duration': motion ? `${750 / appearance.speed}ms` : '0ms',
|
||||
'--ci-delay': motion ? `${260 / appearance.speed}ms` : '0ms',
|
||||
}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="ci-lesson-info">
|
||||
<span v-if="reminder.kind === 'prepare'" class="ci-countdown">
|
||||
距上课还剩
|
||||
<strong>{{ leftSeconds }}</strong>
|
||||
秒
|
||||
</span>
|
||||
<span v-else class="ci-countdown">课间休息</span>
|
||||
<span class="ci-lesson-course">
|
||||
<span>下节课是:</span>
|
||||
<strong>{{ course }}</strong>
|
||||
<small>{{ time }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ci-lesson-progress" :style="{ transform: `scaleX(${progress})` }" />
|
||||
<svg class="ci-mask" :viewBox="`0 0 ${width} 40`" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polygon v-for="(points, i) in polygons" :key="i" :points="points" />
|
||||
</svg>
|
||||
<div class="ci-mask-title" :class="{ 'ci-title-in': mask }">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M6 9a6 6 0 0 1 12 0v4l2 3H4l2-3V9Zm4 10a2 2 0 0 0 4 0" />
|
||||
</svg>
|
||||
<strong>{{ title }}</strong>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ci-lesson {
|
||||
position: relative;
|
||||
width: max-content;
|
||||
min-width: 300px;
|
||||
max-width: 800px;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
border-radius: 8px;
|
||||
background: #20202080;
|
||||
}
|
||||
.ci-lesson-info {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 0 28px;
|
||||
white-space: nowrap;
|
||||
transition: opacity var(--ci-duration) ease-in;
|
||||
}
|
||||
.ci-mask-active .ci-lesson-info {
|
||||
opacity: 0;
|
||||
}
|
||||
.ci-countdown {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ci-countdown strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ci-lesson-course {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ci-lesson-course strong {
|
||||
font-size: 20px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ci-lesson-course small {
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ci-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
fill: var(--island-accent, #60a5fa);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ci-mask-title {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
font-size: 18px;
|
||||
transform: scale(1.1);
|
||||
transition:
|
||||
opacity var(--ci-duration) linear,
|
||||
transform var(--ci-duration) cubic-bezier(0.5, 0, 0.75, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ci-mask-title svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.ci-title-in {
|
||||
animation:
|
||||
ci-title-enter var(--ci-title-duration) var(--ci-delay) cubic-bezier(0.25, 1, 0.5, 1) both,
|
||||
ci-title-scale var(--ci-scale-duration) var(--ci-delay) cubic-bezier(0.25, 1, 0.5, 1) both;
|
||||
}
|
||||
.ci-mask-title:not(.ci-title-in) {
|
||||
animation: ci-title-leave var(--ci-duration) linear both;
|
||||
}
|
||||
@keyframes ci-title-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes ci-title-scale {
|
||||
from {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes ci-title-leave {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
.ci-lesson-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--island-accent, #60a5fa);
|
||||
transform-origin: left;
|
||||
transition: transform 50ms linear;
|
||||
}
|
||||
</style>
|
||||
@@ -84,6 +84,16 @@ body {
|
||||
.island-card.hidden-placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
.island-card.ci-lesson-card .shape path {
|
||||
fill: transparent;
|
||||
stroke: none;
|
||||
}
|
||||
.island-card .ci-fixed-lesson {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */
|
||||
.island-card .shape {
|
||||
|
||||
@@ -44,18 +44,10 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity var(--resident-fade-duration) cubic-bezier(0.47, 0, 0.745, 0.715),
|
||||
visibility 0s var(--resident-fade-duration);
|
||||
}
|
||||
.resident-card .resident-page.page-active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
.resident-card .resident-source {
|
||||
width: max-content;
|
||||
@@ -83,6 +75,7 @@
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
min-height: max(40px, calc(var(--island-font) * 2.5));
|
||||
font-size: var(--ci-body-font, 16px);
|
||||
}
|
||||
.resident-window .resident-card.resident-separated {
|
||||
background: transparent;
|
||||
@@ -152,7 +145,7 @@
|
||||
color: #ffffff80;
|
||||
}
|
||||
.resident-card .resident-bell span {
|
||||
animation: resident-indicator-in 250ms cubic-bezier(0.25, 1, 0.5, 1);
|
||||
animation: resident-indicator-in var(--resident-fade-duration) cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
.resident-card .resident-details {
|
||||
min-width: 160px;
|
||||
@@ -183,6 +176,7 @@
|
||||
display: block;
|
||||
}
|
||||
.resident-card .resident-expanded-rows {
|
||||
font-size: var(--ci-body-font, 16px);
|
||||
border-top: 1px solid #ffffff1a;
|
||||
padding-top: 8px;
|
||||
}
|
||||
@@ -202,15 +196,6 @@
|
||||
filter: brightness(1.18);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.resident-window .resident-card,
|
||||
.resident-card .resident-page {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
.resident-card .resident-bell span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@keyframes resident-indicator-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -253,3 +238,15 @@
|
||||
.resident-card.resident-separated > .tint {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* ClassIsland Fluent MainWindowLine:半透明底、1px 边框,强调遮罩只在提醒时播放。 */
|
||||
.resident-window .resident-card {
|
||||
--wn-bg: #20202080;
|
||||
--wn-border: #ffffff26;
|
||||
}
|
||||
.resident-window .resident-card > .tint {
|
||||
display: none;
|
||||
}
|
||||
.resident-card.resident-lesson .shape path {
|
||||
fill: transparent;
|
||||
stroke: none;
|
||||
}
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
type IslandMode,
|
||||
type IslandNotification,
|
||||
type IslandState,
|
||||
type IslandLessonReminder,
|
||||
} from '@classhelper/shared';
|
||||
import { logger } from './logger.js';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { LessonReminderTracker } from './lesson-reminder.js';
|
||||
|
||||
/** 生产环境的灵动岛页面地址(导航白名单的精确比对用) */
|
||||
const prodIslandUrl = pathToFileURL(path.join(__dirname, '../renderer/island.html')).href;
|
||||
@@ -158,6 +160,37 @@ interface IslandActionPayload {
|
||||
}
|
||||
|
||||
class IslandController {
|
||||
private lessonTracker = new LessonReminderTracker();
|
||||
private lessonCue: IslandLessonReminder | null = null;
|
||||
private lessonTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lessonTestTime: number | null = null;
|
||||
private get lessonShowing(): boolean {
|
||||
return Boolean(
|
||||
this.lessonCue &&
|
||||
!this.editingLayout &&
|
||||
!this.previewId &&
|
||||
!(
|
||||
this.state.active &&
|
||||
(this.state.mode === 'expanded' ||
|
||||
(this.state.active.priority === 'URGENT' && this.state.mode !== 'hidden'))
|
||||
),
|
||||
);
|
||||
}
|
||||
/** 冒烟只在隔离配置中推进时钟,不修改操作系统时间。 */
|
||||
setLessonReminderTestTime(at: number | null): void {
|
||||
if (process.env.ELECTRON_SMOKE_TEST !== '1') return;
|
||||
this.lessonTestTime = at;
|
||||
this.refreshLessonReminder();
|
||||
}
|
||||
private refreshLessonReminder(): void {
|
||||
const next = this.lessonTracker.update(this.schedules, new Date(this.lessonTestTime ?? Date.now()));
|
||||
if (JSON.stringify(next) === JSON.stringify(this.lessonCue)) return;
|
||||
this.lessonCue = next;
|
||||
// 触摸窗口先放得下新横条,再下发内容;混合宽高变化也只能增大画布。
|
||||
if (this.lessonShowing) this.syncTouchLayout();
|
||||
this.emit();
|
||||
this.syncWindow();
|
||||
}
|
||||
private win: BrowserWindow | null = null;
|
||||
private rendererUrl: string | null = null;
|
||||
private ready = false;
|
||||
@@ -582,6 +615,7 @@ class IslandController {
|
||||
await win.loadFile(path.join(__dirname, '../renderer/island.html'));
|
||||
}
|
||||
this.ready = true;
|
||||
if (!this.lessonTimer) this.lessonTimer = setInterval(() => this.refreshLessonReminder(), 1000);
|
||||
if (this.pendingState) {
|
||||
this.emit(this.pendingState);
|
||||
this.pendingState = null;
|
||||
@@ -607,7 +641,11 @@ class IslandController {
|
||||
}
|
||||
|
||||
getState(): IslandState {
|
||||
return { ...this.state, queued: [...this.state.queued] };
|
||||
return {
|
||||
...this.state,
|
||||
lessonReminder: this.lessonShowing ? this.lessonCue : null,
|
||||
queued: [...this.state.queued],
|
||||
};
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
@@ -805,6 +843,7 @@ class IslandController {
|
||||
setClassState(payload: IslandClassStatePayload): void {
|
||||
if (Array.isArray(payload.schedules)) {
|
||||
this.schedules = payload.schedules.slice(0, 100);
|
||||
this.refreshLessonReminder();
|
||||
if (this.ready && this.win && !this.win.isDestroyed())
|
||||
this.win.webContents.send('island:schedules', this.schedules);
|
||||
}
|
||||
@@ -1116,6 +1155,10 @@ class IslandController {
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.lessonTimer) clearInterval(this.lessonTimer);
|
||||
this.lessonTimer = null;
|
||||
this.lessonCue = null;
|
||||
this.lessonTracker = new LessonReminderTracker();
|
||||
if (this.residentShapeTimer) clearTimeout(this.residentShapeTimer);
|
||||
this.residentShapeTimer = null;
|
||||
this.clearTimers();
|
||||
@@ -1362,6 +1405,16 @@ class IslandController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.lessonShowing) {
|
||||
this.cancelFade();
|
||||
if (!this.win.isVisible()) this.applyWindowLayout();
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual'))
|
||||
this.win.showInactive();
|
||||
this.syncHitFromCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知 / 叫人"允许出现在屏幕上)
|
||||
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
|
||||
// (胶囊态也允许,学生收起后还能再点开;普通通知则一律隐藏)
|
||||
@@ -1435,6 +1488,13 @@ class IslandController {
|
||||
height: this.customSize.height + 2 * ISLAND_SHADOW_PAD,
|
||||
};
|
||||
const size = this.formSize();
|
||||
if (this.touchMode && this.lessonShowing) {
|
||||
const current = this.win?.isVisible() ? this.win.getBounds() : null;
|
||||
return {
|
||||
width: Math.round(Math.max(size.width + 2 * ISLAND_SHADOW_PAD, current?.width ?? 0)),
|
||||
height: Math.round(Math.max(size.height + 2 * ISLAND_SHADOW_PAD, current?.height ?? 0)),
|
||||
};
|
||||
}
|
||||
if (!this.touchMode) {
|
||||
return {
|
||||
width: Math.round(Math.max(BBOX_SIZE.width, size.width + ISLAND_SHADOW_PAD * 2)),
|
||||
@@ -1760,6 +1820,7 @@ class IslandController {
|
||||
this.residentEnabled &&
|
||||
settings.pointerFadeEnabled &&
|
||||
!this.editingLayout &&
|
||||
!this.lessonShowing &&
|
||||
Date.now() >= this.residentTouchUntil &&
|
||||
Date.now() >= this.residentMotionUntil &&
|
||||
this.state.mode !== 'expanded' &&
|
||||
@@ -1848,6 +1909,7 @@ class IslandController {
|
||||
/** 当前形态下**岛本身**的尺寸(不是窗口尺寸;响应进程的 `.island-card` 宽高) */
|
||||
private formSize(): { width: number; height: number } {
|
||||
if (this.customLayout) return this.customSize;
|
||||
if (this.lessonShowing) return { width: SIZES.expanded.width, height: 40 };
|
||||
// 多条通知的列表形态:高度由统一布局算法算出(渲染进程算的是同一个数)
|
||||
const layout = this.listLayout();
|
||||
if (layout) return { width: SIZES.expanded.width, height: layout.height };
|
||||
@@ -1921,6 +1983,10 @@ class IslandController {
|
||||
this.setInteractive(false, '窗口不可见');
|
||||
return;
|
||||
}
|
||||
if (this.lessonShowing) {
|
||||
this.setInteractive(false, '课表提醒不遮挡桌面操作');
|
||||
return;
|
||||
}
|
||||
// 完全隐藏(含淡出过程中):一律不再接收鼠标,避免窗口在消失的几百毫秒里吞掉桌面点击。
|
||||
// 空闲细缝态(idleSliver)是"可见即可点",不走这条。
|
||||
if (this.state.mode === 'hidden' && !this.appearance.idleSliver && !this.customLayout) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
dayKeyLocal,
|
||||
resolveClassStatus,
|
||||
type IslandLessonReminder,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
|
||||
/**
|
||||
* 来源:ClassIsland/Services/NotificationProviders/ClassNotificationProvider.cs。
|
||||
* 室内课提前 60 秒,准备遮罩 3 秒,上课 5 秒,下课遮罩 2 秒后预告下节课 10 秒。
|
||||
* 只消费服务端已经解析的日课表投影;不在这里重写 ClassPlan 启用或轮换规则。
|
||||
*/
|
||||
export class LessonReminderTracker {
|
||||
private current: IslandLessonReminder | null = null;
|
||||
|
||||
update(schedules: ScheduleDto[], now: Date): IslandLessonReminder | null {
|
||||
const at = now.getTime();
|
||||
const keyOf = (s: ScheduleDto, kind: IslandLessonReminder['kind']) =>
|
||||
`${dayKeyLocal(now)}:${s.id}:${s.startTime}:${s.endTime}:${kind}`;
|
||||
const status = resolveClassStatus(schedules, now);
|
||||
const timeAt = (time: string) => {
|
||||
const d = new Date(now);
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
d.setHours(h!, m!, 0, 0);
|
||||
return d.getTime();
|
||||
};
|
||||
const today = schedules.filter((s) => s.dayOfWeek === (now.getDay() || 7));
|
||||
const current = today.find((s) => s.id === status.current?.scheduleId);
|
||||
const next = today.find((s) => s.id === status.next?.scheduleId);
|
||||
const ended = today
|
||||
.filter((s) => at >= timeAt(s.endTime) && at - timeAt(s.endTime) < 12_000)
|
||||
.sort((a, b) => b.endTime.localeCompare(a.endTime))[0];
|
||||
let kind: IslandLessonReminder['kind'] | null = null;
|
||||
let lesson: ScheduleDto | undefined;
|
||||
if (current && at - timeAt(current.startTime) < 5_000) {
|
||||
kind = 'on-class';
|
||||
lesson = current;
|
||||
} else if (
|
||||
!current &&
|
||||
ended &&
|
||||
at - timeAt(ended.endTime) < (next ? 12_000 : 5_000) &&
|
||||
(at - timeAt(ended.endTime) < 5_000 || this.current?.id === keyOf(ended, 'break'))
|
||||
) {
|
||||
kind = 'break';
|
||||
lesson = ended;
|
||||
} else if (!current && next && timeAt(next.startTime) - at <= 60_000) {
|
||||
kind = 'prepare';
|
||||
lesson = next;
|
||||
}
|
||||
if (!lesson || !kind) return (this.current = null);
|
||||
const id = keyOf(lesson, kind);
|
||||
const entered = this.current?.id === id && at >= this.current.startedAt ? this.current.startedAt : at;
|
||||
const start = timeAt(lesson.startTime);
|
||||
const end = timeAt(lesson.endTime);
|
||||
return (this.current = {
|
||||
id,
|
||||
kind,
|
||||
courseName: lesson.course?.name || '课程',
|
||||
startTime: lesson.startTime,
|
||||
endTime: lesson.endTime,
|
||||
startedAt: entered,
|
||||
maskUntil:
|
||||
kind === 'prepare'
|
||||
? Math.min(start, entered + 3000)
|
||||
: kind === 'break' && next
|
||||
? entered + 2000
|
||||
: entered + 5000,
|
||||
endsAt: kind === 'prepare' ? start : kind === 'on-class' ? start + 5000 : end + (next ? 12_000 : 5000),
|
||||
lessonStartsAt: start,
|
||||
nextCourseName: kind === 'break' ? next?.course?.name : undefined,
|
||||
nextStartTime: kind === 'break' ? next?.startTime : undefined,
|
||||
nextEndTime: kind === 'break' ? next?.endTime : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
islandNode,
|
||||
normalizeIslandLayouts,
|
||||
ISLAND_SHADOW_PAD,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { island } from './island.js';
|
||||
import { LessonReminderTracker } from './lesson-reminder.js';
|
||||
|
||||
/** 常驻岛始终用隔离的冒烟配置和本地消息;不读取或标记真实班级通知。 */
|
||||
export async function verifyResidentSmoke(
|
||||
@@ -52,6 +54,71 @@ export async function verifyResidentSmoke(
|
||||
const expandedText = islandNode('text', 'resident-expanded-text');
|
||||
expandedText.settings.text = '今日安排';
|
||||
const scheme = fixture.schemes[0]!;
|
||||
const dateAt = (h: number, m: number, s = 0) => {
|
||||
const date = new Date();
|
||||
date.setHours(h, m, s, 0);
|
||||
return date;
|
||||
};
|
||||
const lessonFixtures: ScheduleDto[] = [
|
||||
{
|
||||
id: 'lesson-math',
|
||||
classId: 'local',
|
||||
courseId: 'math',
|
||||
course: { id: 'math', name: '数学' },
|
||||
dayOfWeek: new Date().getDay() || 7,
|
||||
startTime: '09:00',
|
||||
endTime: '09:45',
|
||||
location: null,
|
||||
weekStart: 1,
|
||||
weekEnd: 40,
|
||||
weekParity: 'ALL',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'lesson-chinese',
|
||||
classId: 'local',
|
||||
courseId: 'chinese',
|
||||
course: { id: 'chinese', name: '语文' },
|
||||
dayOfWeek: new Date().getDay() || 7,
|
||||
startTime: '09:55',
|
||||
endTime: '10:40',
|
||||
location: null,
|
||||
weekStart: 1,
|
||||
weekEnd: 40,
|
||||
weekParity: 'ALL',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
await check('课前60秒、上课与下课时间边界正确,刷新和改课不重复重置提醒', () => {
|
||||
const tracker = new LessonReminderTracker();
|
||||
if (tracker.update(lessonFixtures, dateAt(8, 58, 59))) return false;
|
||||
const prepare = tracker.update(lessonFixtures, dateAt(8, 59));
|
||||
const repeat = tracker.update(lessonFixtures, dateAt(8, 59, 5));
|
||||
const renamed = lessonFixtures.map((s) => ({ ...s, course: { ...s.course!, name: '临时换课' } }));
|
||||
const changed = tracker.update(renamed, dateAt(8, 59, 6));
|
||||
const onClass = tracker.update(lessonFixtures, dateAt(9, 0));
|
||||
const noLate = tracker.update(lessonFixtures, dateAt(9, 0, 6));
|
||||
const breaking = tracker.update(lessonFixtures, dateAt(9, 45));
|
||||
const after = tracker.update(lessonFixtures, dateAt(9, 45, 13));
|
||||
const lastBreak = tracker.update(lessonFixtures, dateAt(10, 40));
|
||||
const end = tracker.update(lessonFixtures, dateAt(10, 40, 6));
|
||||
const removed = tracker.update([], dateAt(8, 59));
|
||||
return (
|
||||
prepare?.kind === 'prepare' &&
|
||||
prepare.maskUntil - prepare.startedAt === 3000 &&
|
||||
repeat?.startedAt === prepare.startedAt &&
|
||||
changed?.courseName === '临时换课' &&
|
||||
changed.maskUntil === prepare.maskUntil &&
|
||||
onClass?.kind === 'on-class' &&
|
||||
!noLate &&
|
||||
breaking?.kind === 'break' &&
|
||||
breaking.nextCourseName === '语文' &&
|
||||
!after &&
|
||||
lastBreak?.kind === 'break' &&
|
||||
!end &&
|
||||
!removed
|
||||
);
|
||||
});
|
||||
scheme.rows[0]!.nodes = [
|
||||
islandNode('clock', 'resident-clock'),
|
||||
islandNode('separator', 'resident-separator'),
|
||||
@@ -299,6 +366,10 @@ export async function verifyResidentSmoke(
|
||||
return false;
|
||||
};
|
||||
const immediate = (await waitForCourse()) && Date.now() - started < 600;
|
||||
const aligned = await win.webContents.executeJavaScript(`(() => {
|
||||
const name=document.querySelector('.resident-compact .schedule strong')?.getBoundingClientRect(), time=document.querySelector('.resident-compact .lesson-time')?.getBoundingClientRect();
|
||||
return Boolean(name&&time&&time.left>=name.right&&Math.abs(time.top+time.height/2-name.top-name.height/2)<=2);
|
||||
})()`);
|
||||
await win.webContents.reload();
|
||||
await pause(800);
|
||||
const restored = await waitForCourse();
|
||||
@@ -307,28 +378,43 @@ export async function verifyResidentSmoke(
|
||||
island.syncCustomLayout();
|
||||
await pause(800);
|
||||
island.setClassState({ inClass: true, currentPeriodEnd: null, week: 1, schedules: [] });
|
||||
return immediate && restored && snapshot[0]?.course?.name === '实时课程';
|
||||
return immediate && aligned && restored && snapshot[0]?.course?.name === '实时课程';
|
||||
});
|
||||
await check('常驻岛开合复用固定画布,动画中没有滚动容器或旧胶囊形变', async () => {
|
||||
const bounds = new Set<string>();
|
||||
const timer = setInterval(() => bounds.add(JSON.stringify(win.getBounds())), 20);
|
||||
const recorded: Array<{ at: number; image: Electron.NativeImage }> = [];
|
||||
const capture = process.env.ISLAND_MOTION_CAPTURE === '1' && process.env.ELECTRON_SMOKE_MODE === 'visual';
|
||||
const started = Date.now();
|
||||
if (capture)
|
||||
win.webContents.beginFrameSubscription(true, (image) => {
|
||||
if (Date.now() - (recorded.at(-1)?.at ?? 0) >= 40) recorded.push({ at: Date.now(), image });
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
result = await win.webContents.executeJavaScript(`new Promise(resolve => {
|
||||
const frames=[], gaps=[]; let last=performance.now(), count=0;
|
||||
const frames=[], gaps=[], motion=[]; let last=performance.now(), count=0;
|
||||
const sample=()=>{
|
||||
const card=document.querySelector('.resident-card'), r=card?.getBoundingClientRect();
|
||||
if(!r){frames.push(false);return;}
|
||||
const nodes=[card,...card.querySelectorAll('*')];
|
||||
frames.push(r.left>=0&&r.top>=0&&r.right<=innerWidth+1&&r.bottom<=innerHeight+1&&nodes.every(n=>!['auto','scroll'].includes(getComputedStyle(n).overflowY)&&!['auto','scroll'].includes(getComputedStyle(n).overflowX)));
|
||||
motion.push({at:performance.now()-start,height:r.height,compact:Number(getComputedStyle(card.querySelector('.resident-compact-page')).opacity),details:Number(getComputedStyle(card.querySelector('.resident-detail-page')).opacity)});
|
||||
};
|
||||
const animate=time=>{gaps.push(time-last);last=time;count++;sample();if(time-start<1800)requestAnimationFrame(animate);};
|
||||
const start=performance.now();requestAnimationFrame(animate);
|
||||
window.island.sendAction('expand');setTimeout(()=>window.island.sendAction('collapse'),700);
|
||||
setTimeout(()=>{sample(); const card=document.querySelector('.resident-card');resolve({valid:frames.every(Boolean),count,gaps:gaps.filter(g=>g>0).sort((a,b)=>a-b),height:card.getBoundingClientRect().height,legacy:document.querySelector('.island-root')?.getBoundingClientRect().height===0});},1900);
|
||||
setTimeout(()=>{sample(); const card=document.querySelector('.resident-card');resolve({valid:frames.every(Boolean),count,motion,gaps:gaps.filter(g=>g>0).sort((a,b)=>a-b),height:card.getBoundingClientRect().height,legacy:document.querySelector('.island-root')?.getBoundingClientRect().height===0});},1900);
|
||||
})`);
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
if (capture) win.webContents.endFrameSubscription();
|
||||
}
|
||||
if (capture && process.env.ISLAND_SHOTS_DIR) {
|
||||
const dir = path.join(process.env.ISLAND_SHOTS_DIR, 'motion-frames');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
recorded.forEach((frame, i) => fs.writeFileSync(path.join(dir, `${i}.png`), frame.image.toPNG()));
|
||||
fs.writeFileSync(path.join(dir, 'times.json'), JSON.stringify(recorded.map((f) => f.at - started)));
|
||||
}
|
||||
if (process.env.ISLAND_SHOTS_DIR)
|
||||
fs.writeFileSync(
|
||||
@@ -344,9 +430,41 @@ export async function verifyResidentSmoke(
|
||||
result.legacy &&
|
||||
Math.abs(result.height - 40) <= 1 &&
|
||||
bounds.size === 1 &&
|
||||
(!visual || (result.count >= 20 && result.gaps[Math.floor(result.gaps.length * 0.95)] < 100)),
|
||||
(!visual ||
|
||||
(result.count >= 20 &&
|
||||
result.gaps[Math.floor(result.gaps.length * 0.95)] < 100 &&
|
||||
[
|
||||
result.motion.filter((f: { at: number }) => f.at < 700),
|
||||
result.motion.filter((f: { at: number }) => f.at >= 700),
|
||||
].every(
|
||||
(phase: Array<{ height: number; compact: number; details: number }>) =>
|
||||
new Set(phase.map((f) => Math.round(f.height))).size >= 3 &&
|
||||
phase.filter((f) => f.details > 0.01 && f.details < 0.95 && f.compact < 0.95).length >= 3,
|
||||
))),
|
||||
);
|
||||
});
|
||||
await check('关闭动画后外壳和内容同时落位,恢复动画无需重启', async () => {
|
||||
island.setAppearance({ animations: false });
|
||||
try {
|
||||
island.handleAction({ action: 'expand' });
|
||||
await pause(100);
|
||||
const opened = await win.webContents.executeJavaScript(`(() => {
|
||||
const card=document.querySelector('.resident-card');
|
||||
return card.getBoundingClientRect().height>40&&getComputedStyle(card.querySelector('.resident-detail-page')).opacity==='1'&&getComputedStyle(card.querySelector('.resident-compact-page')).opacity==='0';
|
||||
})()`);
|
||||
island.handleAction({ action: 'collapse' });
|
||||
await pause(100);
|
||||
return (
|
||||
opened &&
|
||||
(await win.webContents.executeJavaScript(`(() => {
|
||||
const card=document.querySelector('.resident-card');
|
||||
return Math.abs(card.getBoundingClientRect().height-40)<=1&&getComputedStyle(card.querySelector('.resident-compact-page')).opacity==='1'&&getComputedStyle(card.querySelector('.resident-detail-page')).opacity==='0';
|
||||
})()`))
|
||||
);
|
||||
} finally {
|
||||
island.setAppearance({ animations: true });
|
||||
}
|
||||
});
|
||||
await check('常驻岛连续快速开合后恢复40px且没有遗留过渡', async () => {
|
||||
const box = win.getBounds();
|
||||
const stable = await win.webContents.executeJavaScript(`new Promise(resolve=>{
|
||||
@@ -375,6 +493,106 @@ export async function verifyResidentSmoke(
|
||||
await pause(700);
|
||||
return Math.abs(immediate - opacity) < 0.01 && Math.abs(after - opacity) < 0.01;
|
||||
});
|
||||
await check('两种布局均弹出ClassIsland课前/上下课提醒,倒计时与右侧时间正确且不写入通知队列', async () => {
|
||||
const capture = process.env.ISLAND_MOTION_CAPTURE === '1' && process.env.ELECTRON_SMOKE_MODE === 'visual';
|
||||
const recorded: Array<{ at: number; image: Electron.NativeImage }> = [];
|
||||
const start = Date.now();
|
||||
let recording = false;
|
||||
const cueAppearance = island.getAppearance();
|
||||
const setTime = async (h: number, m: number, s = 0) => {
|
||||
const at = dateAt(h, m, s).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`,
|
||||
);
|
||||
for (const layout of ['custom', 'fixed'] as const) {
|
||||
saveConfig({ islandLayout: { ...fixture, layout } });
|
||||
island.syncCustomLayout();
|
||||
await setTime(8, 58, 59);
|
||||
island.setClassState({ inClass: false, schedules: lessonFixtures });
|
||||
await pause(1100);
|
||||
if (layout === 'fixed') {
|
||||
island.setAppearance({ idleSliver: true });
|
||||
island.setTouchMode(true);
|
||||
island.pushNotification(message('lesson-touch-canvas', '触摸画布验证', 'NORMAL'));
|
||||
await pause(600);
|
||||
island.handleAction({ action: 'dismiss-all' });
|
||||
await pause(600);
|
||||
}
|
||||
if (capture && layout === 'custom') {
|
||||
recording = true;
|
||||
win.webContents.beginFrameSubscription(true, (image) => {
|
||||
if (Date.now() - (recorded.at(-1)?.at ?? 0) >= 40) recorded.push({ at: Date.now(), image });
|
||||
});
|
||||
}
|
||||
await setTime(8, 59);
|
||||
await pause(800);
|
||||
if (island.getState().lessonReminder?.kind !== 'prepare') return false;
|
||||
const mask = await win.webContents.executeJavaScript(
|
||||
`(() => {const cue=document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'} .ci-mask')||document.querySelector('.ci-fixed-lesson .ci-mask');return Boolean(cue&&cue.querySelectorAll('polygon').length===5);})()`,
|
||||
);
|
||||
if (!mask) return false;
|
||||
await shot('lesson-prepare-mask-' + layout);
|
||||
await setTime(8, 59, 5);
|
||||
await pause(1100);
|
||||
const info = await win.webContents.executeJavaScript(`(() => {
|
||||
const cue=document.querySelector('${layout === 'custom' ? '.resident-lesson-page .ci-lesson' : '.ci-fixed-lesson'}');
|
||||
const name=cue?.querySelector('.ci-lesson-course strong')?.getBoundingClientRect(),time=cue?.querySelector('.ci-lesson-course small')?.getBoundingClientRect();
|
||||
const r=cue?.getBoundingClientRect();
|
||||
return Boolean(cue&&!cue.classList.contains('ci-mask-active')&&cue.textContent.includes('55')&&cue.textContent.includes('数学')&&cue.textContent.includes('09:00–09:45')&&name&&time&&time.left>=name.right&&r&&Math.abs(r.height-40)<=1&&r.left>=0&&r.right<=innerWidth&&r.bottom<=innerHeight);
|
||||
})()`);
|
||||
if (!info || island.getState().queued.length || island.getState().active) return false;
|
||||
await shot('lesson-prepare-info-' + layout);
|
||||
await setTime(9, 0);
|
||||
island.setClassState({ inClass: true });
|
||||
await pause(700);
|
||||
if (island.getState().lessonReminder?.kind !== 'on-class') return false;
|
||||
await shot('lesson-start-' + layout);
|
||||
if (recording) {
|
||||
win.webContents.endFrameSubscription();
|
||||
recording = false;
|
||||
}
|
||||
await setTime(9, 0, 6);
|
||||
await pause(700);
|
||||
if (island.getState().lessonReminder) return false;
|
||||
await setTime(9, 45);
|
||||
island.setClassState({ inClass: false });
|
||||
await pause(700);
|
||||
if (island.getState().lessonReminder?.kind !== 'break') return false;
|
||||
await setTime(9, 45, 3);
|
||||
await pause(1100);
|
||||
const next = await win.webContents.executeJavaScript(
|
||||
`document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'}')?.textContent.includes('语文')`,
|
||||
);
|
||||
if (!next) return false;
|
||||
await shot('lesson-break-' + layout);
|
||||
await setTime(9, 45, 13);
|
||||
if (island.getState().lessonReminder) return false;
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
if (recording) win.webContents.endFrameSubscription();
|
||||
if (capture && process.env.ISLAND_SHOTS_DIR) {
|
||||
const dir = path.join(process.env.ISLAND_SHOTS_DIR, 'lesson-motion-frames');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
recorded.forEach((f, i) => fs.writeFileSync(path.join(dir, `${i}.png`), f.image.toPNG()));
|
||||
fs.writeFileSync(path.join(dir, 'times.json'), JSON.stringify(recorded.map((f) => f.at - start)));
|
||||
}
|
||||
await win.webContents.executeJavaScript(
|
||||
`Date.now=window.__lessonRealNow;delete window.__lessonRealNow;delete window.__lessonSmokeTime`,
|
||||
);
|
||||
island.setLessonReminderTestTime(null);
|
||||
island.setClassState({ inClass: false, schedules: [] });
|
||||
island.setTouchMode(originalTouchMode);
|
||||
island.setAppearance(cueAppearance);
|
||||
saveConfig({ islandLayout: fixture });
|
||||
island.syncCustomLayout();
|
||||
await pause(800);
|
||||
}
|
||||
});
|
||||
await check('长文本多行与2.5倍字号自动适配,变窄窗口和底部右侧停靠均不溢出', async () => {
|
||||
const appearance = island.getAppearance();
|
||||
const dense = JSON.parse(JSON.stringify(fixture));
|
||||
|
||||
@@ -147,7 +147,7 @@ onMounted(async () => {
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">灵动岛</h2>
|
||||
<p class="page-subtitle">经典岛与自定义岛共用外观、停靠位置、动画与提醒规则</p>
|
||||
<p class="page-subtitle">自定义组件与 ClassIsland 提醒,共用停靠位置、动画开关与通知规则</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,13 +170,13 @@ onMounted(async () => {
|
||||
@click="chooseLayout('custom')"
|
||||
>
|
||||
<strong>自定义灵动岛</strong>
|
||||
<span>常驻显示时钟、课程等组件,通知沿用经典岛</span>
|
||||
<span>ClassIsland 风格,常驻显示时钟、课程等组件</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="layoutMode === 'custom'" class="layout-edit-entry">
|
||||
<div>
|
||||
<strong>按你的习惯布置岛内内容</strong>
|
||||
<p>编辑常态内容与无通知展开内容;通知样式、弹出规则和外观设置与经典岛共用。</p>
|
||||
<p>编辑常态内容与无通知展开内容;课前自动提示,通知操作与经典岛共用。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="editLayout">编辑岛内组件</el-button>
|
||||
</div>
|
||||
@@ -207,6 +207,9 @@ onMounted(async () => {
|
||||
<span>外观、位置与弹出</span>
|
||||
</template>
|
||||
<p class="text-muted">紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。</p>
|
||||
<p class="text-muted">
|
||||
上课前 60 秒显示准备提醒和倒计时,上下课时提示课程状态;课程时间显示在名称右侧。
|
||||
</p>
|
||||
<el-form label-width="110px" label-position="left">
|
||||
<el-form-item :label="`${layoutMode === 'custom' ? '通知高度' : '高度'} ${island.height}px`">
|
||||
<el-slider
|
||||
@@ -270,7 +273,9 @@ onMounted(async () => {
|
||||
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="视觉风格">
|
||||
<span v-if="layoutMode === 'custom'">ClassIsland · 半透明横条</span>
|
||||
<el-select
|
||||
v-else
|
||||
:model-value="island.style"
|
||||
style="width: 180px"
|
||||
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
|
||||
@@ -282,7 +287,9 @@ onMounted(async () => {
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
|
||||
<span v-if="layoutMode === 'fixed'" class="text-muted ml-8">
|
||||
纯黑 / 毛玻璃(半透明)/ 主题色渐变
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示位置">
|
||||
<el-select
|
||||
|
||||
Reference in new issue
Block a user