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;
}