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