fix(island): 合并常驻与自定义岛并统一经典通知和即时课表同步
This commit is contained in:
1 parent
05848570d1
commit
e84e43544a
19 files changed
+1487
-1367
No files matched your search
@@ -130,3 +130,31 @@ Windows 触摸模式使用 Electron setShape:动画开始放开绘制区域,
|
||||
- 新旧课表界面与岛桥接共用的缓存仍保存整周数据,岛组件只接收当天投影,避免当天响应覆盖整周离线缓存。
|
||||
- 最终复跑曾遇到本机冷启动超时:桌面第一次界面无响应,原样重跑在已通过的检查之后被旧 45 秒总限时中止;临时后端也超过了原 10 秒就绪等待。外层预算分别调整为 90 秒与 60 秒,心跳 30 秒等单项业务断言不变。
|
||||
调整后最终复跑通过后端 243/243 与桌面 24/24(含二次启动),桌面整套实际耗时 20.6 秒。
|
||||
|
||||
## 常驻岛并入自定义灵动岛(最新行为)
|
||||
|
||||
后续产品要求统一两种自定义显示方式,本节取代上文关于可选常驻、铃铛优先与独立通知卡的历史行为。
|
||||
`layout` 仍为 fixed/custom;custom 直接显示常态组件,`residentEnabled` 只保留为旧配置兼容字段,归一化为 custom 时开启。
|
||||
设置页不再提供独立的常驻开关。编辑器保留常态 rows 与无通知展开 expandedRows,两套内容随本机方案保存。
|
||||
|
||||
自定义运行时统一为 `CustomIslandRuntime.vue`,不再将旧卡片嵌入组件行。
|
||||
经典岛与自定义岛共同使用 `ClassicNotifications.vue` 和 `classic-island.css`,通知类型、排序、徽标、正文、按钮与已读动作只有一份实现。
|
||||
纯黑、玻璃、主题色、透明度、字号、圆角、动画开关与速度均来自同一份 island 外观设置;方案保留组件缩放、行距和布局。
|
||||
自定义组件行、消息正文与通知操作区居中对齐。无通知展开时显示 expandedRows,有通知时显示共用经典通知内容。
|
||||
|
||||
自定义模式不再提前截获所有消息并只亮铃铛:与经典岛走相同的 pushNotification/setClassState 状态机。
|
||||
紧急通知和紧急叫人立即展开,课中普通通知排队、下课后展开;主动点击铃铛仍可查看待处理消息。
|
||||
收起和处理完通知后恢复自定义常态内容。铃铛固定占位,收到消息不横移。空白桌面输入穿透与触摸区域裁剪继续保留。
|
||||
外壳使用经典岛同源的 SpringValue 与 squirclePath;宽高和圆角每帧同时推进,保留零过冲钳制、动画速度与后台吸附兜底。
|
||||
原生画布固定,隐藏渲染器不运行旧形变;统一心跳不随布局切换重复注册。
|
||||
|
||||
课表更新链路做了以下修正:
|
||||
|
||||
- 课表变更事件立即取 active-week,学期接口在旁路校正,避免慢学期请求串行挡住新课表。
|
||||
- 网络结果先同步到岛,再异步完成缓存写入;请求序号在请求开始前分配,迟到响应和已切换账号的结果不会覆盖当前内容。
|
||||
- 实时连接建立及重连时立即刷新;周期补偿从五分钟缩至三十秒,课程边界每秒复算。跨午夜即使上课状态未改变,也同步新一天的课程。
|
||||
- 主进程保留最新日课表快照;窗口启动、重建时补发,渲染器通过受限 getSchedules IPC 读回,登出时清空。
|
||||
- 自适应同时接收原生窗口尺寸通知,防止隐藏窗口的浏览器 resize 延迟而沿用旧宽度;订阅在组件退出时释放。
|
||||
|
||||
验证使用隔离配置、本地消息和替换的本地 API,覆盖慢学期接口、变更事件、重连刷新、迟到请求隔离、课表 IPC 即时更新及窗口重新加载恢复。
|
||||
桌面视觉回归覆盖经典岛原有状态,以及自定义岛单通知、多通知、常态、空闲展开、窄屏、长文本、触摸和固定原生窗口动画。
|
||||
@@ -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">
|
||||
|
||||
+126
-65
@@ -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
|
||||
v-if="!messages.length"
|
||||
class="layout-rows resident-expanded-rows"
|
||||
:style="{ gap: `${scheme.spacing}px` }"
|
||||
>
|
||||
<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` }">
|
||||
<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;
|
||||
}
|
||||
+47
-96
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
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};
|
||||
})()`);
|
||||
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'));
|
||||
})()`);
|
||||
});
|
||||
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));
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
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,11 +204,11 @@ 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`">
|
||||
<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"
|
||||
@@ -238,7 +217,7 @@ onMounted(async () => {
|
||||
@input="(value: number) => applyIslandAppearance({ height: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`宽度 ${island.width}px`">
|
||||
<el-form-item :label="`${layoutMode === 'custom' ? '通知宽度' : '宽度'} ${island.width}px`">
|
||||
<el-slider
|
||||
:model-value="island.width"
|
||||
:min="RANGES.width.min"
|
||||
@@ -305,7 +284,6 @@ onMounted(async () => {
|
||||
</el-select>
|
||||
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="显示位置">
|
||||
<el-select
|
||||
:model-value="island.position"
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user