fix(island): 合并常驻与自定义岛并统一经典通知和即时课表同步

This commit is contained in:
LoveChengke committed 2026-10-17 15:56:44 +08:00
1 parent 05848570d1
commit e84e43544a
19 files changed
+1565 -1445

No files matched your search

@@ -0,0 +1,234 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
PRIORITY_RANK,
PRIORITY_LABELS,
formatDate,
islandListLayout,
ISLAND_SIZE_DELTA,
type IslandState,
type IslandAppearance,
type IslandNotification,
type IslandNotificationKind,
} from '@classhelper/shared';
const props = defineProps<{ state: IslandState; appearance: IslandAppearance; maxHeight: number }>();
const state = computed(() => props.state);
const appearance = computed(() => props.appearance);
const notification = computed(() => state.value.active);
const isUrgent = computed(() => notification.value?.priority === 'URGENT');
const isCall = computed(() => notification.value?.kind === 'call');
const isHomework = computed(() => notification.value?.kind === 'homework');
const pendingNotifications = computed(() =>
[
...new Map(
[state.value.active, ...state.value.queued]
.filter((n): n is IslandNotification => !!n)
.map((n) => [n.id, n]),
).values(),
].sort(
(a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority] || b.createdAt.localeCompare(a.createdAt),
),
);
const pendingCount = computed(() => pendingNotifications.value.length);
const queueCount = computed(() => state.value.queued.length);
const listLayout = computed(() =>
pendingCount.value < 2
? null
: islandListLayout({
count: pendingCount.value,
fontSize: appearance.value.fontSize,
maxHeight: props.maxHeight,
expanded: state.value.listExpanded,
}),
);
const isListMode = computed(() => !!listLayout.value);
const listItems = computed(() => pendingNotifications.value.slice(0, listLayout.value?.rows ?? 0));
const hiddenCount = computed(() => pendingCount.value - listItems.value.length);
const listHint = computed(() => listLayout.value?.hint);
const size = computed(() => {
const extra = isCall.value
? ISLAND_SIZE_DELTA.call
: isUrgent.value
? ISLAND_SIZE_DELTA.urgent
: ISLAND_SIZE_DELTA.expanded;
return {
width: appearance.value.width + (isListMode.value ? ISLAND_SIZE_DELTA.expanded.width : extra.width),
height: listLayout.value?.height ?? appearance.value.height + extra.height,
};
});
const TYPE_ORDER: IslandNotificationKind[] = ['call', 'homework', 'notification'];
const TYPE_LABELS = { call: '叫人', homework: '作业', notification: '通知' };
const pendingTypes = computed(() =>
TYPE_ORDER.filter((k) => pendingNotifications.value.some((n) => (n.kind ?? 'notification') === k)).map(
(k) => TYPE_LABELS[k],
),
);
function rowKind(n: IslandNotification) {
return n.kind ?? 'notification';
}
function rowBadgeText(n: IslandNotification) {
return n.kind === 'call' ? '叫人' : n.kind === 'homework' ? '新作业' : PRIORITY_LABELS[n.priority];
}
function rowBadgeClass(n: IslandNotification) {
return n.kind === 'call'
? 'badge-call'
: n.kind === 'homework'
? 'badge-homework'
: `badge-${n.priority.toLowerCase()}`;
}
function rowSubText(n: IslandNotification) {
return `${n.teacherName ? n.teacherName + ' · ' : ''}${formatDate(n.createdAt, true)}`;
}
const badgeText = computed(() => (notification.value ? rowBadgeText(notification.value) : '通知'));
const badgeClass = computed(() => (notification.value ? rowBadgeClass(notification.value) : 'badge-normal'));
const subtitle = computed(() => (notification.value ? rowSubText(notification.value) : ''));
const callHint = computed(() => notification.value?.subtitle ?? '');
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
const canMarkRead = computed(() => pendingNotifications.value.some((n) => n.kind !== 'homework'));
function collapse() {
window.island?.sendAction('collapse');
}
function openApp() {
window.island?.sendAction('open-app');
}
function expandList() {
window.island?.sendAction('expand-list');
}
function dismiss() {
window.island?.sendAction(isListMode.value ? 'dismiss-all' : 'dismiss');
}
function markRead() {
window.island?.sendAction(isListMode.value ? 'mark-all-read' : 'mark-read', notification.value?.id);
}
</script>
<template>
<span
class="expanded-layer classic-notifications"
:class="{ 'list-mode': isListMode }"
:style="{ width: `${size.width}px`, height: `${size.height}px` }"
>
<header class="head">
<span
class="head-icon"
:class="{ urgent: isUrgent, call: isCall, homework: isHomework, list: isListMode }"
>
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
/>
</svg>
<svg v-else-if="isCall" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
/>
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
/>
</svg>
</span>
<div class="head-meta">
<span class="badge" :class="isListMode ? 'badge-normal' : badgeClass">
{{ isListMode ? `共 ${pendingCount} 条` : badgeText }}
</span>
<template v-if="isListMode">
<span v-if="pendingTypes.length > 1" class="head-sub">{{ pendingTypes.join(' / ') }}</span>
<span v-else class="head-sub">按重要程度排列</span>
</template>
<template v-else>
<span v-if="isCall" class="live-dot call">老师正在等你</span>
<span v-else-if="isUrgent" class="live-dot">需立即查看</span>
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
<span class="head-sub">{{ subtitle }}</span>
</template>
</div>
<button type="button" class="icon-btn" title="收起" @click.stop="collapse">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
d="m6 15 6-6 6 6"
/>
</svg>
</button>
</header>
<!-- 多条消息:竖向排列,按重要程度排序,默认只显示前三条 -->
<template v-if="isListMode">
<h3 class="title">{{ pendingCount }} 条待处理通知</h3>
<div class="body list-body">
<button
v-for="item in listItems"
:key="item.id"
type="button"
class="list-row"
:title="`打开应用查看:${item.title}`"
@click.stop="openApp"
>
<span class="row-icon" :class="rowKind(item)">
<svg v-if="rowKind(item) === 'homework'" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
/>
</svg>
<svg v-else-if="rowKind(item) === 'call'" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
/>
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
/>
</svg>
</span>
<span class="row-text">
<span class="row-title">{{ item.title }}</span>
<span class="row-sub">{{ rowSubText(item) }}</span>
</span>
<span class="row-badge" :class="rowBadgeClass(item)">{{ rowBadgeText(item) }}</span>
</button>
<!-- 还有没显示出来的:能展开就"展开更多",屏幕到任务栏了就只能去应用里看 -->
<div v-if="listHint" class="list-hint">
<button v-if="listHint === 'more'" type="button" class="more-btn" @click.stop="expandList">
展开更多(还有 {{ hiddenCount }} 条)
</button>
<span v-else class="more-app">更多请前往应用内操作</span>
</div>
</div>
</template>
<template v-else>
<h3 class="title">{{ notification?.title ?? '' }}</h3>
<div class="body">
<p class="content-text">{{ notification?.content ?? '' }}</p>
<p v-if="callHint" class="call-hint">{{ callHint }}</p>
<p v-else-if="notification?.subtitle" class="call-hint">{{ notification?.subtitle }}</p>
</div>
</template>
<!-- 一排按钮(多条消息时也是这一排):展开态的操作都收在这里 -->
<footer class="foot">
<span v-if="isListMode" class="more">共 {{ pendingCount }} 条待处理</span>
<span v-else-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
<span v-else class="more muted">来自 ClassHelper</span>
<span class="actions">
<button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
<button v-if="canMarkRead" type="button" class="ghost-btn" @click.stop="markRead">标为已读</button>
<button type="button" class="solid-btn" @click.stop="dismiss">
{{ isCall && !isListMode ? '收到' : '知道了' }}
</button>
</span>
</footer>
</span>
</template>
<style src="./classic-island.css"></style>
@@ -110,7 +110,7 @@ const style = computed<CSSProperties>(() => {
: ({ stretch: 'stretch', start: 'flex-start', center: 'center', end: 'flex-end' } as const)[
n.advanced.align
],
textAlign: n.advanced.align === 'center' ? 'center' : 'start',
textAlign: n.advanced.align === 'start' ? 'start' : n.advanced.align === 'end' ? 'end' : 'center',
color: override && a.customForegroundEnabled ? a.foreground : undefined,
background: override && a.customBackgroundEnabled ? color : undefined,
borderRadius: override && a.customCornerRadiusEnabled ? `${a.cornerRadius}px` : undefined,
@@ -7,6 +7,7 @@ import {
islandNode,
normalizeIslandLayouts,
type IslandLayoutSettings,
type IslandAppearance,
type IslandComponentKind,
type IslandComponentNode,
type IslandState,
@@ -19,6 +20,7 @@ const props = defineProps<{
settings: IslandLayoutSettings;
state: IslandState;
editing: boolean;
appearance: IslandAppearance;
schedules: ScheduleDto[];
}>();
const emit = defineEmits<{
@@ -84,17 +86,6 @@ function collapseResident() {
function onPointerDown(event: PointerEvent) {
if (event.pointerType === 'touch' || event.pointerType === 'pen') window.island?.noteResidentTouch();
}
function restoreClassIslandAppearance() {
Object.assign(scheme.value, {
scale: CI.scale,
color: '#000000',
opacity: CI.opacity,
radius: CI.radius,
spacing: CI.rowSpacing,
});
for (const row of [...scheme.value.rows, ...scheme.value.expandedRows]) row.gap = 12;
save();
}
const selectedId = ref(''),
selectedKind = ref<IslandComponentKind>('clock');
const panel = ref<'component' | 'row' | 'appearance'>('appearance');
@@ -158,14 +149,15 @@ const shellStyle = computed<CSSProperties>(() => ({
minHeight: !props.editing && viewport.value.height ? `${viewport.value.height}px` : undefined,
maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`,
maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`,
color: scheme.value.foreground,
fontSize: '16px',
color: '#ffffffeb',
fontSize: `${props.appearance.fontSize}px`,
'--custom-color':
scheme.value.color +
Math.round(scheme.value.opacity * 255)
.toString(16)
.padStart(2, '0'),
'--custom-radius': `${scheme.value.radius * (draft.value.residentEnabled ? scheme.value.scale : 1)}px`,
props.appearance.style === 'glass'
? '#202024cd'
: props.appearance.style === 'tinted'
? '#0b0b0f'
: '#000000',
'--custom-radius': `${props.appearance.radius}px`,
'--custom-row-gap': `${scheme.value.spacing}px`,
...(draft.value.residentEnabled
? {
@@ -364,6 +356,7 @@ function panMove(e: PointerEvent) {
pan.value = { x: panning.originX + e.clientX - panning.x, y: panning.originY + e.clientY - panning.y };
move(e);
}
let stopViewport: (() => void) | undefined;
let observer: ResizeObserver | undefined,
measureTimer: ReturnType<typeof setTimeout> | undefined,
clock: ReturnType<typeof setInterval>;
@@ -399,12 +392,13 @@ onMounted(() => {
clock = setInterval(() => (now.value = Date.now()), 1000);
observer = new ResizeObserver(measure);
if (inner.value) observer.observe(inner.value);
window.island?.onViewport((v) => (viewport.value = v));
stopViewport = window.island?.onViewport((v) => (viewport.value = v));
measure();
});
onUnmounted(() => {
clearInterval(clock);
clearTimeout(measureTimer);
stopViewport?.();
observer?.disconnect();
});
</script>
@@ -495,15 +489,14 @@ onUnmounted(() => {
</aside>
<main :class="{ canvas: editing }" @pointerdown="panStart">
<div v-if="editing" class="canvas-toolbar">
<nav v-if="draft.residentEnabled" class="surface-tabs" aria-label="编辑常驻岛内容">
<nav class="surface-tabs" aria-label="编辑自定义灵动岛内容">
<button :class="{ active: editingSurface === 'compact' }" @click="setSurface('compact')">
收起时
常态内容
</button>
<button :class="{ active: editingSurface === 'expanded' }" @click="setSurface('expanded')">
无通知展开时
</button>
</nav>
<span v-else>岛内预览</span>
<div>
<button
:class="{ active: panel === 'appearance' }"
@@ -755,31 +748,10 @@ onUnmounted(() => {
</div>
<div class="inspector-body" @change="save">
<section class="setting-section">
<label class="toggle-field">
<span>常驻岛</span>
<input type="checkbox" v-model="draft.residentEnabled" @change="setSurface('compact')" />
</label>
<p class="field-hint">
开启后始终显示收起内容,收到消息时显示铃铛,点击查看通知;没有通知时展开另一套自定义内容。
通知、外观与弹出动画沿用经典灵动岛。在应用的灵动岛设置中调整颜色、字号、圆角和动画速度。
</p>
</section>
<section v-if="draft.residentEnabled" class="setting-section">
<h3>ClassIsland 窗口设置</h3>
<p class="field-hint">
基准岛高 40 px,默认缩放 1×;正文 16、时钟 18 px。背景移入淡化并穿透,右侧展开按钮保持可用。
</p>
<label class="toggle-field">
<span>指针移入淡化</span>
<input type="checkbox" v-model="draft.pointerFadeEnabled" />
</label>
<label v-if="draft.pointerFadeEnabled" class="toggle-field">
<span>反转指针移入淡化</span>
<input type="checkbox" v-model="draft.pointerFadeReversed" />
</label>
<button class="wide-button" @click="restoreClassIslandAppearance">
恢复 ClassIsland 默认外观
</button>
</section>
<section class="setting-section">
<h3>当前方案</h3>
<label class="field">
@@ -797,40 +769,6 @@ onUnmounted(() => {
/>
</label>
</section>
<section class="setting-section">
<h3>岛体</h3>
<label class="field inline-field">
背景颜色
<input type="color" v-model="scheme.color" />
</label>
<label class="field inline-field">
文字颜色
<input type="color" v-model="scheme.foreground" />
</label>
<label class="field">
背景透明度
<div class="range-field">
<input
type="range"
v-model.number="scheme.opacity"
:min="draft.residentEnabled ? 0 : 0.1"
max="1"
step="0.05"
/>
<output>{{ Math.round(scheme.opacity * 100) }}%</output>
</div>
</label>
<label class="field inline-field">
圆角
<input
type="number"
v-model.number="scheme.radius"
min="0"
:max="draft.residentEnabled ? CI.radiusMax : 100"
step="0.5"
/>
</label>
</section>
<section class="setting-section">
<h3>多行布局</h3>
<label class="field inline-field">
@@ -1,9 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from 'vue';
import {
CLASSISLAND_ISLAND_METRICS as CI,
ISLAND_SHADOW_PAD,
PRIORITY_LABELS,
PRIORITY_RANK,
type IslandAppearance,
type IslandLayoutSettings,
@@ -12,6 +10,9 @@ import {
} 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';
const props = defineProps<{
settings: IslandLayoutSettings;
@@ -28,7 +29,7 @@ const messages = computed(() => {
);
return [...unique.values()].sort(
(a, b) =>
PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] ||
PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority] ||
Date.parse(b.createdAt) - Date.parse(a.createdAt),
);
});
@@ -47,13 +48,6 @@ const anchor = computed(() =>
? 'right'
: 'center',
);
const capacity = computed(() =>
Math.max(1, Math.floor((available.value.height / scheme.value.scale - 130) / 104)),
);
const visibleMessages = computed(() =>
messages.value.slice(0, props.state.listExpanded ? capacity.value : Math.min(3, capacity.value)),
);
const remaining = computed(() => messages.value.length - visibleMessages.value.length);
function fitted(size: { width: number; height: number }) {
// 以未变形内容测量;字体、配置缩放、行数和屏幕变化都参与适配,不从动画中的壳反推内容。
const scale = Math.min(
@@ -66,23 +60,75 @@ 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 morph = ref({ width: 40, height: 40, radius: 20 });
const springs = { width: new SpringValue(40), height: new SpringValue(40), radius: new SpringValue(20) };
const path = computed(() => squirclePath(morph.value.width, morph.value.height, morph.value.radius));
const style = computed<CSSProperties>(() => ({
width: `${fit.value.width}px`,
height: `${fit.value.height}px`,
color: scheme.value.foreground,
'--resident-background': `${scheme.value.color}${Math.round(scheme.value.opacity * 255)
.toString(16)
.padStart(2, '0')}`,
'--resident-radius': `${scheme.value.radius * fit.value.scale}px`,
width: `${morph.value.width}px`,
height: `${morph.value.height}px`,
'--card-path': `path("${path.value}")`,
'--island-font': `${props.appearance.fontSize}px`,
'--island-accent': props.appearance.accent,
'--state-color':
props.state.active?.priority === 'URGENT'
? '#ff453a'
: props.state.active?.kind === 'call'
? '#ff9f0a'
: props.appearance.accent,
'--resident-background': 'var(--wn-bg)',
'--resident-radius': `${morph.value.radius}px`,
'--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': `${CI.secondaryFontSize}px`,
'--emphasized': `${CI.emphasizedFontSize}px`,
'--large': `${CI.largeFontSize}px`,
'--resident-duration': motion.value ? `${550 / props.appearance.speed}ms` : '0ms',
'--secondary': `${props.appearance.fontSize * 0.78}px`,
'--emphasized': `${props.appearance.fontSize * 1.1}px`,
'--large': `${props.appearance.fontSize * 1.3}px`,
'--resident-fade-duration': motion.value ? `${200 / props.appearance.speed}ms` : '0ms',
}));
let morphFrame = 0;
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,
size.width / 2,
size.height / 2,
);
const target = { width: size.width, height: size.height, radius };
const snap = () => {
cancelAnimationFrame(morphFrame);
morphFrame = 0;
clearTimeout(morphFallback);
for (const key of ['width', 'height', 'radius'] as const) springs[key].snap(target[key]);
morph.value = target;
void nextTick(reportHit);
};
if (!motion.value || matchMedia('(prefers-reduced-motion: reduce)').matches) {
snap();
return;
}
for (const key of ['width', 'height', 'radius'] as const) springs[key].setTarget(target[key]);
clearTimeout(morphFallback);
morphFallback = setTimeout(snap, 1200 / props.appearance.speed);
if (morphFrame) return;
let last = performance.now();
const frame = (time: number) => {
const dt = (time - last) * props.appearance.speed;
last = time;
// 三个维度每帧都推进;不能用短路 || 导致后面的维度等宽度结束才开始。
const moving = Object.values(springs)
.map((s) => s.step(dt))
.some(Boolean);
morph.value = { width: springs.width.value, height: springs.height.value, radius: springs.radius.value };
if (moving) morphFrame = requestAnimationFrame(frame);
else {
morphFrame = 0;
clearTimeout(morphFallback);
void nextTick(reportHit);
}
};
morphFrame = requestAnimationFrame(frame);
}
function action(value: 'expand' | 'collapse' | 'expand-list' | 'dismiss-all' | 'mark-all-read' | 'open-app') {
window.island?.sendAction(value);
}
@@ -161,26 +207,36 @@ function measure() {
});
});
}
function resize() {
function resizeCanvas(width = innerWidth, height = innerHeight) {
available.value = {
width: Math.max(40, innerWidth - 2 * ISLAND_SHADOW_PAD - 4),
height: Math.max(40, innerHeight - 2 * ISLAND_SHADOW_PAD - 4),
width: Math.max(40, width - 2 * ISLAND_SHADOW_PAD - 4),
height: Math.max(40, height - 2 * ISLAND_SHADOW_PAD - 4),
};
measure();
}
const resize = () => resizeCanvas();
let stopViewport: (() => void) | undefined;
function visibility() {
visible.value = !document.hidden;
void nextTick(reportHit);
}
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
watch([expanded, scheme, () => props.state.listExpanded, messages], measure, { deep: true, flush: 'post' });
watch(fit, trackHit, { flush: 'post' });
watch(
[fit, motion, () => props.appearance.radius, () => props.appearance.speed],
() => {
animate();
trackHit();
},
{ flush: 'post' },
);
watch(
() => props.appearance.position,
() => void nextTick(trackHit),
);
onMounted(() => {
resize();
stopViewport = window.island?.onViewport((v) => resizeCanvas(v.width, v.height));
observer = new ResizeObserver(measure);
if (compact.value) observer.observe(compact.value);
if (details.value) observer.observe(details.value);
@@ -190,10 +246,13 @@ onMounted(() => {
void document.fonts.ready.then(measure);
});
onUnmounted(() => {
stopViewport?.();
observer?.disconnect();
clearInterval(clock);
clearTimeout(hitTimer);
cancelAnimationFrame(hitFrame);
cancelAnimationFrame(morphFrame);
clearTimeout(morphFallback);
window.removeEventListener('resize', resize);
document.removeEventListener('visibilitychange', visibility);
window.island?.reportResidentControl(null);
@@ -209,19 +268,42 @@ onUnmounted(() => {
>
<section
ref="shell"
class="custom-card resident-card"
:class="{ 'resident-open': expanded, 'resident-separated': scheme.separated && !expanded }"
class="custom-card resident-card island-card"
:data-style="appearance.style"
:class="{
'resident-open': expanded,
expanded,
pill: !expanded,
urgent: state.active?.priority === 'URGENT',
call: state.active?.kind === 'call',
'resident-separated': scheme.separated && !expanded,
}"
:style="style"
:role="expanded ? 'region' : 'button'"
tabindex="0"
:aria-label="
expanded ? '常驻岛内容' : messages.length ? `查看 ${messages.length} 条通知` : '展开常驻岛'
expanded
? '自定义灵动岛内容'
: messages.length
? `查看 ${messages.length} 条通知`
: '展开自定义灵动岛'
"
:aria-expanded="expanded"
@click="open"
@keydown="key"
@transitionend.self="reportHit"
>
<svg
class="shape"
:width="morph.width"
:height="morph.height"
:viewBox="`0 0 ${morph.width} ${morph.height}`"
preserveAspectRatio="none"
aria-hidden="true"
>
<path :d="path" />
</svg>
<span class="tint" aria-hidden="true" />
<div
class="resident-page resident-compact-page"
:style="{ '--resident-scale': compactFit.scale }"
@@ -256,7 +338,7 @@ onUnmounted(() => {
<button
class="resident-bell"
:class="{ 'bell-empty': !messages.length }"
:aria-label="messages.length ? `查看 ${messages.length} 条通知` : '展开常驻岛'"
:aria-label="messages.length ? `查看 ${messages.length} 条通知` : '展开自定义灵动岛'"
@click.stop="action('expand')"
>
<ComponentIcon v-if="messages.length" kind="notification" />
@@ -287,48 +369,27 @@ onUnmounted(() => {
ref="details"
class="resident-source resident-details"
:class="{ 'with-messages': messages.length }"
:style="
messages.length ? { width: `${Math.min(440, available.width / scheme.scale)}px` } : undefined
"
>
<header class="resident-panel-heading">
<span>{{ messages.length ? `通知 · ${messages.length}` : '常驻岛' }}</span>
<button class="icon-btn" aria-label="收起常驻岛" @click.stop="action('collapse')">
<ClassicNotifications
v-if="messages.length"
id="custom-alert-host"
:state="state"
:appearance="appearance"
:max-height="available.height / scheme.scale"
/>
<header v-else class="resident-panel-heading">
<span>自定义灵动岛</span>
<button class="icon-btn" aria-label="收起自定义灵动岛" @click.stop="action('collapse')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="m8 14 4-4 4 4" />
</svg>
</button>
</header>
<div v-if="messages.length" id="custom-alert-host" class="resident-messages">
<article
v-for="item in visibleMessages"
:key="item.id"
class="list-row"
:data-priority="item.priority"
>
<div class="resident-message-title">
<strong>{{ item.title }}</strong>
<span>{{ PRIORITY_LABELS[item.priority] }}</span>
</div>
<p>{{ item.content }}</p>
<small>{{ [item.teacherName, item.courseName].filter(Boolean).join(' · ') }}</small>
</article>
<button
v-if="remaining"
class="resident-more"
@click.stop="action(state.listExpanded ? 'open-app' : 'expand-list')"
>
{{
state.listExpanded ? `其余 ${remaining} 条在应用中查看` : `展开更多(还有 ${remaining} 条)`
}}
</button>
<footer class="resident-actions">
<button @click.stop="action('open-app')">查看完整内容</button>
<button @click.stop="action('mark-all-read')">标为已读</button>
<button class="solid-btn" @click.stop="action('dismiss-all')">知道了</button>
</footer>
</div>
<div v-else class="layout-rows resident-expanded-rows" :style="{ gap: `${scheme.spacing}px` }">
<div
v-if="!messages.length"
class="layout-rows resident-expanded-rows"
:style="{ gap: `${scheme.spacing}px` }"
>
<section
v-for="row in scheme.expandedRows"
:key="row.id"
@@ -356,4 +417,4 @@ onUnmounted(() => {
</section>
</div>
</template>
<style src="./resident-island.css"></style>
<style src="./custom-runtime.css"></style>
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, onMounted, ref, shallowRef } from 'vue';
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import {
defaultIslandLayouts,
DEFAULT_ISLAND_APPEARANCE,
@@ -10,7 +10,7 @@ import {
} from '@classhelper/shared';
import FixedIsland from './IslandApp.vue';
import CustomIsland from './CustomIsland.vue';
import ResidentIsland from './ResidentIsland.vue';
import CustomIslandRuntime from './CustomIslandRuntime.vue';
const settings = ref(defaultIslandLayouts()),
editing = ref(false),
ready = ref(false);
@@ -18,7 +18,7 @@ const schedules = ref<ScheduleDto[]>([]);
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const nativeVisible = ref(false);
const alertHost = shallowRef<HTMLElement | null>(null);
const resident = () => settings.value.layout === 'custom' && settings.value.residentEnabled && !editing.value;
const resident = () => settings.value.layout === 'custom' && !editing.value;
const state = ref<IslandState>({
mode: 'hidden',
active: null,
@@ -37,7 +37,10 @@ async function done() {
editing.value = false;
await window.island?.setEditing(false);
}
let heartbeat: ReturnType<typeof setInterval>;
onUnmounted(() => clearInterval(heartbeat));
onMounted(async () => {
heartbeat = setInterval(() => window.island?.alive(), 5000);
const bridge = window.island;
if (bridge) {
bridge.onLayout((value) => (settings.value = value));
@@ -49,6 +52,7 @@ onMounted(async () => {
nativeVisible.value = await bridge.getVisible();
appearance.value = await bridge.getAppearance();
settings.value = await bridge.getLayout();
schedules.value = await bridge.getSchedules();
state.value = await bridge.getState();
editing.value = await bridge.getEditing();
}
@@ -57,7 +61,7 @@ onMounted(async () => {
});
</script>
<template>
<ResidentIsland
<CustomIslandRuntime
v-if="resident()"
:settings="settings"
:state="state"
@@ -70,6 +74,7 @@ onMounted(async () => {
:settings="settings"
:state="state"
:editing="editing"
:appearance="appearance"
:schedules="schedules"
@save="save"
@done="done"
@@ -0,0 +1,743 @@
/*
* 排版系数(必须与 @classhelper/shared 的 ISLAND_TYPE_SCALE 保持一致):
* title 1.08 / body 0.95 / meta 0.78 / badge 0.76 / pillTitle 0.92 / pillSub 0.74
*
* 关键:所有文本尺寸都由 --island-font 通过 calc() 推导,所以「字号」设置一改,
* 岛内文字立即整体等比缩放(WinIsland 的 font_size 模型),不存在写死的字号。
*/
html,
body,
#app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: transparent;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
}
body {
font-family:
-apple-system, 'Segoe UI Variable Display', 'Segoe UI', 'Microsoft YaHei', 'PingFang SC',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
.island-root {
width: 100%;
height: 100%;
position: relative;
background: transparent;
}
/* ---------------------------------------------------------------- 卡片与形状 */
.island-root.embedded .island-card {
left: 0 !important;
top: 0 !important;
right: auto !important;
bottom: auto !important;
transform: none !important;
}
.island-card {
position: absolute;
display: block;
padding: 0;
margin: 0;
border: none;
background: none;
color: rgba(255, 255, 255, 0.92);
font-size: var(--island-font, 13px);
text-align: left;
cursor: pointer;
/* 配色 token(默认:WinIsland 纯黑风格) */
--wn-bg: #000000;
--wn-border: rgba(255, 255, 255, 0.118);
--wn-text-1: rgba(255, 255, 255, 0.92);
--wn-text-2: rgba(255, 255, 255, 0.72);
--wn-text-3: rgba(255, 255, 255, 0.58);
--wn-surface: rgba(255, 255, 255, 0.1);
--wn-surface-hover: rgba(255, 255, 255, 0.18);
--wn-divider: rgba(255, 255, 255, 0.09);
}
.island-card[data-style='glass'] {
/* WinIsland「glass 无 host backdrop」降级色:比纯黑透一点,但保证任何桌面下都不是白底 */
--wn-bg: rgba(32, 32, 36, 0.804);
--wn-border: rgba(255, 255, 255, 0.157);
--wn-surface: rgba(255, 255, 255, 0.14);
--wn-surface-hover: rgba(255, 255, 255, 0.22);
}
.island-card[data-style='tinted'] {
--wn-bg: #0b0b0f;
--wn-border: rgba(255, 255, 255, 0.14);
}
.island-card.hidden-placeholder {
visibility: hidden;
}
/* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */
.island-card .shape {
position: absolute;
left: 0;
top: 0;
display: block;
}
.island-card .shape path {
fill: var(--wn-bg);
stroke: var(--wn-border);
stroke-width: 1;
/*
投影**始终声明**,形态切换只改参数 —— 不要写成"只在 .expanded 上挂 filter"。
给元素加 / 去 CSS filter 会让 Chromium 新建或销毁它的渲染表面(effect node),
而首帧的合成可能发生在新表面栅格化完成之前,那一帧会被当作空内容画出去;
卡片的底色恰恰就画在这个被过滤的元素上(fill 在本元素),于是**开/合的那一瞬间
底座会闪掉一帧、只剩未过滤的文字层**(用户反馈的"开合时一瞬间的闪动")。
胶囊态用 0 0 0:硬轮廓投影正好压在路径底下,肉眼等同无投影,与 WinIsland
"只有展开态有投影"的观感一致。
*/
filter: drop-shadow(0 var(--card-shadow-y, 0px) var(--card-shadow-blur, 0px) rgba(0, 0, 0, 0.11));
}
/* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */
.island-card .content {
position: absolute;
inset: 0;
display: block;
clip-path: var(--card-path);
overflow: hidden;
}
.island-card .layer {
position: absolute;
inset: 0;
display: flex;
pointer-events: none;
}
/* 只有"当前形态"的那一层响应鼠标,避免透明层的按钮被误点 */
.island-card.pill .pill-layer,
.island-card.expanded .expanded-layer {
pointer-events: auto;
}
/* 展开态投影(WinIsland: rgba(0,0,0,.11)、y+2、σ=3 → CSS 0 2px 3px)。
只改参数、不动 filter 声明本身,理由见上面的 .shape path。 */
.island-card.expanded .shape path {
--card-shadow-y: 2px;
--card-shadow-blur: 3px;
}
/* 状态色:只做"内层微染 + 描边",不外溢(保持桌面干净,也不会有光晕硬边) */
.island-card.urgent .shape path,
.island-card.call .shape path {
stroke: color-mix(in srgb, var(--state-color) 62%, rgba(255, 255, 255, 0.2));
}
.island-card .tint {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
}
.island-card.urgent .tint {
opacity: 1;
background: radial-gradient(120% 90% at 50% 0%, rgba(255, 69, 58, 0.22), transparent 68%);
}
.island-card.call .tint {
opacity: 1;
background: radial-gradient(120% 90% at 50% 0%, rgba(255, 159, 10, 0.2), transparent 68%);
}
.island-card[data-style='tinted'] .tint {
opacity: 1;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--island-accent) 30%, transparent),
transparent 62%
);
}
/* ---------------------------------------------------------------- 胶囊(compact) */
.pill-layer {
flex-direction: row;
align-items: center;
/* 水平对齐交给 .pill-inner(它固定为胶囊几何,且按停靠方式贴边/居中) */
justify-content: flex-start;
}
/**
* 胶囊内容盒:**固定为胶囊自己的宽高与内边距**,不随卡片形变重排。
*
* 为什么必须固定:卡片在开合时 268 ⇄ 424 变宽变窄,而中心停靠下卡片的左右边都会动。
* 内容若跟着卡片宽度走,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
* (用户反馈的"新消息那行字往右跳一下再缩回"),底部也会被收缩的形状裁掉一块。
* 固定成胶囊几何后:文字在整段动画里位置与省略号都不变,只是随卡片一起淡出/淡入。
*
* 贴哪条边与卡片停靠方式一致(左停靠贴左、右停靠贴右、居中则居中对齐)——
* 这样卡片变宽时内容也不会横移,静止态外观与之前完全一致。
*/
.pill-inner {
display: inline-flex;
flex-direction: row;
align-items: center;
flex: 0 0 auto;
gap: calc(var(--island-font, 13px) * 0.62);
width: var(--island-w, 268px);
height: 100%;
padding: 0 calc(var(--island-font, 13px) * 0.78);
}
.island-card[data-anchor='center'] .pill-layer {
justify-content: center;
}
.island-card[data-anchor='right'] .pill-layer {
justify-content: flex-end;
}
/**
* 收回动画(`reason === 'closing'`):胶囊收回过程中**不显示任何胶囊内容**。
*
* 这时状态已经在同一帧清空(没有可展示的消息),若照常渲染胶囊层,卡片刚收回就会闪出
* 一句"新消息 · 点击查看" —— 语义正好相反。这里保留 DOM(宽度固定为 `--island-w`,
* 内容盒不会重排,符合"开合时胶囊内容不许重排"的约定)只把内容隐藏,
* 卡片形状照样沿既有弹簧收回,随后由主进程淡出窗口。
*/
.island-card.closing .pill-inner {
opacity: 0;
}
.pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: calc(var(--island-font, 13px) * 1.24);
height: calc(var(--island-font, 13px) * 1.24);
border-radius: 50%;
background: var(--wn-surface);
color: var(--wn-text-1);
}
.pill-icon svg {
width: calc(var(--island-font, 13px) * 0.86);
height: calc(var(--island-font, 13px) * 0.86);
}
.pill-icon.call {
color: #ff9f0a;
}
.pill-icon.urgent {
color: #ff453a;
}
.pill-icon.homework {
color: var(--island-accent);
}
.pill-text {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
flex: 1 1 auto;
gap: 1px;
}
.pill-title {
font-size: calc(var(--island-font, 13px) * 0.92);
font-weight: 600;
line-height: 1.15;
color: var(--wn-text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pill-sub {
font-size: calc(var(--island-font, 13px) * 0.74);
line-height: 1.15;
color: var(--wn-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 多条消息时把类型做成胶囊小标签(叫人/作业/通知),收起状态也能一眼看出内容构成 */
.pill-types {
display: inline-flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.2);
flex: 0 0 auto;
padding-right: calc(var(--island-font, 13px) * 0.2);
}
.pill-type {
font-size: calc(var(--island-font, 13px) * 0.6);
line-height: 1.4;
padding: 0 calc(var(--island-font, 13px) * 0.36);
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: var(--wn-text-2);
white-space: nowrap;
}
.pill-chevron {
display: inline-flex;
align-items: center;
color: rgba(255, 255, 255, 0.34);
flex: 0 0 auto;
}
.pill-chevron svg {
width: calc(var(--island-font, 13px) * 0.86);
height: calc(var(--island-font, 13px) * 0.86);
}
/* ---------------------------------------------------------------- 展开卡(expanded) */
.expanded-layer {
/* 关键:展开层必须纵向排列。此前把 flex-direction 从基类挪到分层时漏了这一条,
导致展开卡内容横排(标题/元信息/按钮散落,实机可见) */
flex-direction: column;
padding: calc(var(--island-font, 13px) * 1.4) calc(var(--island-font, 13px) * 1.6);
gap: calc(var(--island-font, 13px) * 0.7);
justify-content: center;
}
/*
* 多条通知的列表形态。
*
* 这里的每个尺寸都必须与 @classhelper/shared 的 `ISLAND_LIST_METRICS` 一一对应:
* 主进程按那份度量算出**窗口包围盒**(列表比普通展开卡高,窗口不够高会把底部按钮裁掉),
* 渲染进程按同一份度量排布。改这里就必须改那里,否则会出现"最后一行被切掉"。
* 选择器一律写成 `.expanded-layer.list-mode ...`(比基类的单类选择器更具体),
* 免得被后面定义的 `.title` / `.body` 覆盖掉尺寸——那样高度就对不上算出来的值了。
*/
.expanded-layer.list-mode {
/* 高度已经算准,改 flex-start:居中排版一旦有像素级误差会把上下两端同时切掉 */
justify-content: flex-start;
}
.expanded-layer.list-mode .head {
height: calc(var(--island-font, 13px) * 1.7);
}
.expanded-layer.list-mode .title {
flex: 0 0 auto;
display: block;
height: calc(var(--island-font, 13px) * 1.4);
line-height: 1.4;
-webkit-line-clamp: unset;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.expanded-layer.list-mode .body {
flex: 1 1 auto;
min-height: 0;
gap: calc(var(--island-font, 13px) * 0.4);
justify-content: flex-start;
overflow: hidden;
}
.head-icon.list {
background: color-mix(in srgb, var(--island-accent) 20%, transparent);
color: var(--island-accent);
}
.list-row {
display: flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.55);
flex: 0 0 auto;
width: 100%;
height: calc(var(--island-font, 13px) * 2.7);
padding: 0 calc(var(--island-font, 13px) * 0.6);
border: none;
border-radius: calc(var(--island-font, 13px) * 0.5);
background: rgba(255, 255, 255, 0.045);
color: var(--wn-text-1);
text-align: left;
cursor: pointer;
transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.row-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: calc(var(--island-font, 13px) * 1.3);
height: calc(var(--island-font, 13px) * 1.3);
border-radius: calc(var(--island-font, 13px) * 0.36);
background: var(--wn-surface);
color: var(--wn-text-2);
}
.row-icon svg {
width: calc(var(--island-font, 13px) * 0.86);
height: calc(var(--island-font, 13px) * 0.86);
}
.row-icon.call {
color: #ff9f0a;
}
.row-icon.homework {
color: var(--island-accent);
}
.row-text {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
min-width: 0;
flex: 1 1 auto;
}
.row-title {
font-size: calc(var(--island-font, 13px) * 0.9);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-sub {
font-size: calc(var(--island-font, 13px) * 0.68);
line-height: 1.1;
color: var(--wn-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-badge {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
height: calc(var(--island-font, 13px) * 1.25);
padding: 0 calc(var(--island-font, 13px) * 0.5);
border-radius: 999px;
font-size: calc(var(--island-font, 13px) * 0.68);
font-weight: 600;
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.78);
}
/* 列表底部的提示行:能展开时是按钮,屏幕到任务栏了就是一句"去应用里看" */
.list-hint {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
height: calc(var(--island-font, 13px) * 2.1);
}
.more-btn {
border: none;
background: var(--wn-surface);
color: var(--wn-text-1);
height: 100%;
width: 100%;
border-radius: calc(var(--island-font, 13px) * 0.5);
font-size: calc(var(--island-font, 13px) * 0.76);
font-weight: 600;
cursor: pointer;
transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.more-app {
font-size: calc(var(--island-font, 13px) * 0.76);
color: var(--wn-text-3);
white-space: nowrap;
}
.head {
display: flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.7);
flex: 0 0 auto;
}
.head-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(var(--island-font, 13px) * 1.62);
height: calc(var(--island-font, 13px) * 1.62);
border-radius: calc(var(--island-font, 13px) * 0.5);
background: var(--wn-surface);
color: var(--wn-text-1);
flex: 0 0 auto;
}
.head-icon svg {
width: calc(var(--island-font, 13px) * 1.05);
height: calc(var(--island-font, 13px) * 1.05);
}
.head-icon.urgent {
background: rgba(255, 69, 58, 0.2);
color: #ff453a;
}
.head-icon.call {
background: rgba(255, 159, 10, 0.2);
color: #ff9f0a;
}
.head-icon.homework {
background: color-mix(in srgb, var(--island-accent) 22%, transparent);
color: var(--island-accent);
}
.head-meta {
display: flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.55);
min-width: 0;
flex: 1 1 auto;
}
.badge {
display: inline-flex;
align-items: center;
height: calc(var(--island-font, 13px) * 1.5);
padding: 0 calc(var(--island-font, 13px) * 0.62);
border-radius: 999px;
font-size: calc(var(--island-font, 13px) * 0.76);
font-weight: 600;
letter-spacing: 0.02em;
background: color-mix(in srgb, var(--island-accent) 26%, transparent);
color: color-mix(in srgb, var(--island-accent) 70%, #ffffff);
flex: 0 0 auto;
}
.badge-urgent {
background: rgba(255, 69, 58, 0.24);
color: #ff6961;
}
.badge-high {
background: rgba(255, 159, 10, 0.22);
color: #ffb340;
}
.badge-low,
.badge-normal {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.78);
}
.badge-call {
background: rgba(255, 159, 10, 0.24);
color: #ffb340;
}
.badge-homework {
background: color-mix(in srgb, var(--island-accent) 26%, transparent);
color: color-mix(in srgb, var(--island-accent) 72%, #ffffff);
}
.live-dot {
display: inline-flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.32);
font-size: calc(var(--island-font, 13px) * 0.76);
color: #ff6961;
white-space: nowrap;
}
.live-dot.call {
color: #ffb340;
}
.live-dot.muted {
color: var(--wn-text-3);
}
.head-sub {
font-size: calc(var(--island-font, 13px) * 0.78);
color: var(--wn-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(var(--island-font, 13px) * 1.7);
height: calc(var(--island-font, 13px) * 1.7);
border: none;
border-radius: 50%;
background: var(--wn-surface);
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
flex: 0 0 auto;
transition:
background var(--ch-dur-fast) var(--ch-ease-out),
color var(--ch-dur-fast) var(--ch-ease-out);
}
.icon-btn svg {
width: calc(var(--island-font, 13px) * 0.8);
height: calc(var(--island-font, 13px) * 0.8);
}
.title {
margin: 0;
font-size: calc(var(--island-font, 13px) * 1.08);
font-weight: 700;
line-height: 1.3;
color: #fff;
flex: 0 0 auto;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.body {
flex: 0 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: calc(var(--island-font, 13px) * 0.35);
overflow: hidden;
}
.content-text {
margin: 0;
font-size: calc(var(--island-font, 13px) * 0.95);
line-height: 1.42;
color: var(--wn-text-2);
word-break: break-word;
}
.call-hint {
margin: 0;
font-size: calc(var(--island-font, 13px) * 0.85);
line-height: 1.4;
color: color-mix(in srgb, var(--state-color) 66%, #ffffff);
word-break: break-word;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--island-font, 13px) * 0.7);
flex: 0 0 auto;
margin-top: calc(var(--island-font, 13px) * 0.5);
padding-top: calc(var(--island-font, 13px) * 0.7);
border-top: 1px solid var(--wn-divider);
}
.more {
font-size: calc(var(--island-font, 13px) * 0.78);
color: var(--wn-text-2);
white-space: nowrap;
}
.more.muted {
color: var(--wn-text-3);
}
.actions {
display: flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.5);
flex: 0 0 auto;
}
.ghost-btn,
.solid-btn {
height: calc(var(--island-font, 13px) * 2.05);
padding: 0 calc(var(--island-font, 13px) * 0.9);
border: none;
border-radius: 999px;
font-size: calc(var(--island-font, 13px) * 0.76);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition:
background var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-dur-instant) var(--ch-ease-out);
}
.ghost-btn {
background: var(--wn-surface);
color: var(--wn-text-1);
}
.solid-btn {
background: #ffffff;
color: #0b0b0f;
}
.island-card.call .solid-btn {
background: #ff9f0a;
color: #1a1205;
}
.island-card.urgent .solid-btn {
background: #ff453a;
color: #ffffff;
}
.ghost-btn:active,
.solid-btn:active {
transform: scale(0.97);
}
/* --------------------------------------------------------------- 悬停微交互
*
* **必须关在 `@media (hover: hover) and (pointer: fine)` 里**:教室机器上的希沃触摸屏
* 点一下会产生"幽灵 hover"并**一直粘着**,直到点别处才消失 —— 不关的话,点过「知道了」
* 的按钮会永远保持高亮(同 AGENTS.md §5 第 30 条那类问题)。
* 这是 beUI `useHoverCapable` 的 CSS 版:纯样式效果用媒体查询就够,不必挂 composable。
*/
@media (hover: hover) and (pointer: fine) {
.list-row:hover {
background: var(--wn-surface);
}
.more-btn:hover,
.ghost-btn:hover {
background: var(--wn-surface-hover);
}
.icon-btn:hover {
background: var(--wn-surface-hover);
color: #ffffff;
}
}
/* 同一个通知组件用于经典岛绝对层与自定义岛自然测量层。 */
.classic-notifications {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
@@ -1,4 +1,4 @@
/* ClassIsland ClassicTheme: 40px 行高、12px 内边距、550ms 背景、200ms 内容淡入。 */
/* 自定义岛:共用经典岛形状/动效与通知排版,组件行采用居中自然尺寸并自适应。 */
.resident-window {
position: fixed;
inset: 10px;
@@ -16,18 +16,14 @@
max-width: none;
max-height: none;
overflow: hidden;
overflow: clip;
clip-path: var(--card-path);
isolation: isolate;
pointer-events: auto;
background: var(--resident-background);
background: none;
border-radius: var(--resident-radius);
font-size: 16px;
font-size: var(--island-font);
line-height: 1.4;
box-shadow: 0 2px 3px #0000001c;
transition:
width var(--resident-duration) cubic-bezier(0, 1, 0.5, 1.1),
height var(--resident-duration) cubic-bezier(0, 1, 0.5, 1.1),
border-radius var(--resident-fade-duration) ease;
box-shadow: none;
}
.resident-window[data-anchor='left'] .resident-card {
left: 0;
@@ -86,7 +82,7 @@
align-items: center;
gap: 6px;
padding: 0 12px;
min-height: 40px;
min-height: max(40px, calc(var(--island-font) * 2.5));
}
.resident-window .resident-card.resident-separated {
background: transparent;
@@ -109,17 +105,20 @@
.resident-card .layout-rows {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
}
.resident-card .row-components {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
min-height: 40px;
min-height: max(40px, calc(var(--island-font) * 2.5));
}
.resident-card .resident-compact .component-node:not(.container) {
min-height: 40px;
max-height: 40px;
min-height: max(40px, calc(var(--island-font) * 2.5));
display: flex;
align-items: center;
padding: 0 calc(var(--row-gap, 12px) / 2);
@@ -158,6 +157,7 @@
.resident-card .resident-details {
min-width: 160px;
padding: 0 12px 12px;
text-align: center;
}
.resident-card .resident-panel-heading {
display: flex;
@@ -193,89 +193,6 @@
width: 100%;
max-width: 100%;
}
.resident-card .resident-messages {
display: flex;
flex-direction: column;
gap: 8px;
}
.resident-card .list-row {
display: block;
height: auto;
min-height: 0;
padding: 12px;
border: 1px solid #ffffff18;
border-radius: 6px;
background: #ffffff08;
}
.resident-card .resident-message-title {
display: flex;
align-items: center;
gap: 12px;
}
.resident-card .resident-message-title strong {
flex: 1;
min-width: 0;
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resident-card .resident-message-title > span {
flex: none;
color: #ffffff80;
font-size: 11px;
}
.resident-card .list-row[data-priority='URGENT'] .resident-message-title > span {
color: #ff9c94;
}
.resident-card .list-row p {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
margin: 6px 0;
font-size: 14px;
line-height: 1.5;
overflow-wrap: anywhere;
white-space: pre-line;
}
.resident-card .list-row:only-of-type p {
-webkit-line-clamp: 5;
}
.resident-card .list-row small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #ffffff80;
}
.resident-card .resident-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-top: 4px;
}
.resident-card .resident-actions button:first-child {
margin-right: auto;
}
.resident-card .resident-actions button,
.resident-card .resident-more {
border: 0;
border-radius: 6px;
padding: 8px 10px;
background: #ffffff0d;
color: inherit;
font: inherit;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.resident-card .resident-actions .solid-btn {
background: #ffffffdd;
color: #202124;
}
.resident-card button:focus-visible {
outline: 2px solid #adc4ff;
outline-offset: -2px;
@@ -302,3 +219,37 @@
opacity: 1;
}
}
.resident-card .resident-details.with-messages {
padding: 0;
min-width: 0;
}
.resident-card .classic-notifications {
position: relative;
text-align: center;
}
.resident-card .classic-notifications .head-meta {
justify-content: center;
}
.resident-card .classic-notifications .foot {
justify-content: center;
flex-wrap: wrap;
}
.resident-card .classic-notifications .row-text {
align-items: center;
}
.resident-card .classic-notifications .row-title,
.resident-card .classic-notifications .row-sub {
max-width: 100%;
}
.resident-card .resident-source .component-node {
text-align: center;
justify-content: center;
}
.resident-card .resident-compact .schedule > div {
align-items: center;
}
.resident-card.resident-separated > .shape,
.resident-card.resident-separated > .tint {
visibility: hidden;
}
+26 -26
View File
@@ -203,7 +203,7 @@ class IslandController {
return getConfig().islandLayout.layout === 'custom';
}
private get residentEnabled(): boolean {
return this.customLayout && getConfig().islandLayout.residentEnabled;
return this.customLayout;
}
private residentWasEnabled = false;
private residentFaded = false;
@@ -514,11 +514,17 @@ class IslandController {
* 用户看到的就是「点了没反应 / 点开又立刻缩回去」。
* 因此这里再用**指针位置**兜一层:指针还在岛上就绝不收起(详见 isCursorOnIsland)。
*/
win.on('resize', () => {
if (this.customLayout && !this.editingLayout && this.ready && !win.isDestroyed()) {
const { width, height } = win.getContentBounds();
win.webContents.send('island:viewport', { width, height });
}
});
win.on('blur', () => {
if (this.editingLayout) return;
// 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启)
if (!this.blurCollapseEnabled) return;
if (this.state.mode !== 'expanded' || (this.state.inClass && !this.residentEnabled)) return;
if (this.state.mode !== 'expanded') return;
// 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点)
if (this.previewId && this.state.active?.id === this.previewId) return;
const sinceActivated = Date.now() - this.state.updatedAt;
@@ -582,6 +588,7 @@ class IslandController {
}
// 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值
this.emitAppearance();
this.win?.webContents.send('island:schedules', this.schedules);
this.emit();
if (this.residentEnabled) {
this.residentWasEnabled = true;
@@ -594,6 +601,11 @@ class IslandController {
return this.win;
}
private schedules: ContractScheduleDto[] = [];
getSchedules(): ContractScheduleDto[] {
return [...this.schedules];
}
getState(): IslandState {
return { ...this.state, queued: [...this.state.queued] };
}
@@ -740,22 +752,6 @@ class IslandController {
}
if (this.previewId && this.previewId !== notification.id) this.endPreview(this.previewId);
if (this.residentEnabled) {
// 常驻模式按用户选择先亮铃铛,普通/紧急/叫人均由点击打开;已经在看通知时继续显示列表。
const showingNotifications = this.state.mode === 'expanded' && this.state.active !== null;
if (this.state.active && this.state.active.id !== notification.id)
this.state.queued = [
...this.state.queued.filter((item) => item.id !== this.state.active!.id),
this.state.active,
];
this.activate(
notification,
notification.priority === 'URGENT' ? 'urgent' : 'new',
showingNotifications ? 'expanded' : 'pill',
);
return;
}
if (this.isImmediate(notification)) {
// 插播:如果正在展示别的消息,把它放回队列
if (this.state.active && this.state.active.id !== notification.id) {
@@ -807,18 +803,15 @@ class IslandController {
/** 上课状态变化:进入上课隐藏;下课则自动弹出队列 */
setClassState(payload: IslandClassStatePayload): void {
if (Array.isArray(payload.schedules))
this.win?.webContents.send('island:schedules', payload.schedules.slice(0, 100));
if (Array.isArray(payload.schedules)) {
this.schedules = payload.schedules.slice(0, 100);
if (this.ready && this.win && !this.win.isDestroyed())
this.win.webContents.send('island:schedules', this.schedules);
}
const wasInClass = this.state.inClass;
this.state.inClass = payload.inClass;
this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null;
if (this.residentEnabled) {
this.state.updatedAt = Date.now();
this.emit();
return;
}
if (payload.inClass) {
if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏');
this.explicitShowId = null;
@@ -1114,6 +1107,10 @@ class IslandController {
if (!this.win || this.win.isDestroyed()) return;
// 焦点/可激活性统一由 applyFocusable 维护(窗口必须始终可激活,否则丢 mousedown)
if (this.win.isFocused()) this.win.blur();
if (this.customLayout) {
this.syncWindow();
return;
}
if (this.win.isVisible()) this.win.hide();
this.win.setOpacity(1);
}
@@ -2223,6 +2220,9 @@ export const island = new IslandController();
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
export function registerIslandIpc(): void {
ipcMain.handle('island:get-schedules', (event) =>
event.sender === island.getWindow()?.webContents ? island.getSchedules() : [],
);
ipcMain.handle('island:get-layout', () => getConfig().islandLayout);
ipcMain.handle('island:save-layout', (_event, value: unknown) => {
const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout;
@@ -90,25 +90,21 @@ export async function verifyLayoutSmoke(
await pause();
return !island.getEditingLayout() && getConfig().islandLayout.schemes[0]!.rows.length === 2;
});
await check('自定义窗口等于卡片加阴影留白且夹紧工作区', async () => {
await check('自定义岛统一使用固定画布且常态组件居中适配', async () => {
await pause();
const rect = await win.webContents.executeJavaScript(
`(() => {const r=document.querySelector('.custom-card').getBoundingClientRect(), inner=document.querySelector('.custom-inner').getBoundingClientRect();return {width:r.width,height:r.height,contentWidth:inner.width+24,contentHeight:inner.height+24};})()`,
);
const box = win.getBounds();
return (
Math.abs(box.width - rect.width - 2 * ISLAND_SHADOW_PAD) <= 2 &&
Math.abs(box.height - rect.height - 2 * ISLAND_SHADOW_PAD) <= 2 &&
Math.abs(rect.width - rect.contentWidth) <= 2 &&
Math.abs(rect.height - rect.contentHeight) <= 2
);
return win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.resident-card'), source=card?.querySelector('.resident-compact');
if(!card||!source)return false;
const r=card.getBoundingClientRect(),s=source.getBoundingClientRect();
return r.width<=innerWidth-${2 * ISLAND_SHADOW_PAD}&&r.height<=innerHeight-${2 * ISLAND_SHADOW_PAD}&&Math.abs(r.left+r.width/2-s.left-s.width/2)<=2;
})()`);
});
await check('自定义提醒复用原渲染器且进入主要行', async () => {
await check('自定义岛紧急通知立即弹出并复用经典通知内容', async () => {
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
island.pushNotification({
id: 'custom-smoke-alert',
title: '组件化提醒验收',
content: '通知、作业与叫人共用原卡片行为',
content: '通知、作业与叫人共用经典卡片',
priority: 'URGENT',
createdAt: new Date().toISOString(),
teacherName: '临时教师',
@@ -116,50 +112,13 @@ export async function verifyLayoutSmoke(
});
await pause();
await pause();
return win.webContents.executeJavaScript(
`(() => {const host=document.querySelector('#custom-alert-host'),node=host?.querySelector('.island-root.embedded');return Boolean(node&&node.textContent.includes('组件化提醒验收')&&host.closest('[data-row]')===document.querySelector('[data-row]'));})()`,
);
});
await check('提醒展开内容可见,岛宽取组件与提醒的较宽者', async () => {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
const fits = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.custom-card'), host=card?.querySelector('#custom-alert-host');
const layer=host?.querySelector('.expanded-layer');
if(!card||!host||!layer||Number(getComputedStyle(layer).opacity)<0.95)return false;
const rows=[...card.querySelectorAll('.row-components')].map(row=>row.getBoundingClientRect().width);
const expected=Math.max(...rows,host.getBoundingClientRect().width)+24;
const outer=card.getBoundingClientRect(), inner=host.getBoundingClientRect();
return Math.abs(outer.width-expected)<=2 && inner.bottom<=outer.bottom+1;
})()`);
if (fits) return true;
await pause();
}
return false;
});
// 隐藏窗口的 rAF 可能限流;先等原渲染器的形变兜底与尺寸防抖落地,再验最终几何。
await check('组件与提醒同处岛体内部,提醒没有第二层岛壳', async () => {
return win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.custom-card'), host=card?.querySelector('#custom-alert-host');
const shape=host?.querySelector('.shape'), content=host?.querySelector('.content');
if(!card||!host||!shape||!content)return false;
const outer=card.getBoundingClientRect(), inner=host.getBoundingClientRect();
return document.querySelectorAll('.custom-card').length===1 && getComputedStyle(shape).display==='none'
&& getComputedStyle(content).clipPath==='none' && inner.left>=outer.left && inner.right<=outer.right+1
&& inner.top>=outer.top && inner.bottom<=outer.bottom+1 && Boolean(card.querySelector('.clock'));
const result = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.resident-card'), content=card?.querySelector('.classic-notifications'),r=card?.getBoundingClientRect(),c=content?.getBoundingClientRect();
const valid=Boolean(content&&content.textContent.includes('组件化提醒验收')&&card.querySelectorAll('.shape').length===1&&c.left>=r.left-1&&c.right<=r.right+1&&c.bottom<=r.bottom+1);
return {valid,card:r?.toJSON(),content:c?.toJSON(),shape:card?.querySelectorAll('.shape').length};
})()`);
});
await check('主要行禁用提醒后按允许提醒的行回落', async () => {
const changed = getConfig().islandLayout;
const rows = changed.schemes[0]!.rows;
rows[0]!.allowNotifications = false;
saveConfig({ islandLayout: changed });
island.syncCustomLayout();
await pause();
await pause();
return win.webContents.executeJavaScript(
`(() => {const host=document.querySelector('#custom-alert-host');return Boolean(host?.querySelector('.island-root.embedded')&&host.closest('[data-row]')===document.querySelectorAll('[data-row]')[1]);})()`,
);
if (!result.valid) throw Error(JSON.stringify(result));
return island.getState().mode === 'expanded';
});
if (process.env.ISLAND_SHOTS_DIR)
fs.writeFileSync(
@@ -57,6 +57,7 @@ export async function runQuickSmoke(win: BrowserWindow): Promise<void> {
});
for (const [name, method] of [
['IndexedDB 缓存读写', 'cacheSelfTest'],
['课表变更和重连立即刷新,慢学期接口与迟到响应不阻塞', 'islandScheduleSyncSelfTest'],
['断网缓存回退', 'offlineScenario'],
['服务端与班级缓存隔离', 'cacheIsolationSelfTest'],
]) {
@@ -60,7 +60,7 @@ export async function verifyResidentSmoke(
scheme.expandedRows[0]!.nodes = [islandNode('date', 'resident-date'), expandedText];
island.handleAction({ action: 'dismiss-all' });
await pause();
await check('旧岛配置默认关闭常驻,并兼容补齐空闲展开布局', () => {
await check('旧自定义配置迁移到统一常驻界面,并补齐空闲展开布局', () => {
const legacy = JSON.parse(JSON.stringify(fixture));
delete legacy.residentEnabled;
delete legacy.schemes[0].expandedRows;
@@ -69,7 +69,7 @@ export async function verifyResidentSmoke(
Object.assign(oversized.schemes[0], { scale: 99, radius: 99, opacity: -1, spacing: 999 });
const bounds = normalizeIslandLayouts(oversized).schemes[0]!;
return (
!value.residentEnabled &&
value.residentEnabled &&
value.schemes[0]!.expandedRows[0]!.nodes.length > 0 &&
bounds.scale === 2.5 &&
bounds.radius === 20 &&
@@ -164,7 +164,9 @@ export async function verifyResidentSmoke(
});
await shot('resident-empty-expanded');
await check('无通知展开页可以收回常驻岛且尺寸恢复', async () => {
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="收起常驻岛"]')?.click()`);
await win.webContents.executeJavaScript(
`document.querySelector('[aria-label="收起自定义灵动岛"]')?.click()`,
);
await pause(800);
return (
island.getState().mode === 'hidden' &&
@@ -187,7 +189,7 @@ export async function verifyResidentSmoke(
island.pushNotification(message('resident-normal', '常驻通知一', 'NORMAL'));
await pause();
return (
island.getState().mode === 'pill' &&
island.getState().mode === 'hidden' &&
(await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.custom-card'),bell=card?.querySelector('.resident-bell');
return Boolean(bell&&!bell.classList.contains('bell-empty')&&bell.textContent.trim()==='1'&&Math.abs(card.getBoundingClientRect().width-${compactWidth})<=2&&!card.classList.contains('has-alert'));
@@ -195,18 +197,20 @@ export async function verifyResidentSmoke(
);
});
await shot('resident-bell');
await check('紧急消息也先提示铃铛,重复消息不增加数量', async () => {
await check('紧急消息沿用经典岛立即展开,重复消息不增加数量', async () => {
island.pushNotification(message('resident-urgent', '常驻通知二', 'URGENT'));
island.pushNotification(message('resident-urgent', '常驻通知二', 'URGENT'));
await pause();
return (
island.getState().mode === 'pill' &&
island.getState().mode === 'expanded' &&
(await win.webContents.executeJavaScript(
`document.querySelector('.resident-bell')?.textContent.trim()==='2'`,
))
);
});
await check('点击铃铛展开通知列表,上课时也可主动查看', async () => {
island.handleAction({ action: 'collapse' });
await pause(800);
await win.webContents.executeJavaScript(`document.querySelector('.resident-bell')?.click()`);
await pause(1800);
return (
@@ -219,7 +223,7 @@ export async function verifyResidentSmoke(
await shot('resident-notifications');
await check('收起通知保留铃铛,键盘可重新展开', async () => {
await win.webContents.executeJavaScript(
`document.querySelector('.resident-panel-heading .icon-btn')?.click()`,
`document.querySelector('.classic-notifications .icon-btn')?.click()`,
);
await pause(800);
const collapsed = island.getState().mode === 'pill';
@@ -253,11 +257,58 @@ export async function verifyResidentSmoke(
await pause();
island.pushNotification(message('resident-after-open', '展开后新消息', 'NORMAL'));
await pause(800);
const collapsed = island.getState().mode === 'pill';
const collapsed = island.getState().mode === 'hidden';
island.handleAction({ action: 'dismiss-all' });
await pause();
return collapsed;
});
await check('课表变更立即传到常态与展开组件,重新加载后仍恢复最新快照', async () => {
const updated = [
{
id: 'island-schedule-live',
classId: 'local',
courseId: 'local-course',
dayOfWeek: new Date().getDay() || 7,
startTime: '00:00',
endTime: '23:59',
location: null,
weekStart: 1,
weekEnd: 40,
weekParity: 'ALL' as const,
createdAt: new Date().toISOString(),
course: { id: 'local-course', name: '实时课程' },
},
];
const settings = JSON.parse(JSON.stringify(fixture));
settings.schemes[0].rows[0].nodes = [islandNode('schedule', 'live-schedule')];
saveConfig({ islandLayout: settings });
island.syncCustomLayout();
await pause();
const started = Date.now();
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1, schedules: updated });
const waitForCourse = async () => {
for (let i = 0; i < 30; i++) {
if (
await win.webContents.executeJavaScript(
`document.querySelector('.resident-compact .schedule')?.textContent.includes('实时课程')`,
)
)
return true;
await pause(20);
}
return false;
};
const immediate = (await waitForCourse()) && Date.now() - started < 600;
await win.webContents.reload();
await pause(800);
const restored = await waitForCourse();
const snapshot = await win.webContents.executeJavaScript('window.island.getSchedules()');
saveConfig({ islandLayout: fixture });
island.syncCustomLayout();
await pause(800);
island.setClassState({ inClass: true, currentPeriodEnd: null, week: 1, schedules: [] });
return immediate && restored && snapshot[0]?.course?.name === '实时课程';
});
await check('常驻岛开合复用固定画布,动画中没有滚动容器或旧胶囊形变', async () => {
const bounds = new Set<string>();
const timer = setInterval(() => bounds.add(JSON.stringify(win.getBounds())), 20);
@@ -365,8 +416,8 @@ export async function verifyResidentSmoke(
island.handleAction({ action: 'expand-list' });
await pause(800);
const geometry = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.resident-card'), footer=card.querySelector('.resident-actions'), r=card.getBoundingClientRect(), f=footer?.getBoundingClientRect();
return {r:r.toJSON(),f:f?.toJSON(), fits:Boolean(f&&f.bottom<=r.bottom+1&&f.left>=r.left&&f.right<=r.right+1&&card.querySelector('.resident-more')&&[card,...card.querySelectorAll('*')].every(n=>!['auto','scroll'].includes(getComputedStyle(n).overflowY)))};
const card=document.querySelector('.resident-card'), footer=card.querySelector('.classic-notifications .foot'), r=card.getBoundingClientRect(), f=footer?.getBoundingClientRect();
return {r:r.toJSON(),f:f?.toJSON(), fits:Boolean(f&&f.bottom<=r.bottom+1&&f.left>=r.left&&f.right<=r.right+1&&(card.querySelector('.list-hint')||card.querySelectorAll('.list-row').length===8)&&[card,...card.querySelectorAll('*')].every(n=>!['auto','scroll'].includes(getComputedStyle(n).overflowY)))};
})()`);
await shot('resident-many-notifications');
island.handleAction({ action: 'dismiss-all' });
@@ -21,7 +21,8 @@ export interface IslandBridge {
reportSize(value: { width: number; height: number }): void;
reportResidentControl(value: { x: number; y: number; width: number; height: number } | null): void;
noteResidentTouch(): void;
onViewport(handler: (value: { width: number; height: number }) => void): void;
onViewport(handler: (value: { width: number; height: number }) => void): () => void;
getSchedules(): Promise<ContractScheduleDto[]>;
onSchedules(handler: (value: ContractScheduleDto[]) => void): void;
onState(handler: (state: IslandState) => void): void;
@@ -74,8 +75,12 @@ const bridge: IslandBridge = {
reportResidentControl: (value) => ipcRenderer.send('island:resident-control', value),
noteResidentTouch: () => ipcRenderer.send('island:resident-touch'),
onViewport: (handler) => {
ipcRenderer.on('island:viewport', (_event, value) => handler(value));
const listener = (_event: Electron.IpcRendererEvent, value: { width: number; height: number }) =>
handler(value);
ipcRenderer.on('island:viewport', listener);
return () => ipcRenderer.removeListener('island:viewport', listener);
},
getSchedules: () => ipcRenderer.invoke('island:get-schedules'),
onSchedules: (handler) => {
ipcRenderer.on('island:schedules', (_event, value) => handler(value));
},
+112
View File
@@ -1192,9 +1192,121 @@ export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
}
}
/** 本地替换 API,不连接真实服务:覆盖变更推送、重连与迟到响应。 */
export async function islandScheduleSyncSelfTest(): Promise<SmokeCheckResult> {
const [{ api }, { useAuthStore }, { useAppStore }, { useRealtimeStore }, bridge, { SOCKET_EVENTS }] =
await Promise.all([
import('../api/http.js'),
import('../stores/auth.js'),
import('../stores/app.js'),
import('../stores/realtime.js'),
import('../island/bridge.js'),
import('@classhelper/shared'),
]);
const auth = useAuthStore(),
app = useAppStore(),
realtime = useRealtimeStore();
const token = auth.token,
week = app.currentWeek,
originalGet = api.get,
originalOn = realtime.on;
const callbacks = new Map<string, (value: unknown) => void>();
const terms: Array<(value: unknown) => void> = [];
let name = '变更后课程',
calls = 0,
delay = false,
release: (() => void) | undefined;
const snapshot = (courseName: string) => ({
days: [
{
schedules: [
{
id: 'local-live-course',
classId: 'local',
courseId: 'local-course',
dayOfWeek: new Date().getDay() || 7,
startTime: '00:00',
endTime: '23:59',
weekStart: 1,
weekEnd: 40,
weekParity: 'ALL',
course: { id: 'local-course', name: courseName },
},
],
},
],
});
bridge.stopIslandBridge();
const key = `week:${week}`,
cached = await cacheGet('schedules', key);
try {
auth.applyToken('local-smoke-schedule');
api.get = <T>(url: string): Promise<T> => {
if (url === '/schedules/active-week') {
calls++;
const data = snapshot(name);
if (delay) {
delay = false;
return new Promise<T>((resolve) => {
release = () => resolve(data as T);
});
}
return Promise.resolve(data as T);
}
// 模拟很慢的学期接口,不能再挡住课表推送。
return new Promise<T>((resolve) => terms.push((value) => resolve(value as T)));
};
realtime.on = (event, handler) => {
callbacks.set(event, handler);
originalOn(event, handler);
};
bridge.startIslandBridge();
const wait = async (expected: string) => {
const deadline = performance.now() + 700;
while (performance.now() < deadline) {
if (bridge.getIslandSchedules()[0]?.course?.name === expected) return true;
await new Promise((r) => setTimeout(r, 10));
}
return false;
};
const started = performance.now();
callbacks.get(SOCKET_EVENTS.scheduleUpdated)?.({});
const changed = await wait(name),
elapsed = performance.now() - started;
name = '断线重连课程';
callbacks.get('__connected__')?.({});
const reconnected = await wait(name);
name = '迟到的旧课程';
delay = true;
const old = bridge.refreshIslandSchedules();
await new Promise((r) => setTimeout(r, 10));
name = '最新课程';
await bridge.refreshIslandSchedules();
release?.();
await old;
const fresh = bridge.getIslandSchedules()[0]?.course?.name === name;
const saved = await cacheGet<Array<{ course?: { name: string } }>>('schedules', key);
const cacheFresh = saved?.value[0]?.course?.name === name;
return {
ok: changed && reconnected && fresh && cacheFresh && calls === 5,
detail: `本地推送刷新 ${Math.round(elapsed)}ms,重连=${reconnected},迟到响应隔离=${fresh && cacheFresh},课表请求=${calls}`,
};
} finally {
bridge.stopIslandBridge();
api.get = originalGet;
realtime.on = originalOn;
for (const resolve of terms) resolve({ currentWeek: week, maxWeek: 40 });
auth.applyToken(token);
app.currentWeek = week;
if (cached) await cacheSet('schedules', key, cached.value);
else await cacheRemove('schedules', key);
}
}
/** 注册到 window,供主进程冒烟脚本调用 */
export function registerSmokeHooks(): void {
window.__classhelperSmoke__ = {
islandScheduleSyncSelfTest,
cacheSelfTest,
cacheIsolationSelfTest,
offlineScenario,
@@ -23,11 +23,11 @@ import { useRealtimeStore } from '../stores/realtime.js';
* 职责:把"当前是否在上课时间段"这件事算出来并同步给主进程 ——
* 主进程据此决定通知是"立即弹出"还是"上课期间隐藏、下课后自动弹出"。
*
* 判定完全基于本地缓存的课表(离线也能算),并每 10 秒复算一次,
* 因此下课后最多 10 秒内会自动弹出暂存的通知。
* 判定完全基于本地缓存的课表(离线也能算),并每秒复算一次,
* 因此下课后最多 1 秒内会自动弹出暂存的通知。
*/
const TICK_INTERVAL_MS = 10_000;
const REFRESH_INTERVAL_MS = 5 * 60_000;
const TICK_INTERVAL_MS = 1_000;
const REFRESH_INTERVAL_MS = 30_000;
let schedules: ScheduleDto[] = [];
let currentPeriod: ClassPeriod | null = null;
@@ -54,27 +54,38 @@ export async function refreshIslandSchedules(): Promise<void> {
if (!auth.token) return;
const scope = getCacheScope();
const classId = auth.classId ?? undefined;
await appStore.refreshTerm(classId);
if (scope !== getCacheScope() || !auth.token) return;
const request = ++scheduleRequest;
if (scope !== getCacheScope() || !auth.token || request !== scheduleRequest) return;
const week = appStore.currentWeek;
const result = await fetchWithCache<ScheduleDto[]>(
'schedules',
`week:${week}`,
async () =>
(
async () => {
const data = (
await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', { classId, week })
).days.flatMap((day) => day.schedules),
).days.flatMap((day) => day.schedules);
// 网络结果先上岛,IndexedDB 写入不再挡住可见内容;旧请求与切换账号的结果不得回写。
if (scope !== getCacheScope() || request !== scheduleRequest || !auth.token)
throw new Error('课表刷新已被新请求替代');
schedules = data;
scheduleWeek = week;
recomputeClassState(true);
return data;
},
[],
);
if (scope !== getCacheScope() || request !== scheduleRequest) return;
if (result.error && !result.updatedAt) return;
schedules = result.data;
scheduleWeek = week;
recomputeClassState(true);
if (result.fromCache) {
schedules = result.data;
scheduleWeek = week;
recomputeClassState(true);
}
}
let sentDay = '';
function recomputeClassState(force = false): void {
const day = new Date().toDateString();
const appStore = useAppStore();
const status = resolveClassStatus(schedules, new Date(), scheduleWeek);
const nextInClass = status.inClass;
@@ -83,7 +94,8 @@ function recomputeClassState(force = false): void {
currentPeriod?.endTime === status.current?.endTime;
currentPeriod = status.current;
if (!force && nextInClass === inClass && samePeriod) return;
if (!force && day === sentDay && nextInClass === inClass && samePeriod) return;
sentDay = day;
inClass = nextInClass;
window.desktop?.islandSetClassState({
@@ -329,6 +341,10 @@ export function subscribeIslandMarkAllRead(): void {
/** 具名的课表变更处理器:匿名函数在停止时无法 off 掉,会随"登出→登录"越挂越多 */
function onScheduleUpdated(): void {
// 教学周校正并行进行,课表变更不再等待另一个接口;周次改变由 watch 补取。
void useAppStore()
.refreshTerm(useAuthStore().classId ?? undefined)
.catch(() => undefined);
void refreshIslandSchedules().catch(() => undefined);
}
@@ -341,14 +357,15 @@ export function startIslandBridge(): void {
},
);
void refreshIslandSchedules().catch(() => undefined);
onScheduleUpdated();
useRealtimeStore().on('__connected__', onScheduleUpdated);
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
refreshTimer = setInterval(() => {
void refreshIslandSchedules().catch(() => undefined);
onScheduleUpdated();
}, REFRESH_INTERVAL_MS);
}
@@ -370,6 +387,8 @@ export function stopIslandBridge(): void {
stopWeekWatch = null;
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
useRealtimeStore().off('__connected__', onScheduleUpdated);
sentDay = '';
schedules = [];
currentPeriod = null;
inClass = false;
@@ -381,5 +400,6 @@ export function stopIslandBridge(): void {
inClass: false,
currentPeriodEnd: null,
week: useAppStore().currentWeek,
schedules: [],
});
}
@@ -44,12 +44,7 @@ async function changeLayout() {
}
async function chooseLayout(mode: 'fixed' | 'custom') {
layoutMode.value = mode;
if (mode === 'fixed') residentEnabled.value = false;
await changeLayout();
}
async function changeResident(value: boolean | string | number) {
residentEnabled.value = value === true;
if (residentEnabled.value) layoutMode.value = 'custom';
residentEnabled.value = mode === 'custom';
await changeLayout();
}
async function changePointerFade(
@@ -152,7 +147,7 @@ onMounted(async () => {
<div class="page-header">
<div>
<h2 class="page-title">灵动岛</h2>
<p class="page-subtitle">屏幕上方的消息浮窗:外观、停靠位置与动画</p>
<p class="page-subtitle">经典岛与自定义岛共用外观、停靠位置、动画与提醒规则</p>
</div>
</div>
@@ -175,36 +170,20 @@ onMounted(async () => {
@click="chooseLayout('custom')"
>
<strong>自定义灵动岛</strong>
<span>在岛内排列时钟、课程和消息等组件</span>
<span>常驻显示时钟、课程等组件,通知沿用经典岛</span>
</button>
</div>
<div class="resident-setting">
<div>
<strong>常驻岛</strong>
<p>始终显示自定义内容;收到消息后显示铃铛,点击查看。没有通知时可展开另一套自定义内容。</p>
</div>
<el-switch :model-value="residentEnabled" aria-label="常驻岛" @change="changeResident" />
</div>
<div v-if="layoutMode === 'custom'" class="layout-edit-entry">
<div>
<strong>按你的习惯布置岛内内容</strong>
<p>
{{
residentEnabled
? '分别编辑「收起时」与「无通知展开时」的内容,点选组件即可设置。'
: '在预览中添加和排列组件,点选组件即可设置;更改自动保存到本机。'
}}
</p>
<p>编辑常态内容与无通知展开内容;通知样式、弹出规则和外观设置与经典岛共用。</p>
</div>
<el-button type="primary" @click="editLayout">编辑岛内组件</el-button>
</div>
<div v-if="residentEnabled" class="resident-window-options">
<div v-if="layoutMode === 'custom'" class="resident-window-options">
<div>
<strong>ClassIsland 窗口设置</strong>
<p>
常驻行基准高度 40 px。指针移入背景后淡化至 5%
并穿透,右侧铃铛或展开按钮保持可用;编辑、展开和触摸交互期间暂停避让。
</p>
<strong>指针避让</strong>
<p>指针移入后淡化并穿透,右侧展开入口保持可用;展开和触摸交互期间暂停避让。</p>
</div>
<label>
<span>指针移入淡化</span>
@@ -225,87 +204,86 @@ onMounted(async () => {
<el-card shadow="never">
<template #header>
<span>{{ layoutMode === 'fixed' ? '外观与位置' : '位置与提醒' }}</span>
<span>外观、位置与弹出</span>
</template>
<el-form label-width="96px" label-position="left">
<template v-if="layoutMode === 'fixed'">
<el-form-item :label="`高度 ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
:max="RANGES.height.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ height: value })"
<p class="text-muted">紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。</p>
<el-form label-width="110px" label-position="left">
<el-form-item :label="`${layoutMode === 'custom' ? '通知高度' : '高度'} ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
:max="RANGES.height.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ height: value })"
/>
</el-form-item>
<el-form-item :label="`${layoutMode === 'custom' ? '通知宽度' : '宽度'} ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
:max="RANGES.width.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ width: value })"
/>
</el-form-item>
<el-form-item :label="`圆角 ${island.radius}px`">
<el-slider
:model-value="island.radius"
:min="RANGES.radius.min"
:max="RANGES.radius.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ radius: value })"
/>
</el-form-item>
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
<el-slider
:model-value="island.opacity"
:min="RANGES.opacity.min"
:max="RANGES.opacity.max"
:step="0.05"
@input="(value: number) => applyIslandAppearance({ opacity: value })"
/>
</el-form-item>
<el-form-item :label="`字号 ${island.fontSize}px`">
<el-slider
:model-value="island.fontSize"
:min="RANGES.fontSize.min"
:max="RANGES.fontSize.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
/>
</el-form-item>
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
<el-slider
:model-value="island.speed"
:min="RANGES.speed.min"
:max="RANGES.speed.max"
:step="0.1"
@input="(value: number) => applyIslandAppearance({ speed: value })"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker
:model-value="island.accent"
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
/>
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="视觉风格">
<el-select
:model-value="island.style"
style="width: 180px"
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
>
<el-option
v-for="item in styleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-form-item>
<el-form-item :label="`宽度 ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
:max="RANGES.width.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ width: value })"
/>
</el-form-item>
<el-form-item :label="`圆角 ${island.radius}px`">
<el-slider
:model-value="island.radius"
:min="RANGES.radius.min"
:max="RANGES.radius.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ radius: value })"
/>
</el-form-item>
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
<el-slider
:model-value="island.opacity"
:min="RANGES.opacity.min"
:max="RANGES.opacity.max"
:step="0.05"
@input="(value: number) => applyIslandAppearance({ opacity: value })"
/>
</el-form-item>
<el-form-item :label="`字号 ${island.fontSize}px`">
<el-slider
:model-value="island.fontSize"
:min="RANGES.fontSize.min"
:max="RANGES.fontSize.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
/>
</el-form-item>
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
<el-slider
:model-value="island.speed"
:min="RANGES.speed.min"
:max="RANGES.speed.max"
:step="0.1"
@input="(value: number) => applyIslandAppearance({ speed: value })"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker
:model-value="island.accent"
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
/>
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="视觉风格">
<el-select
:model-value="island.style"
style="width: 180px"
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
>
<el-option
v-for="item in styleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item>
</template>
</el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item>
<el-form-item label="显示位置">
<el-select
:model-value="island.position"
+1
View File
@@ -253,6 +253,7 @@ declare global {
island?: IslandRendererBridge;
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
__classhelperSmoke__?: {
islandScheduleSyncSelfTest(): Promise<{ ok: boolean; detail: string }>;
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
cacheIsolationSelfTest(): Promise<{ ok: boolean; detail: string }>;
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
+3 -2
View File
@@ -214,7 +214,7 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
const fallback = defaultIslandLayouts();
if (!input || typeof input !== 'object') return fallback;
const raw = input as IslandLayoutSettings;
const resident = raw.layout === 'custom' && raw.residentEnabled === true;
const resident = raw.layout === 'custom';
const clamp = (n: unknown, min: number, max: number, value: number) =>
typeof n === 'number' && Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : value;
const color = (v: unknown, value: string) =>
@@ -327,7 +327,8 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
}
return {
layout: raw.layout === 'custom' ? 'custom' : 'fixed',
residentEnabled: raw.layout === 'custom' && raw.residentEnabled === true,
// 旧常驻开关保留为兼容字段;自定义布局统一常驻,不再分成两种运行模式。
residentEnabled: raw.layout === 'custom',
pointerFadeEnabled: raw.pointerFadeEnabled !== false,
pointerFadeReversed: raw.pointerFadeReversed === true,
schemes: schemes.length ? schemes : fallback.schemes,