feat(island): 彻底照搬 WinIsland 架构重写灵动岛(固定包围盒窗口 + 窗口内弹簧形变)
背景:上一版仍沿用"每帧移动/缩放透明窗口"的做法,实机上会出现形变抖动与残影
(用户反馈"越改越烂、开合震动还在")。本次按 WinIsland 的真实做法重做架构。
主进程(packages/desktop-client/src/main/island.ts):
- 窗口一次创建为**固定包围盒**(最大形态 + ISLAND_SHADOW_PAD 阴影留白),
开合过程中不再调用 setBounds;只在"尺寸/位置设置变化"时重排一次窗口
- 新增 setInteractive():默认 setIgnoreMouseEvents(true,{forward:true}) 整块穿透,
由渲染层命中测试回调切换(照搬 WinIsland set_cursor_hittest),保证不吞桌面点击
- 移除逐帧几何代码(animateBounds/boundsFor/computeBounds/resolveAnchor 用于窗口定位的简化版保留)
- 空闲细缝不再受 Windows 36px 最小窗口高度限制(窗口固定,岛可真正缩到 6px 宽)
渲染层(IslandApp.vue + 新增 spring.ts):
- 岛是**单一元素**,宽/高/圆角由 WinIsland 弹簧逐帧逼近:
force=(target-value)*0.10*dt → velocity=(velocity+force)*0.68^dt → value+=velocity*dt
(dt 以帧计;换向保留 35% 动量、单帧位移 ≤ 剩余距离 20%),并加零过冲钳制
- 胶囊层与展开层常驻并交叉淡入(展开 progress²、胶囊 1-1.5×progress),形变全程无缩放回弹
- 形状层改为显式像素宽高 + preserveAspectRatio="none"(避免 viewBox 缩放导致形状错位/残留角)
- 修两个实机可见缺陷:① 展开层漏了 flex-direction: column(展开卡内容横排、元素散落);
② 隐藏且无细缝时不再把岛缩成细缝(否则下次弹出会"从细缝长大")
- 尺寸增量 ISLAND_SIZE_DELTA / 阴影留白移入 @classhelper/shared,主/渲染两端共用一份
验证(verify:desktop 53 → 54 项,全绿):
- 新增「开合过程中窗口全程恒定」(逐帧窗口签名恒为 476x282@482,-2,岛 386x230→…→268x44 单调收缩,
SVG 形状 bbox 与卡片框一致 ±2px)
- 新增「岛外鼠标穿透」(岛内 interactive=true / 岛外 false)
- 开合不震动改为按"岛"判定:窗口冻结 + 岛锚点 0.00px + 无过冲;截图按岛矩形裁剪并统计岛外红像素=0
- 其余能力(通知/作业/叫人/紧急、标为已读、上课隐藏、6 锚点、字号可调、设置立即生效、空闲细缝)全部保留
This commit is contained in:
1 parent
93447922dc
commit
7a4668cb1b
8 files changed
+954
-410
No files matched your search
@@ -2,6 +2,9 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_SHADOW_PAD,
|
||||
ISLAND_SIZE_DELTA,
|
||||
ISLAND_SLIVER_WIDTH,
|
||||
type IslandAppearance,
|
||||
PRIORITY_LABELS,
|
||||
formatDate,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
type IslandNotificationKind,
|
||||
type IslandState,
|
||||
} from '@classhelper/shared';
|
||||
import { SpringValue } from './spring.js';
|
||||
import { squirclePath } from './squircle.js';
|
||||
|
||||
/**
|
||||
@@ -91,16 +95,6 @@ const stateColor = computed(() => {
|
||||
return '';
|
||||
});
|
||||
|
||||
/**
|
||||
* 入场动画:胶囊直接出现(WinIsland 的 compact 没有入场动画),
|
||||
* 展开 / 紧急只用透明度淡入,形变完全交给窗口尺寸(因此不会出现缩放回弹)。
|
||||
*/
|
||||
const transitionName = computed(() => (mode.value === 'pill' ? 'island-instant' : 'island-fade'));
|
||||
const transitionDuration = computed(() => ({
|
||||
enter: mode.value === 'pill' ? 0 : Math.round(140 / appearance.value.speed),
|
||||
leave: Math.round(90 / appearance.value.speed),
|
||||
}));
|
||||
|
||||
function expand(): void {
|
||||
bridge?.sendAction('expand');
|
||||
}
|
||||
@@ -132,65 +126,190 @@ function openApp(): void {
|
||||
/** 个性化外观:主进程下发后写入 CSS 变量,形状/排版/配色立即变化(无需重启) */
|
||||
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
|
||||
/** 卡片与窗口的内边距(必须与主进程 CARD_PAD_X / CARD_PAD_Y 一致) */
|
||||
const CARD_PAD_X = 5;
|
||||
const CARD_PAD_Y = 5;
|
||||
/** 各形态相对"胶囊"的尺寸增量(与主进程共用同一常量表,避免两边漂移) */
|
||||
const EXTRA = ISLAND_SIZE_DELTA;
|
||||
|
||||
/**
|
||||
* 各形态相对"胶囊窗口"的额外尺寸(必须与主进程 BASE.expanded/urgent/call 的增量一致):
|
||||
* 卡片尺寸 = 窗口尺寸 - 内边距,因此卡片始终在窗口内居中、四周留白恒定。
|
||||
* 岛的目标尺寸(**岛本身**,不是窗口):
|
||||
* - 胶囊:外观设置里的 width/height
|
||||
* - 展开/紧急/叫人:胶囊 + `EXTRA`
|
||||
* - 空闲细缝:6px 宽竖条(WinIsland hidden_width 的思路)
|
||||
* 全部由下面的弹簧逐帧逼近,窗口(固定包围盒)全程不动。
|
||||
*/
|
||||
const EXTRA = {
|
||||
expanded: { width: 156, height: 186 },
|
||||
urgent: { width: 172, height: 202 },
|
||||
call: { width: 188, height: 218 },
|
||||
} as const;
|
||||
|
||||
/** 当前形态的窗口尺寸(用于推出卡片尺寸) */
|
||||
const windowSize = computed(() => {
|
||||
function targetSize(): { width: number; height: number } {
|
||||
const width = appearance.value.width;
|
||||
const height = appearance.value.height;
|
||||
if (mode.value === 'hidden') {
|
||||
// 有"空闲细缝"时才收成细缝;否则保持胶囊尺寸——
|
||||
// 窗口此时是隐藏的,没必要缩到细缝(否则下次弹出会从细缝"长大",多出入场动画)。
|
||||
if (!appearance.value.idleSliver) return { width, height };
|
||||
return { width: ISLAND_SLIVER_WIDTH, height: Math.max(12, Math.min(28, height - 22)) };
|
||||
}
|
||||
if (mode.value !== 'expanded') return { width, height };
|
||||
const extra = isCall.value ? EXTRA.call : isUrgent.value ? EXTRA.urgent : EXTRA.expanded;
|
||||
return { width: width + extra.width, height: height + extra.height };
|
||||
});
|
||||
|
||||
/** 卡片像素尺寸(= 窗口 - 内边距) */
|
||||
const cardSize = computed(() => ({
|
||||
width: Math.max(2, windowSize.value.width - CARD_PAD_X * 2),
|
||||
height: Math.max(2, windowSize.value.height - CARD_PAD_Y * 2),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 圆角:胶囊取 h/2(满圆角),展开卡取 min(48*系数, w/2, h/2)(WinIsland expanded_island_radius) */
|
||||
const cardRadius = computed(() => {
|
||||
const { width, height } = cardSize.value;
|
||||
if (mode.value !== 'expanded') return Math.min(appearance.value.radius, height / 2);
|
||||
function targetRadius(size: { width: number; height: number }): number {
|
||||
if (mode.value !== 'expanded') return Math.min(appearance.value.radius, size.height / 2);
|
||||
const scaled = (48 * appearance.value.radius) / 20;
|
||||
return Math.max(0, Math.min(scaled, width / 2, height / 2));
|
||||
});
|
||||
return Math.max(0, Math.min(scaled, size.width / 2, size.height / 2));
|
||||
}
|
||||
|
||||
/** 连续圆角路径(超椭圆角,见 squircle.ts) */
|
||||
const cardPath = computed(() => squirclePath(cardSize.value.width, cardSize.value.height, cardRadius.value));
|
||||
/* ---------------------------------------------------------------- 弹簧形变 */
|
||||
|
||||
const springs = {
|
||||
width: new SpringValue(DEFAULT_ISLAND_APPEARANCE.width),
|
||||
height: new SpringValue(DEFAULT_ISLAND_APPEARANCE.height),
|
||||
radius: new SpringValue(DEFAULT_ISLAND_APPEARANCE.radius),
|
||||
};
|
||||
|
||||
/** 当前正在播放的形变值(渲染用) */
|
||||
const morph = ref<{ width: number; height: number; radius: number }>({
|
||||
width: DEFAULT_ISLAND_APPEARANCE.width,
|
||||
height: DEFAULT_ISLAND_APPEARANCE.height,
|
||||
radius: DEFAULT_ISLAND_APPEARANCE.radius,
|
||||
});
|
||||
/** 形变进度 0(胶囊)→ 1(展开):用于内容交叉淡入(WinIsland expanded_alpha / mini_alpha) */
|
||||
const progress = ref(0);
|
||||
|
||||
let rafHandle: number | null = null;
|
||||
let lastFrameAt = 0;
|
||||
|
||||
function syncTargets(snap = false): void {
|
||||
const size = targetSize();
|
||||
const radius = targetRadius(size);
|
||||
if (snap || !appearance.value.animations) {
|
||||
springs.width.snap(size.width);
|
||||
springs.height.snap(size.height);
|
||||
springs.radius.snap(radius);
|
||||
morph.value = { width: size.width, height: size.height, radius };
|
||||
progress.value = mode.value === 'expanded' ? 1 : 0;
|
||||
return;
|
||||
}
|
||||
springs.width.setTarget(size.width);
|
||||
springs.height.setTarget(size.height);
|
||||
springs.radius.setTarget(radius);
|
||||
startFrameLoop();
|
||||
}
|
||||
|
||||
function startFrameLoop(): void {
|
||||
if (rafHandle !== null) return;
|
||||
lastFrameAt = performance.now();
|
||||
const frame = (now: number): void => {
|
||||
const delta = now - lastFrameAt;
|
||||
lastFrameAt = now;
|
||||
// 个性化"动画速度":直接缩放 dt(WinIsland 没有该设置,这是我们的扩展)
|
||||
const scaled = delta * appearance.value.speed;
|
||||
const moving = springs.width.step(scaled) || springs.height.step(scaled) || springs.radius.step(scaled);
|
||||
|
||||
morph.value = {
|
||||
width: springs.width.value,
|
||||
height: springs.height.value,
|
||||
radius: springs.radius.value,
|
||||
};
|
||||
|
||||
// 内容交叉淡入:胶囊层先淡出(进度 2/3 前退净),展开层按平方淡入(WinIsland 的做法)
|
||||
const pill = appearance.value.height;
|
||||
const expanded = targetSize().height;
|
||||
const span = Math.abs(expanded - pill);
|
||||
progress.value =
|
||||
span < 1 ? (mode.value === 'expanded' ? 1 : 0) : Math.abs(morph.value.height - pill) / span;
|
||||
|
||||
if (moving || !springs.width.settled || !springs.height.settled || !springs.radius.settled) {
|
||||
rafHandle = requestAnimationFrame(frame);
|
||||
return;
|
||||
}
|
||||
// 收尾对齐(避免浮点残差)
|
||||
const size = targetSize();
|
||||
springs.width.snap(size.width);
|
||||
springs.height.snap(size.height);
|
||||
springs.radius.snap(targetRadius(size));
|
||||
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
|
||||
progress.value = mode.value === 'expanded' ? 1 : 0;
|
||||
rafHandle = null;
|
||||
};
|
||||
rafHandle = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片内联样式:尺寸、形状、配色、排版全部由外观设置驱动。
|
||||
*
|
||||
* 注意 `left` 用 `calc(50% - 卡片宽/2)`(相对**当前窗口宽度**居中),而不是固定 padding:
|
||||
* 窗口在形变过程中宽度由主进程逐帧插值,卡片只有居中才能保证"卡片中心全程不动"(实测 0.00px)。
|
||||
* 实际生效的圆角:始终钳制到 `min(圆角, w/2, h/2)`。
|
||||
* 弹簧对"尺寸"与"圆角"是各自独立推进的,形变过程中圆角可能短暂大于 h/2;
|
||||
* 统一钳制既保证形状合法,也让 CSS 变量与真实绘制一致(便于断言)。
|
||||
*/
|
||||
const cardStyle = computed(() => {
|
||||
const card = cardSize.value;
|
||||
return {
|
||||
width: `${card.width}px`,
|
||||
height: `${card.height}px`,
|
||||
left: `calc(50% - ${card.width / 2}px)`,
|
||||
top: `${CARD_PAD_Y}px`,
|
||||
const effectiveRadius = computed(() =>
|
||||
Math.max(0, Math.min(morph.value.radius, morph.value.width / 2, morph.value.height / 2)),
|
||||
);
|
||||
|
||||
/** 连续圆角路径(超椭圆角,见 squircle.ts) */
|
||||
const cardPath = computed(() => squirclePath(morph.value.width, morph.value.height, effectiveRadius.value));
|
||||
|
||||
/** 胶囊内容透明度:进度到 2/3 时完全退净(WinIsland MINI_FADE_RATE = 1.5) */
|
||||
const pillAlpha = computed(() => Math.max(0, Math.min(1, 1 - progress.value * 1.5)));
|
||||
/** 展开内容透明度:进度平方(WinIsland expanded_alpha = progress²) */
|
||||
const expandedAlpha = computed(() => progress.value * progress.value);
|
||||
|
||||
/**
|
||||
* 岛在**固定窗口**内的定位(照搬 WinIsland:窗口不动,岛在画布内居中/贴边形变)。
|
||||
* 水平:居中锚点用 `left: 50% + translateX(-50%)`;左/右锚点贴边(留阴影余量)。
|
||||
*/
|
||||
const islandStyle = computed(() => {
|
||||
const position = appearance.value.position;
|
||||
const style: Record<string, string> = {
|
||||
width: `${morph.value.width}px`,
|
||||
height: `${morph.value.height}px`,
|
||||
'--card-path': `path("${cardPath.value}")`,
|
||||
'--card-r': `${cardRadius.value}px`,
|
||||
'--card-r': `${morph.value.radius}px`,
|
||||
'--state-color': stateColor.value || 'transparent',
|
||||
};
|
||||
if (position.endsWith('left')) {
|
||||
style.left = `${ISLAND_SHADOW_PAD}px`;
|
||||
} else if (position.endsWith('right')) {
|
||||
style.right = `${ISLAND_SHADOW_PAD}px`;
|
||||
} else {
|
||||
style.left = '50%';
|
||||
style.transform = 'translateX(-50%)';
|
||||
}
|
||||
if (position.startsWith('bottom')) style.bottom = `${ISLAND_SHADOW_PAD}px`;
|
||||
else style.top = `${ISLAND_SHADOW_PAD}px`;
|
||||
return style;
|
||||
});
|
||||
|
||||
/** 当前形态的样式类(供既有断言与样式复用) */
|
||||
const islandClass = computed(() => ({
|
||||
pill: mode.value === 'pill' || (mode.value === 'hidden' && !appearance.value.idleSliver),
|
||||
expanded: mode.value === 'expanded',
|
||||
'hidden-placeholder': mode.value === 'hidden' && !appearance.value.idleSliver,
|
||||
sliver: mode.value === 'hidden' && appearance.value.idleSliver,
|
||||
urgent: isUrgent.value,
|
||||
call: isCall.value,
|
||||
homework: isHomework.value,
|
||||
'after-class': showAfterClassHint.value,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 鼠标命中测试(照搬 WinIsland 的 set_cursor_hittest 思路):
|
||||
* 指针在岛体上(或可交互控件上)时才让窗口接收鼠标,否则穿透,保证不吞桌面点击。
|
||||
*/
|
||||
let lastInteractive: boolean | null = null;
|
||||
function updateInteractive(clientX: number, clientY: number): void {
|
||||
const node = document.querySelector('.island-card') as HTMLElement | null;
|
||||
let inside = false;
|
||||
if (node) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const margin = 2;
|
||||
inside =
|
||||
clientX >= rect.left - margin &&
|
||||
clientX <= rect.right + margin &&
|
||||
clientY >= rect.top - margin &&
|
||||
clientY <= rect.bottom + margin;
|
||||
}
|
||||
if (inside === lastInteractive) return;
|
||||
lastInteractive = inside;
|
||||
bridge?.setInteractive?.(inside);
|
||||
}
|
||||
|
||||
function applyAppearance(next: IslandAppearance | null | undefined): void {
|
||||
if (!next) return;
|
||||
appearance.value = next;
|
||||
@@ -201,43 +320,63 @@ function applyAppearance(next: IslandAppearance | null | undefined): void {
|
||||
style.setProperty('--island-w', `${next.width}px`);
|
||||
style.setProperty('--island-h', `${next.height}px`);
|
||||
style.setProperty('--island-anim', next.animations ? '1' : '0');
|
||||
// 形变时长(与主进程的缓动时长一致),用于内容淡入淡出的节奏对齐
|
||||
const morph = next.style === 'glass' ? 320 : 320;
|
||||
style.setProperty('--island-dur', `${Math.round(morph / Math.max(0.25, next.speed))}ms`);
|
||||
style.setProperty('--island-dur', `${Math.round(320 / Math.max(0.25, next.speed))}ms`);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
|
||||
bridge?.onState((next) => {
|
||||
state.value = next;
|
||||
syncTargets();
|
||||
});
|
||||
const initial = await bridge?.getState();
|
||||
if (initial) state.value = initial;
|
||||
// 外观:先同步一次,再订阅后续改动(改设置立即生效)
|
||||
bridge?.onAppearance((next) => applyAppearance(next));
|
||||
bridge?.onAppearance((next) => {
|
||||
applyAppearance(next);
|
||||
syncTargets();
|
||||
});
|
||||
const initialAppearance = await bridge?.getAppearance?.();
|
||||
applyAppearance(initialAppearance);
|
||||
syncTargets(true);
|
||||
// 初始状态可能是 hidden(窗口未显示),此时同步一次目标即可
|
||||
syncTargets();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
固定包围盒窗口内的**单个**灵动岛(照搬 WinIsland):
|
||||
岛的宽高/圆角由弹簧逐帧逼近,窗口本身不动;胶囊层与展开层在里面交叉淡入。
|
||||
-->
|
||||
<div class="island-root">
|
||||
<Transition :name="transitionName" :duration="transitionDuration">
|
||||
<!-- 胶囊态:WinIsland compact —— 小尺寸、满圆角、图标 + 文本 + 提示箭头 -->
|
||||
<button
|
||||
v-if="mode === 'pill' && notification"
|
||||
key="pill"
|
||||
type="button"
|
||||
class="island-card pill"
|
||||
:class="{ urgent: isUrgent, call: isCall, homework: isHomework, 'after-class': showAfterClassHint }"
|
||||
:data-style="appearance.style"
|
||||
:style="cardStyle"
|
||||
@click="expand"
|
||||
<div
|
||||
class="island-card"
|
||||
:class="islandClass"
|
||||
:data-style="appearance.style"
|
||||
:style="islandStyle"
|
||||
@click="mode === 'expanded' ? onCardClick($event) : expand()"
|
||||
>
|
||||
<!--
|
||||
形状层:显式给像素宽高 + preserveAspectRatio="none"。
|
||||
只写 width/height:100% 时 SVG 会按 viewBox 比例做 preserveAspectRatio 缩放,
|
||||
形变过程中 viewBox 与元素尺寸不同步就会出现形状错位/残留角(实测肉眼可见)。
|
||||
-->
|
||||
<svg
|
||||
class="shape"
|
||||
:width="Math.max(1, morph.width)"
|
||||
:height="Math.max(1, morph.height)"
|
||||
:viewBox="`0 0 ${Math.max(1, morph.width)} ${Math.max(1, morph.height)}`"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg class="shape" :viewBox="`0 0 ${cardSize.width} ${cardSize.height}`" aria-hidden="true">
|
||||
<path :d="cardPath" />
|
||||
</svg>
|
||||
<span class="content">
|
||||
<span class="tint" aria-hidden="true"></span>
|
||||
<path :d="cardPath" />
|
||||
</svg>
|
||||
<span class="content">
|
||||
<span class="tint" aria-hidden="true"></span>
|
||||
|
||||
<!-- 胶囊层(WinIsland compact):进度 > 2/3 时完全退净 -->
|
||||
<span class="layer pill-layer" :style="{ opacity: pillAlpha }">
|
||||
<span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
|
||||
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
@@ -263,7 +402,7 @@ onMounted(async () => {
|
||||
{{ pillTitle }}
|
||||
<template v-if="queueCount > 0">· 共 {{ queueCount + 1 }} 条</template>
|
||||
</span>
|
||||
<span class="pill-sub">{{ notification.teacherName || '老师' }} · 点击查看</span>
|
||||
<span class="pill-sub">{{ notification?.teacherName || '老师' }} · 点击查看</span>
|
||||
</span>
|
||||
<span class="pill-chevron" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
@@ -277,23 +416,9 @@ onMounted(async () => {
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 展开态:WinIsland expanded —— 大圆角卡片、白字分级、底部胶囊按钮 -->
|
||||
<section
|
||||
v-else-if="mode === 'expanded' && notification"
|
||||
key="expanded"
|
||||
class="island-card expanded"
|
||||
:class="{ urgent: isUrgent, call: isCall, homework: isHomework }"
|
||||
:data-style="appearance.style"
|
||||
:style="cardStyle"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<svg class="shape" :viewBox="`0 0 ${cardSize.width} ${cardSize.height}`" aria-hidden="true">
|
||||
<path :d="cardPath" />
|
||||
</svg>
|
||||
<span class="content">
|
||||
<span class="tint" aria-hidden="true"></span>
|
||||
<!-- 展开层(WinIsland expanded):进度²淡入 -->
|
||||
<span class="layer expanded-layer" :style="{ opacity: expandedAlpha }">
|
||||
<header class="head">
|
||||
<span class="head-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
|
||||
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -322,7 +447,7 @@ onMounted(async () => {
|
||||
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
|
||||
<span class="head-sub">{{ subtitle }}</span>
|
||||
</div>
|
||||
<button type="button" class="icon-btn" title="收起" @click="collapse">
|
||||
<button type="button" class="icon-btn" title="收起" @click.stop="collapse">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="none"
|
||||
@@ -335,30 +460,27 @@ onMounted(async () => {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<h3 class="title">{{ notification.title }}</h3>
|
||||
<h3 class="title">{{ notification?.title ?? '' }}</h3>
|
||||
<div class="body">
|
||||
<p class="content-text">{{ notification.content }}</p>
|
||||
<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>
|
||||
<p v-else-if="notification?.subtitle" class="call-hint">{{ notification?.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
<span v-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
|
||||
<span v-else class="more muted">来自班级小助手</span>
|
||||
<span class="actions">
|
||||
<button type="button" class="ghost-btn" @click="openApp">打开应用</button>
|
||||
<button type="button" class="ghost-btn" @click="markRead">标为已读</button>
|
||||
<button type="button" class="solid-btn" @click="dismiss">
|
||||
<button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
|
||||
<button type="button" class="ghost-btn" @click.stop="markRead">标为已读</button>
|
||||
<button type="button" class="solid-btn" @click.stop="dismiss">
|
||||
{{ isCall ? '收到' : '知道了' }}
|
||||
</button>
|
||||
</span>
|
||||
</footer>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<!-- 隐藏态占位(窗口不可见,仅保证 DOM 结构稳定) -->
|
||||
<div v-else key="hidden" class="island-card hidden-placeholder"></div>
|
||||
</Transition>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -444,9 +566,8 @@ body {
|
||||
/* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */
|
||||
.island-card .shape {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -456,16 +577,28 @@ body {
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* 内容层裁切到同一形状内 */
|
||||
/* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */
|
||||
.island-card .content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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) */
|
||||
.island-card.expanded .shape path {
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.11));
|
||||
@@ -505,7 +638,7 @@ body {
|
||||
|
||||
/* ---------------------------------------------------------------- 胶囊(compact) */
|
||||
|
||||
.island-card.pill .content {
|
||||
.pill-layer {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--island-font, 13px) * 0.62);
|
||||
@@ -583,10 +716,12 @@ body {
|
||||
|
||||
/* ---------------------------------------------------------------- 展开卡(expanded) */
|
||||
|
||||
.island-card.expanded .content {
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* WinIsland 同款弹簧(照搬 `src/utils/physics.rs` 与 `src/window/app/frame.rs` 的参数与更新式)。
|
||||
*
|
||||
* 原始实现(Rust,每帧调用一次,`dt` 以**帧**为单位):
|
||||
* ```text
|
||||
* force = (target - value) * stiffness * dt
|
||||
* velocity = (velocity + force) * damping^dt
|
||||
* value += velocity * dt
|
||||
* ```
|
||||
* 主岛尺寸用的参数:`stiffness = 0.10`、`damping = 0.68`(阻尼比 ζ≈0.977,接近临界阻尼,
|
||||
* 理论过冲 ~1e-6,肉眼不可见)。换向时还有两个限制(`redirect_velocity_towards`):
|
||||
* 反向动量只保留 35%、单帧位移不超过剩余距离的 20%。
|
||||
*
|
||||
* 我们在此基础上**额外加了零过冲钳制**(越过目标立刻吸附并清零速度):
|
||||
* 即便某帧卡顿导致 dt 变大,也绝不会出现"超过目标再弹回来"的震动。
|
||||
*/
|
||||
export interface SpringOptions {
|
||||
/** 刚度(WinIsland 主岛尺寸:0.10) */
|
||||
stiffness?: number;
|
||||
/** 速度保留率(WinIsland 主岛尺寸:0.68) */
|
||||
damping?: number;
|
||||
/** 吸附阈值(px) */
|
||||
snapThreshold?: number;
|
||||
}
|
||||
|
||||
const MOMENTUM_RETENTION = 0.35;
|
||||
const MAX_DISTANCE_RATIO = 0.2;
|
||||
const MAX_DT = 6;
|
||||
/** 一帧按 60fps 折算(与 WinIsland 的 dt 单位一致) */
|
||||
const FRAME_MS = 1000 / 60;
|
||||
|
||||
export class SpringValue {
|
||||
value: number;
|
||||
velocity = 0;
|
||||
target: number;
|
||||
|
||||
private readonly stiffness: number;
|
||||
private readonly damping: number;
|
||||
private readonly snapThreshold: number;
|
||||
|
||||
constructor(initial: number, options: SpringOptions = {}) {
|
||||
this.value = initial;
|
||||
this.target = initial;
|
||||
this.stiffness = options.stiffness ?? 0.1;
|
||||
this.damping = options.damping ?? 0.68;
|
||||
this.snapThreshold = options.snapThreshold ?? 0.05;
|
||||
}
|
||||
|
||||
setTarget(target: number): void {
|
||||
if (target === this.target) return;
|
||||
this.redirectVelocity(target);
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
/** 直接落位(关闭动画 / 首次同步时使用) */
|
||||
snap(target: number): void {
|
||||
this.target = target;
|
||||
this.value = target;
|
||||
this.velocity = 0;
|
||||
}
|
||||
|
||||
get settled(): boolean {
|
||||
return Math.abs(this.target - this.value) <= this.snapThreshold && Math.abs(this.velocity) <= 0.01;
|
||||
}
|
||||
|
||||
/**
|
||||
* 换向限速(WinIsland):反向时只保留 35% 动量,并把速度幅值限制在"剩余距离的 20%"以内,
|
||||
* 保证不会因为方向切换产生突跳。
|
||||
*/
|
||||
private redirectVelocity(target: number): void {
|
||||
const distance = target - this.value;
|
||||
if (distance === 0) {
|
||||
this.velocity = 0;
|
||||
return;
|
||||
}
|
||||
const sameDirection = Math.sign(distance) === Math.sign(this.velocity);
|
||||
if (!sameDirection) this.velocity *= MOMENTUM_RETENTION;
|
||||
const maxVelocity = Math.abs(distance) * MAX_DISTANCE_RATIO;
|
||||
if (Math.abs(this.velocity) > maxVelocity) {
|
||||
this.velocity = Math.sign(this.velocity) * maxVelocity;
|
||||
}
|
||||
}
|
||||
|
||||
/** 推进一帧;返回是否仍在运动 */
|
||||
step(deltaMs: number): boolean {
|
||||
const dt = Math.min(MAX_DT, Math.max(0.1, deltaMs / FRAME_MS));
|
||||
const distance = this.target - this.value;
|
||||
if (distance === 0 && Math.abs(this.velocity) <= 0.01) {
|
||||
this.velocity = 0;
|
||||
this.value = this.target;
|
||||
return false;
|
||||
}
|
||||
|
||||
const force = distance * this.stiffness * dt;
|
||||
this.velocity = (this.velocity + force) * Math.pow(this.damping, dt);
|
||||
|
||||
const previous = this.value;
|
||||
this.value += this.velocity * dt;
|
||||
|
||||
// 零过冲钳制:越过目标(或已足够接近)就吸附到目标并停止
|
||||
const crossed = (this.target - previous) * (this.target - this.value) <= 0;
|
||||
if (crossed || Math.abs(this.target - this.value) <= this.snapThreshold) {
|
||||
this.value = this.target;
|
||||
this.velocity = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(this.value) || !Number.isFinite(this.velocity)) {
|
||||
this.value = this.target;
|
||||
this.velocity = 0;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user