feat(island): 增加常驻内容并沿用 ClassIsland 尺寸与避让设置

This commit is contained in:
LoveChengke committed 2026-10-17 14:29:25 +08:00
1 parent c83b03d8e6
commit 7f7d3925b2
12 files changed
+1042 -77

No files matched your search

+2 -1
View File
@@ -74,10 +74,11 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] 通知、新作业、叫人都会上岛:先是胶囊,点开是详情卡 - [x] 通知、新作业、叫人都会上岛:先是胶囊,点开是详情卡
- [x] 多条消息展开为竖排列表,按重要程度排序,放不下时提示去应用内看 - [x] 多条消息展开为竖排列表,按重要程度排序,放不下时提示去应用内看
- [x] **上课时段不打扰**:普通通知暂存到下课再弹;紧急通知与叫人立刻弹 - [x] **默认提醒模式上课时段不打扰**:普通通知暂存到下课再弹;紧急通知与叫人立刻弹
- [x] 尺寸、圆角、字号、主题色、停靠位置(顶部 / 底部 × 左 / 中 / 右)都可以调 - [x] 尺寸、圆角、字号、主题色、停靠位置(顶部 / 底部 × 左 / 中 / 右)都可以调
- [x] 纯黑 / 毛玻璃 / 主题色渐变三种底 - [x] 纯黑 / 毛玻璃 / 主题色渐变三种底
- [x] 触摸屏(希沃白板这类一体机)也能点开 - [x] 触摸屏(希沃白板这类一体机)也能点开
- [x] 可选常驻岛:自定义收起与无通知展开内容,收到消息显示铃铛,点击查看通知
### 部署与运维 ### 部署与运维
+20
View File
@@ -64,6 +64,26 @@
本轮视觉回归通过 102/102,随后补充的岛宽修正与展开可见性检查通过后台 28/28,并通过二次启动。后台最终几何检查先等待原渲染器的形变兜底和尺寸防抖完成,避免在隐藏窗口的中间动画帧判断最终尺寸。客户端构建、类型检查、lint、改动文件格式检查及文档严格构建均通过。 本轮视觉回归通过 102/102,随后补充的岛宽修正与展开可见性检查通过后台 28/28,并通过二次启动。后台最终几何检查先等待原渲染器的形变兜底和尺寸防抖完成,避免在隐藏窗口的中间动画帧判断最终尺寸。客户端构建、类型检查、lint、改动文件格式检查及文档严格构建均通过。
## 可选常驻岛
本机 `islandLayout.residentEnabled` 默认关闭,仅在 custom 模式生效。开启后,现有方案 rows 作为收起内容,新增 expandedRows 保存没有通知时点击展开的内容;两套内容在同一编辑器中切换编辑,随方案复制、导入导出和本机配置一起保存。旧配置自动补齐展开布局,不改变原来的常驻选择。
按本次产品要求,常驻模式中普通、紧急和叫人消息都先提示右侧铃铛及去重后的数量,点击常驻内容、铃铛或按 Enter/空格才展开原通知列表;上课时允许主动查看。没有消息时展开自定义内容,收起、处理完成及通知撤回后保留常驻岛。经典模式与关闭常驻的自定义模式继续使用原提醒规则。
铃铛固定占位,避免收消息时内容横移。普通组件默认继承行的垂直居中对齐;原先无条件 align-self:stretch 会使不同字号文字从顶部排版,现只在父容器有宽度且明确配置时应用覆盖对齐。时钟采用等宽数字,分隔符保持固定线高,展开页标题与收起按钮在同一基准线上。设置页重新获得焦点时读回本机配置,避免编辑器内改常驻选项后主设置页显示旧值。
新增隔离检查覆盖旧配置兼容、空闲常驻、铃铛与文本对齐、无消息展开/收起、普通与紧急消息点击展示、同 id 去重、课堂中查看、键盘展开、处理完成后的窗口几何、空闲展开时新消息提示、两套内容独立编辑和关闭常驻恢复经典模式。截图留档于 `.cache/resident-island-shots/`。
### 再次核对 ClassIsland 尺寸与避让设置
2026-10-17 重新从 GitHub master 读取 [经典主题](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/XamlThemes/ClassicTheme/Styles.axaml)、[Fluent 主题](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/XamlThemes/FluentTheme/Styles.axaml)、[默认设置](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Models/Settings.cs)、[窗口设置页](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/SettingPages/WindowSettingsPage.axaml)、[外观设置页](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/SettingPages/AppearanceSettingsPage.axaml)及 [行样式](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Controls/MainWindowLine.axaml)。主题行高为 40、内容左右留白 12;四档字体为 14/16/18/20,时钟用强调字号 18;默认圆角 8、背景不透明度 0.5、缩放 1、行距 5。共享 CLASSISLAND_ISLAND_METRICS 作为唯一尺寸与默认值表,常驻行移除上下 12px 留白。缩放、圆角与背景透明度范围也与上游一致。
原生窗口复用现有光标轮询,在常驻背景移入时降低到 5% 不透明度并开启点击穿透,移出恢复;支持反转及关闭。保留独立的右侧交互入口:无消息时是小展开箭头,有消息时显示铃铛与数量。它不触发避让,避免和此前点击查看通知的要求冲突;编辑、展开和触笔/触摸后 1 秒内暂停避让。这是为本产品保留交互入口的明确适配,上游原始主界面默认整体穿透。未引入 RawInput/.NET 依赖,轮询方式对应上游默认 UseRawInput=false。窗口隐藏或淡化不清空消息队列。
补充后台检查实测常驻行 40px、原生 setOpacity 的 5% 比例、点击穿透状态、移出恢复、反转/关闭淡化、右侧入口与触摸宽限期。源码缓存和截图分别留在 `.cache/classisland-current/`、`.cache/resident-island-ci-shots/`。
最终构建视觉验收 118/118,随后后台重启检查 43/43、单实例锁和配置句柄释放通过;全仓类型、lint、构建与产物凭据门禁、格式及文档严格检查通过。最终截图为 `.cache/resident-island-ci-final-shots/`,验收日志为 `.cache/resident-island-ci-visual-final.log`。本次不修改业务数据库、不打包或推送发布。
## 十项自检 ## 十项自检
1. 第三周周三第二节:ClassPlan 的规则与 ClassPlanEntry 的 timeLayoutItemId → `resolveActiveClassPlan` → `loadTermContext/resolveCurrentWeek` → `projectPlan` → `/schedules/active-week` → `refreshIslandSchedule` 的整周 IndexedDB 缓存 → `recomputeClassState` 发送当天课程 → `ComponentNode.vue` 课程表组件。 1. 第三周周三第二节:ClassPlan 的规则与 ClassPlanEntry 的 timeLayoutItemId → `resolveActiveClassPlan` → `loadTermContext/resolveCurrentWeek` → `projectPlan` → `/schedules/active-week` → `refreshIslandSchedule` 的整周 IndexedDB 缓存 → `recomputeClassState` 发送当天课程 → `ComponentNode.vue` 课程表组件。
+7
View File
@@ -1583,6 +1583,13 @@ makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列
天气组件本轮不做:没有可离线使用的数据源,验收不依赖外网。 天气组件本轮不做:没有可离线使用的数据源,验收不依赖外网。
自定义组件栏位于灵动岛内部,提醒内容也在同一个岛体内展开,不再嵌套另一张带底色的岛卡片。 自定义组件栏位于灵动岛内部,提醒内容也在同一个岛体内展开,不再嵌套另一张带底色的岛卡片。
可在「设置 → 灵动岛」选择常驻岛:收起内容与无通知展开内容分别编辑,并随方案一起导入导出。
常驻模式的新消息(含紧急消息)先亮铃铛和数量,点击岛体或铃铛查看原通知列表;上课时也可主动查看。
没有待处理消息时点击岛体,展示自定义的空闲展开页;处理完消息后回到常驻内容。关闭常驻后使用原提醒规则。
铃铛在右侧预留固定空间,收到消息时内容不横移;默认组件按垂直中心对齐,时间使用等宽数字。
常驻外观重新按 ClassIsland 源码对齐:单行基准高 40 px、左右留白 12 px、四档字号 14/16/18/20、时钟使用强调字号 18 px;默认缩放 1×、圆角 8 px、背景不透明度 0.5、行间距 5 px。
窗口设置沿用「指针移入淡化」与「反转指针移入淡化」;普通背景淡化至 5% 并穿透点击,移出恢复。铃铛/展开按钮、编辑模式、已经展开的内容及触摸操作短暂宽限期保持可交互。
界面缩放范围 0.75–2.5×,圆角 0–20 px,背景不透明度 0–1;整体外观可一键恢复 ClassIsland 默认值。
编辑器左侧按用途添加组件、中央预览岛内排列、右侧只显示当前对象的设置;方案操作收在顶部菜单。 编辑器左侧按用途添加组件、中央预览岛内排列、右侧只显示当前对象的设置;方案操作收在顶部菜单。
主设置页选择经典或自定义灵动岛,自定义模式的组件尺寸、配色在编辑器中调整,主设置页保留位置与提醒选项。 主设置页选择经典或自定义灵动岛,自定义模式的组件尺寸、配色在编辑器中调整,主设置页保留位置与提醒选项。
迁移保留旧 Schedule 作为审计来源;新门面只写新模型。重复占用同一时间点的旧行无法同时成为一节课, 迁移保留旧 Schedule 作为审计来源;新门面只写新模型。重复占用同一时间点的旧行无法同时成为一节课,
@@ -17,6 +17,11 @@ const props = defineProps<{
parentHasWidth?: boolean; parentHasWidth?: boolean;
}>(); }>();
const emit = defineEmits<{ select: [id: string]; drag: [event: PointerEvent, id: string] }>(); const emit = defineEmits<{ select: [id: string]; drag: [event: PointerEvent, id: string] }>();
function select(event: MouseEvent) {
if (!props.editing) return;
event.stopPropagation();
emit('select', props.node.id);
}
const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind)); const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind));
const current = computed(() => { const current = computed(() => {
const d = new Date(props.now), const d = new Date(props.now),
@@ -101,7 +106,7 @@ const style = computed<CSSProperties>(() => {
width: n.advanced.width ? `${n.advanced.width}px` : undefined, width: n.advanced.width ? `${n.advanced.width}px` : undefined,
maxWidth: n.advanced.maxWidth ? `${n.advanced.maxWidth}px` : undefined, maxWidth: n.advanced.maxWidth ? `${n.advanced.maxWidth}px` : undefined,
alignSelf: !props.parentHasWidth alignSelf: !props.parentHasWidth
? 'stretch' ? undefined
: ({ stretch: 'stretch', start: 'flex-start', center: 'center', end: 'flex-end' } as const)[ : ({ stretch: 'stretch', start: 'flex-start', center: 'center', end: 'flex-end' } as const)[
n.advanced.align n.advanced.align
], ],
@@ -114,8 +119,8 @@ const style = computed<CSSProperties>(() => {
'--emphasized': override ? `${a.emphasizedFontSize}px` : undefined, '--emphasized': override ? `${a.emphasizedFontSize}px` : undefined,
'--large': override ? `${a.largeFontSize}px` : undefined, '--large': override ? `${a.largeFontSize}px` : undefined,
'--rolling-duration': `${Math.max(1, 200 / n.settings.speed)}s`, '--rolling-duration': `${Math.max(1, 200 / n.settings.speed)}s`,
flexDirection: n.settings.direction === 'vertical' ? 'column' : 'row', flexDirection: container.value ? (n.settings.direction === 'vertical' ? 'column' : 'row') : undefined,
gap: `${n.settings.gap}px`, gap: container.value ? `${n.settings.gap}px` : undefined,
display: container.value ? 'flex' : undefined, display: container.value ? 'flex' : undefined,
}; };
}); });
@@ -127,7 +132,7 @@ const style = computed<CSSProperties>(() => {
:class="[{ selected: editing && selectedId === node.id, container, 'node-editing': editing }, node.kind]" :class="[{ selected: editing && selectedId === node.id, container, 'node-editing': editing }, node.kind]"
:style="style" :style="style"
:data-node="node.id" :data-node="node.id"
@click.stop="editing && emit('select', node.id)" @click="select"
> >
<button <button
v-if="editing" v-if="editing"
@@ -168,7 +173,7 @@ const style = computed<CSSProperties>(() => {
<slot v-if="node.id === alertNodeId" name="alerts" /> <slot v-if="node.id === alertNodeId" name="alerts" />
<span v-else-if="editing">{{ ISLAND_COMPONENT_LABELS[node.kind] }}内容组件</span> <span v-else-if="editing">{{ ISLAND_COMPONENT_LABELS[node.kind] }}内容组件</span>
</template> </template>
<span v-else :class="{ large: node.kind === 'clock', emphasized: node.kind === 'countdown' }"> <span v-else :class="{ emphasized: node.kind === 'clock' || node.kind === 'countdown' }">
{{ content }} {{ content }}
</span> </span>
</div> </div>
@@ -181,6 +186,34 @@ const style = computed<CSSProperties>(() => {
flex-shrink: 0; flex-shrink: 0;
position: relative; position: relative;
} }
.component-node:not(.node-editing) {
line-height: 1.4;
}
.clock {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.date,
.countdown {
white-space: nowrap;
}
.schedule > div {
display: flex;
flex-direction: column;
gap: 3px;
}
.schedule > div + div {
margin-top: 8px;
}
.separator > span {
display: block;
width: 1px;
height: 24px;
overflow: hidden;
background: currentColor;
font-size: 0;
opacity: 0.25;
}
.container { .container {
align-items: center; align-items: center;
} }
@@ -2,6 +2,7 @@
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from 'vue';
import { import {
ISLAND_COMPONENT_LABELS, ISLAND_COMPONENT_LABELS,
CLASSISLAND_ISLAND_METRICS as CI,
ISLAND_SHADOW_PAD, ISLAND_SHADOW_PAD,
islandNode, islandNode,
normalizeIslandLayouts, normalizeIslandLayouts,
@@ -37,13 +38,68 @@ watch(
{ deep: true }, { deep: true },
); );
const scheme = computed(() => draft.value.schemes.find((s) => s.id === draft.value.activeSchemeId)!); const scheme = computed(() => draft.value.schemes.find((s) => s.id === draft.value.activeSchemeId)!);
const editingSurface = ref<'compact' | 'expanded'>('compact');
const pendingCount = computed(
() => new Set([props.state.active?.id, ...props.state.queued.map((item) => item.id)].filter(Boolean)).size,
);
const residentCompact = computed(
() => draft.value.residentEnabled && !props.editing && props.state.mode !== 'expanded',
);
const idleExpanded = computed(
() =>
draft.value.residentEnabled &&
!props.editing &&
props.state.mode === 'expanded' &&
pendingCount.value === 0,
);
const rows = computed(() =>
(props.editing ? editingSurface.value === 'expanded' : idleExpanded.value)
? scheme.value.expandedRows
: scheme.value.rows,
);
function setSurface(surface: 'compact' | 'expanded') {
editingSurface.value = surface;
selectedId.value = '';
selectedRowId.value = '';
panel.value = 'appearance';
}
function openResident(event: Event) {
if (!residentCompact.value || (event.target as HTMLElement).closest('button, a, input, select, textarea'))
return;
window.island?.sendAction('expand');
}
function residentKey(event: KeyboardEvent) {
if (event.target !== event.currentTarget) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openResident(event);
}
}
function expandResident() {
window.island?.sendAction('expand');
}
function collapseResident() {
window.island?.sendAction('collapse');
}
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(''), const selectedId = ref(''),
selectedKind = ref<IslandComponentKind>('clock'); selectedKind = ref<IslandComponentKind>('clock');
const panel = ref<'component' | 'row' | 'appearance'>('appearance'); const panel = ref<'component' | 'row' | 'appearance'>('appearance');
const selectedRowId = ref(''); const selectedRowId = ref('');
const activeRow = computed( const activeRow = computed(() => rows.value.find((r) => r.id === selectedRowId.value) || rows.value[0]!);
() => scheme.value.rows.find((r) => r.id === selectedRowId.value) || scheme.value.rows[0]!,
);
const componentGroups = [ const componentGroups = [
{ name: '日常信息', kinds: ['clock', 'date', 'schedule', 'countdown', 'text', 'separator'] }, { name: '日常信息', kinds: ['clock', 'date', 'schedule', 'countdown', 'text', 'separator'] },
{ name: '消息提醒', kinds: ['notification', 'homework', 'call'] }, { name: '消息提醒', kinds: ['notification', 'homework', 'call'] },
@@ -75,14 +131,14 @@ function allNodes(nodes: IslandComponentNode[]): IslandComponentNode[] {
return nodes.flatMap((n) => [n, ...allNodes(n.children)]); return nodes.flatMap((n) => [n, ...allNodes(n.children)]);
} }
const selected = computed(() => const selected = computed(() =>
scheme.value.rows.flatMap((r) => allNodes(r.nodes)).find((n) => n.id === selectedId.value), rows.value.flatMap((r) => allNodes(r.nodes)).find((n) => n.id === selectedId.value),
); );
const alertRow = computed( const alertRow = computed(
() => () =>
scheme.value.rows.find((r) => r.main && r.allowNotifications) || rows.value.find((r) => r.main && r.allowNotifications) || rows.value.find((r) => r.allowNotifications),
scheme.value.rows.find((r) => r.allowNotifications),
); );
const alertNodeId = computed(() => { const alertNodeId = computed(() => {
if (residentCompact.value || idleExpanded.value) return '';
const kind = props.state.active?.kind ?? 'notification'; const kind = props.state.active?.kind ?? 'notification';
return ( return (
(alertRow.value && (alertRow.value &&
@@ -90,7 +146,13 @@ const alertNodeId = computed(() => {
'' ''
); );
}); });
const hasMessage = computed(() => props.state.mode !== 'hidden' && Boolean(props.state.active)); const hasMessage = computed(
() =>
props.state.mode !== 'hidden' &&
Boolean(props.state.active) &&
(!draft.value.residentEnabled || props.state.mode === 'expanded') &&
!props.editing,
);
const shellStyle = computed<CSSProperties>(() => ({ const shellStyle = computed<CSSProperties>(() => ({
width: viewport.value.width ? `${viewport.value.width}px` : 'max-content', width: viewport.value.width ? `${viewport.value.width}px` : 'max-content',
minHeight: !props.editing && viewport.value.height ? `${viewport.value.height}px` : undefined, minHeight: !props.editing && viewport.value.height ? `${viewport.value.height}px` : undefined,
@@ -103,8 +165,17 @@ const shellStyle = computed<CSSProperties>(() => ({
Math.round(scheme.value.opacity * 255) Math.round(scheme.value.opacity * 255)
.toString(16) .toString(16)
.padStart(2, '0'), .padStart(2, '0'),
'--custom-radius': `${scheme.value.radius}px`, '--custom-radius': `${scheme.value.radius * (draft.value.residentEnabled ? scheme.value.scale : 1)}px`,
'--custom-row-gap': `${scheme.value.spacing}px`, '--custom-row-gap': `${scheme.value.spacing}px`,
...(draft.value.residentEnabled
? {
'--secondary': `${CI.secondaryFontSize}px`,
'--emphasized': `${CI.emphasizedFontSize}px`,
'--large': `${CI.largeFontSize}px`,
'--resident-height': `${CI.rowHeight}px`,
'--resident-padding': `${CI.horizontalPadding * scheme.value.scale}px`,
}
: {}),
gap: `${scheme.value.spacing}px`, gap: `${scheme.value.spacing}px`,
})); }));
function id() { function id() {
@@ -117,7 +188,7 @@ function save() {
savedAt.value = '已自动保存'; savedAt.value = '已自动保存';
} }
function addRow(after: number) { function addRow(after: number) {
scheme.value.rows.splice(after + 1, 0, { rows.value.splice(after + 1, 0, {
id: id(), id: id(),
name: '新主界面行', name: '新主界面行',
main: false, main: false,
@@ -125,7 +196,7 @@ function addRow(after: number) {
gap: 8, gap: 8,
nodes: [], nodes: [],
}); });
selectRow(scheme.value.rows[after + 1]!.id); selectRow(rows.value[after + 1]!.id);
save(); save();
} }
function add(kind = selectedKind.value, row = activeRow.value) { function add(kind = selectedKind.value, row = activeRow.value) {
@@ -136,7 +207,7 @@ function add(kind = selectedKind.value, row = activeRow.value) {
save(); save();
} }
function findList(nodeId: string, nodes?: IslandComponentNode[]): IslandComponentNode[] | null { function findList(nodeId: string, nodes?: IslandComponentNode[]): IslandComponentNode[] | null {
for (const list of nodes ? [nodes] : scheme.value.rows.map((r) => r.nodes)) { for (const list of nodes ? [nodes] : rows.value.map((r) => r.nodes)) {
if (list.some((n) => n.id === nodeId)) return list; if (list.some((n) => n.id === nodeId)) return list;
for (const n of list) { for (const n of list) {
const found = findList(nodeId, n.children); const found = findList(nodeId, n.children);
@@ -182,7 +253,7 @@ function newScheme() {
const copy = JSON.parse(JSON.stringify(scheme.value)); const copy = JSON.parse(JSON.stringify(scheme.value));
copy.id = id(); copy.id = id();
copy.name = name; copy.name = name;
for (const r of copy.rows) { for (const r of [...copy.rows, ...copy.expandedRows]) {
r.id = id(); r.id = id();
for (const n of allNodes(r.nodes)) n.id = id(); for (const n of allNodes(r.nodes)) n.id = id();
} }
@@ -256,16 +327,16 @@ function drop() {
if (!drag) return; if (!drag) return;
const moving = drag; const moving = drag;
drag = null; drag = null;
const row = scheme.value.rows.find((r) => r.id === moving.rowId); const row = rows.value.find((r) => r.id === moving.rowId);
if (!row) return; if (!row) return;
if (moving.type === 'library') { if (moving.type === 'library') {
add(moving.kind, row); add(moving.kind, row);
return; return;
} }
if (moving.type === 'row') { if (moving.type === 'row') {
const from = scheme.value.rows.findIndex((r) => r.id === moving.id), const from = rows.value.findIndex((r) => r.id === moving.id),
to = scheme.value.rows.indexOf(row); to = rows.value.indexOf(row);
if (from >= 0) scheme.value.rows.splice(to, 0, scheme.value.rows.splice(from, 1)[0]!); if (from >= 0) rows.value.splice(to, 0, rows.value.splice(from, 1)[0]!);
save(); save();
return; return;
} }
@@ -316,6 +387,10 @@ function measure() {
parseFloat(padding?.paddingBottom || '0'), parseFloat(padding?.paddingBottom || '0'),
), ),
}); });
const control = shell.value?.querySelector('.resident-bell')?.getBoundingClientRect();
window.island?.reportResidentControl(
control ? { x: control.x, y: control.y, width: control.width, height: control.height } : null,
);
} }
}, 120); }, 120);
} }
@@ -336,6 +411,7 @@ onUnmounted(() => {
<template> <template>
<div <div
:class="editing ? 'layout-editor' : 'custom-window'" :class="editing ? 'layout-editor' : 'custom-window'"
@pointerdown.capture="onPointerDown"
@pointermove="panMove" @pointermove="panMove"
@pointerup=" @pointerup="
drop(); drop();
@@ -409,7 +485,7 @@ onUnmounted(() => {
<label class="field"> <label class="field">
添加到 添加到
<select :value="activeRow.id" @change="selectedRowId = ($event.target as HTMLSelectElement).value"> <select :value="activeRow.id" @change="selectedRowId = ($event.target as HTMLSelectElement).value">
<option v-for="row in scheme.rows" :key="row.id" :value="row.id">{{ row.name }}</option> <option v-for="row in rows" :key="row.id" :value="row.id">{{ row.name }}</option>
</select> </select>
</label> </label>
<button class="primary-button wide-button" @click="add()"> <button class="primary-button wide-button" @click="add()">
@@ -419,7 +495,15 @@ onUnmounted(() => {
</aside> </aside>
<main :class="{ canvas: editing }" @pointerdown="panStart"> <main :class="{ canvas: editing }" @pointerdown="panStart">
<div v-if="editing" class="canvas-toolbar"> <div v-if="editing" class="canvas-toolbar">
<span>岛内预览</span> <nav v-if="draft.residentEnabled" 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> <div>
<button <button
:class="{ active: panel === 'appearance' }" :class="{ active: panel === 'appearance' }"
@@ -447,13 +531,31 @@ onUnmounted(() => {
<div <div
ref="shell" ref="shell"
class="custom-card" class="custom-card"
:class="{ separated: scheme.separated, 'has-alert': hasMessage }" :class="{ separated: scheme.separated, 'has-alert': hasMessage, 'resident-size': residentCompact }"
:role="residentCompact ? 'button' : undefined"
:tabindex="residentCompact ? 0 : undefined"
:aria-label="
residentCompact ? (pendingCount ? `查看 ${pendingCount} 条通知` : '展开常驻岛') : undefined
"
:aria-expanded="draft.residentEnabled && !editing ? state.mode === 'expanded' : undefined"
@click="openResident"
@keydown="residentKey"
:data-priority="hasMessage ? state.active?.priority : undefined" :data-priority="hasMessage ? state.active?.priority : undefined"
:style="[shellStyle, editing ? { width: 'max-content', maxWidth: 'none', maxHeight: 'none' } : {}]" :style="[shellStyle, editing ? { width: 'max-content', maxWidth: 'none', maxHeight: 'none' } : {}]"
> >
<div ref="inner" class="custom-inner" :style="{ zoom: scheme.scale }"> <div
ref="inner"
class="custom-inner"
:class="{ 'resident-compact': residentCompact }"
:style="{ zoom: scheme.scale }"
>
<header v-if="idleExpanded" class="resident-panel-heading">
<span>常驻岛</span>
<button aria-label="收起常驻岛" @click.stop="collapseResident">收起 ↑</button>
</header>
<div class="layout-rows">
<section <section
v-for="row in scheme.rows" v-for="row in rows"
:key="row.id" :key="row.id"
:data-row="row.id" :data-row="row.id"
class="layout-row" class="layout-row"
@@ -461,7 +563,11 @@ onUnmounted(() => {
:style="{ '--row-gap': `${row.gap}px` }" :style="{ '--row-gap': `${row.gap}px` }"
> >
<div v-if="editing" class="row-tools"> <div v-if="editing" class="row-tools">
<button class="row-drag" aria-label="拖动行" @pointerdown="startDrag($event, 'row', row.id)"> <button
class="row-drag"
aria-label="拖动行"
@pointerdown="startDrag($event, 'row', row.id)"
>
⠿ ⠿
</button> </button>
<button class="row-name" @click="selectRow(row.id)"> <button class="row-name" @click="selectRow(row.id)">
@@ -495,7 +601,9 @@ onUnmounted(() => {
</ComponentNode> </ComponentNode>
</div> </div>
<ComponentNode <ComponentNode
v-for="n in row.nodes.filter((n) => ['notification', 'homework', 'call'].includes(n.kind))" v-for="n in row.nodes.filter((n) =>
['notification', 'homework', 'call'].includes(n.kind),
)"
:key="n.id" :key="n.id"
:node="n" :node="n"
:state="state" :state="state"
@@ -521,11 +629,34 @@ onUnmounted(() => {
</div> </div>
</section> </section>
</div> </div>
<button
v-if="residentCompact"
class="resident-bell"
:class="{ 'bell-empty': !pendingCount }"
:aria-label="pendingCount ? `查看 ${pendingCount} 条通知` : '展开常驻岛'"
@click.stop="expandResident"
>
<ComponentIcon v-if="pendingCount" kind="notification" />
<svg
v-else
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="1.6"
aria-hidden="true"
>
<path d="m8 10 4 4 4-4" />
</svg>
<span v-if="pendingCount">{{ pendingCount > 99 ? '99+' : pendingCount }}</span>
</button>
</div>
</div> </div>
<button <button
v-if="editing" v-if="editing"
class="add-row-button" class="add-row-button"
@click="addRow(scheme.rows.findIndex((r) => r.id === activeRow.id))" @click="addRow(rows.findIndex((r) => r.id === activeRow.id))"
> >
+ 在下方新建主界面行 + 在下方新建主界面行
</button> </button>
@@ -584,7 +715,7 @@ onUnmounted(() => {
class="wide-button" class="wide-button"
:disabled="activeRow.main" :disabled="activeRow.main"
@click=" @click="
scheme.rows.forEach((r) => (r.main = r.id === activeRow.id)); rows.forEach((r) => (r.main = r.id === activeRow.id));
save(); save();
" "
> >
@@ -593,14 +724,12 @@ onUnmounted(() => {
<p class="field-hint">提醒优先显示在主要行内;若该行禁用提醒,则显示在下一条允许提醒的行内。</p> <p class="field-hint">提醒优先显示在主要行内;若该行禁用提醒,则显示在下一条允许提醒的行内。</p>
</section> </section>
<section class="setting-section"> <section class="setting-section">
<button class="wide-button" @click="addRow(scheme.rows.indexOf(activeRow))"> <button class="wide-button" @click="addRow(rows.indexOf(activeRow))">在此行下方新增行</button>
在此行下方新增行
</button>
<button <button
class="wide-button danger-button" class="wide-button danger-button"
:disabled="scheme.rows.length < 2" :disabled="rows.length < 2"
@click=" @click="
scheme.rows.splice(scheme.rows.indexOf(activeRow), 1); rows.splice(rows.indexOf(activeRow), 1);
selectedRowId = ''; selectedRowId = '';
save(); save();
" "
@@ -614,9 +743,43 @@ onUnmounted(() => {
<div class="inspector-heading"> <div class="inspector-heading">
<span class="eyebrow">方案设置</span> <span class="eyebrow">方案设置</span>
<h2>整体外观</h2> <h2>整体外观</h2>
<p>所有组件共用一个灵动岛</p> <p>
{{
draft.residentEnabled
? editingSurface === 'compact'
? '正在编辑收起时的常驻内容'
: '正在编辑没有通知时的展开内容'
: '所有组件共用一个灵动岛'
}}
</p>
</div> </div>
<div class="inspector-body" @change="save"> <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"> <section class="setting-section">
<h3>当前方案</h3> <h3>当前方案</h3>
<label class="field"> <label class="field">
@@ -624,8 +787,14 @@ onUnmounted(() => {
<input v-model="scheme.name" /> <input v-model="scheme.name" />
</label> </label>
<label class="field inline-field"> <label class="field inline-field">
内容缩放 界面缩放
<input type="number" v-model.number="scheme.scale" min="0.5" max="3" step="0.1" /> <input
type="number"
v-model.number="scheme.scale"
:min="draft.residentEnabled ? CI.scaleMin : 0.5"
:max="draft.residentEnabled ? CI.scaleMax : 3"
step="0.05"
/>
</label> </label>
</section> </section>
<section class="setting-section"> <section class="setting-section">
@@ -641,20 +810,38 @@ onUnmounted(() => {
<label class="field"> <label class="field">
背景透明度 背景透明度
<div class="range-field"> <div class="range-field">
<input type="range" v-model.number="scheme.opacity" min="0.1" max="1" step="0.05" /> <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> <output>{{ Math.round(scheme.opacity * 100) }}%</output>
</div> </div>
</label> </label>
<label class="field inline-field"> <label class="field inline-field">
圆角 圆角
<input type="number" v-model.number="scheme.radius" min="0" max="100" /> <input
type="number"
v-model.number="scheme.radius"
min="0"
:max="draft.residentEnabled ? CI.radiusMax : 100"
step="0.5"
/>
</label> </label>
</section> </section>
<section class="setting-section"> <section class="setting-section">
<h3>多行布局</h3> <h3>多行布局</h3>
<label class="field inline-field"> <label class="field inline-field">
行间距 行间距
<input type="number" v-model.number="scheme.spacing" min="0" max="80" /> <input
type="number"
v-model.number="scheme.spacing"
min="0"
:max="draft.residentEnabled ? CI.rowSpacingMax : 100"
step="5"
/>
</label> </label>
<label class="toggle-field"> <label class="toggle-field">
<span>各行分离显示</span> <span>各行分离显示</span>
@@ -19,6 +19,129 @@
flex-direction: column; flex-direction: column;
gap: var(--custom-row-gap); gap: var(--custom-row-gap);
} }
.custom-card .layout-rows {
display: flex;
flex-direction: column;
gap: var(--custom-row-gap);
min-width: 0;
}
.custom-card .custom-inner.resident-compact {
flex-direction: row;
align-items: center;
gap: 6px;
}
.custom-card.resident-size {
padding: 0 var(--resident-padding, 12px);
}
.custom-card.resident-size .row-content,
.custom-card.resident-size .row-components {
height: var(--resident-height, 40px);
min-height: var(--resident-height, 40px);
}
.custom-card.resident-size .row-components {
gap: 0;
}
.custom-card.resident-size .component-node:not(.node-editing) {
height: var(--resident-height, 40px);
max-height: var(--resident-height, 40px);
padding: 0 calc(var(--row-gap, 12px) / 2);
display: flex;
align-items: center;
justify-content: center;
}
.custom-card.resident-size .component-node.schedule {
flex-direction: column;
}
.custom-card.resident-size .schedule small,
.custom-card.resident-size .schedule strong {
line-height: 1.05;
}
.custom-card.resident-size .separator > span {
height: 20px;
}
.custom-card[role='button'] {
cursor: pointer;
}
.custom-card[role='button']:focus-visible {
outline: 2px solid #8dafff;
outline-offset: -3px;
}
.custom-card .resident-bell {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 28px;
flex: 0 0 28px;
margin: 0;
padding: 4px;
border: 0;
border-radius: 6px;
background: transparent;
color: #ffda8b;
cursor: pointer;
}
.custom-card .resident-bell:hover {
background: #ffffff1a;
}
.custom-card .resident-bell.bell-empty {
color: #ffffff60;
}
.custom-card .resident-bell svg {
width: 18px;
height: 18px;
}
.custom-card .resident-bell span {
position: absolute;
top: -3px;
right: -3px;
display: grid;
place-items: center;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
background: #f45b58;
color: white;
font:
600 9px/1 'Segoe UI',
sans-serif;
box-shadow: 0 0 0 2px var(--custom-color);
}
.custom-card .resident-panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 4px 6px 12px;
border-bottom: 1px solid #ffffff1a;
margin-bottom: 4px;
color: #ffffff94;
font-size: 12px;
}
.custom-card .resident-panel-heading button {
min-height: 30px;
padding: 4px 10px;
border: 0;
border-radius: 8px;
background: #ffffff0d;
color: inherit;
font: inherit;
cursor: pointer;
}
.layout-editor .surface-tabs {
pointer-events: auto;
display: flex;
gap: 6px;
}
.layout-editor .surface-tabs button {
font-size: 12px;
}
.layout-editor .surface-tabs button.active {
color: var(--editor-accent);
background: #edf2ff;
border-color: #b6c8f0;
}
.custom-card .layout-row { .custom-card .layout-row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+139 -10
View File
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
import { BrowserWindow, ipcMain, screen } from 'electron'; import { BrowserWindow, ipcMain, screen } from 'electron';
import { import {
DEFAULT_ISLAND_APPEARANCE, DEFAULT_ISLAND_APPEARANCE,
CLASSISLAND_ISLAND_METRICS,
normalizeIslandLayouts, normalizeIslandLayouts,
ISLAND_POSITIONS, ISLAND_POSITIONS,
ISLAND_SHADOW_PAD, ISLAND_SHADOW_PAD,
@@ -201,14 +202,46 @@ class IslandController {
private get customLayout(): boolean { private get customLayout(): boolean {
return getConfig().islandLayout.layout === 'custom'; return getConfig().islandLayout.layout === 'custom';
} }
private get residentEnabled(): boolean {
return this.customLayout && getConfig().islandLayout.residentEnabled;
}
private residentWasEnabled = false;
private residentFaded = false;
private residentTouchUntil = 0;
private residentControl: { x: number; y: number; width: number; height: number } | null = null;
getEditingLayout(): boolean { getEditingLayout(): boolean {
return this.editingLayout; return this.editingLayout;
} }
syncCustomLayout(): void { syncCustomLayout(): void {
if (this.residentEnabled !== this.residentWasEnabled) {
this.residentFaded = false;
this.residentWasEnabled = this.residentEnabled;
this.clearTimers();
this.state = {
...this.state,
mode: this.state.active ? 'pill' : 'hidden',
listExpanded: false,
updatedAt: Date.now(),
};
if (this.residentEnabled) this.settleCustomLayout();
this.emit();
}
this.win?.webContents.send('island:layout', getConfig().islandLayout); this.win?.webContents.send('island:layout', getConfig().islandLayout);
this.syncWindow(); this.syncWindow();
} }
/** 常驻岛的离散收起:先隐藏,等两端内容尺寸防抖完成后以常态尺寸重新显示。 */
private settleCustomLayout(): void {
if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer);
this.settlingLayout = true;
this.win?.hide();
this.layoutSettleTimer = setTimeout(() => {
this.layoutSettleTimer = null;
this.settlingLayout = false;
this.syncWindow();
}, 320);
}
editLayout(enabled = true): void { editLayout(enabled = true): void {
if (enabled) this.residentFaded = false;
if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer); if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer);
this.layoutSettleTimer = null; this.layoutSettleTimer = null;
this.settlingLayout = false; this.settlingLayout = false;
@@ -238,12 +271,7 @@ class IslandController {
if (this.customLayout) { if (this.customLayout) {
// 退出编辑先保持隐藏,等行工具栏移除、渲染器防抖测量和主进程防抖完成。 // 退出编辑先保持隐藏,等行工具栏移除、渲染器防抖测量和主进程防抖完成。
// 避免把编辑器的占位高度带回常态岛;收缩仍全部发生在不可见期间。 // 避免把编辑器的占位高度带回常态岛;收缩仍全部发生在不可见期间。
this.settlingLayout = true; this.settleCustomLayout();
this.layoutSettleTimer = setTimeout(() => {
this.layoutSettleTimer = null;
this.settlingLayout = false;
this.syncWindow();
}, 320);
} else this.syncWindow(); } else this.syncWindow();
} }
} }
@@ -418,7 +446,7 @@ class IslandController {
if (this.editingLayout) return; if (this.editingLayout) return;
// 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启) // 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启)
if (!this.blurCollapseEnabled) return; if (!this.blurCollapseEnabled) return;
if (this.state.mode !== 'expanded' || this.state.inClass) return; if (this.state.mode !== 'expanded' || (this.state.inClass && !this.residentEnabled)) return;
// 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点) // 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点)
if (this.previewId && this.state.active?.id === this.previewId) return; if (this.previewId && this.state.active?.id === this.previewId) return;
const sinceActivated = Date.now() - this.state.updatedAt; const sinceActivated = Date.now() - this.state.updatedAt;
@@ -483,6 +511,10 @@ class IslandController {
// 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值 // 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值
this.emitAppearance(); this.emitAppearance();
this.emit(); this.emit();
if (this.residentEnabled) {
this.residentWasEnabled = true;
this.settleCustomLayout();
}
logger.info('灵动岛已就绪(置顶透明窗口,顶部居中)'); logger.info('灵动岛已就绪(置顶透明窗口,顶部居中)');
} }
@@ -636,6 +668,22 @@ class IslandController {
} }
if (this.previewId && this.previewId !== notification.id) this.endPreview(this.previewId); 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.isImmediate(notification)) {
// 插播:如果正在展示别的消息,把它放回队列 // 插播:如果正在展示别的消息,把它放回队列
if (this.state.active && this.state.active.id !== notification.id) { if (this.state.active && this.state.active.id !== notification.id) {
@@ -693,6 +741,12 @@ class IslandController {
this.state.inClass = payload.inClass; this.state.inClass = payload.inClass;
this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null; this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null;
if (this.residentEnabled) {
this.state.updatedAt = Date.now();
this.emit();
return;
}
if (payload.inClass) { if (payload.inClass) {
if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏'); if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏');
this.explicitShowId = null; this.explicitShowId = null;
@@ -743,6 +797,16 @@ class IslandController {
); );
switch (payload.action) { switch (payload.action) {
case 'expand': { case 'expand': {
if (this.residentEnabled) {
this.clearTimers();
const pending = this.pendingNotifications();
this.state.active = pending[0] ?? null;
this.state.queued = pending.slice(1);
this.explicitShowId = this.state.active?.id ?? null;
this.setState({ mode: 'expanded', listExpanded: false });
if (this.state.active) this.scheduleCollapse(TIMEOUTS.expanded);
break;
}
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须 // 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。 // 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
const showing = this.state.active; const showing = this.state.active;
@@ -816,6 +880,11 @@ class IslandController {
break; break;
} }
case 'collapse': case 'collapse':
if (this.residentEnabled) {
this.clearTimers();
this.setState({ mode: this.pendingNotifications().length ? 'pill' : 'hidden', reason: null });
break;
}
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊) // 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
// 上课时段:普通通知彻底隐藏;**叫人 / 紧急消息**回缩为胶囊(保持可见,允许再次展开) // 上课时段:普通通知彻底隐藏;**叫人 / 紧急消息**回缩为胶囊(保持可见,允许再次展开)
if (this.state.inClass && !this.isOpenable(this.state.active)) { if (this.state.inClass && !this.isOpenable(this.state.active)) {
@@ -876,6 +945,10 @@ class IslandController {
this.clearTimers(); this.clearTimers();
this.explicitShowId = null; this.explicitShowId = null;
if (!this.win || this.win.isDestroyed()) return; if (!this.win || this.win.isDestroyed()) return;
if (this.residentEnabled) {
this.setState({ mode: 'hidden', reason: null });
return;
}
// 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏, // 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏,
// 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。 // 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。
if (this.appearance.idleSliver) { if (this.appearance.idleSliver) {
@@ -975,6 +1048,9 @@ class IslandController {
destroy(): void { destroy(): void {
this.clearTimers(); this.clearTimers();
if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer);
this.layoutSettleTimer = null;
this.settlingLayout = false;
this.stopHitPoll(); this.stopHitPoll();
this.cancelBoundsAnimation(); this.cancelBoundsAnimation();
if (this.win && !this.win.isDestroyed()) this.win.destroy(); if (this.win && !this.win.isDestroyed()) this.win.destroy();
@@ -1155,6 +1231,8 @@ class IslandController {
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */ /** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void { private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void {
if (this.residentEnabled && this.state.mode === 'expanded' && patch.mode && patch.mode !== 'expanded')
this.settleCustomLayout();
this.state = { ...this.state, ...patch, updatedAt: Date.now() }; this.state = { ...this.state, ...patch, updatedAt: Date.now() };
// 离开展开态就把"展开更多"复位:下次再点开时默认回到"只显示前三条" // 离开展开态就把"展开更多"复位:下次再点开时默认回到"只显示前三条"
if (this.state.mode !== 'expanded') this.state.listExpanded = false; if (this.state.mode !== 'expanded') this.state.listExpanded = false;
@@ -1193,7 +1271,11 @@ class IslandController {
if (this.settlingLayout) return; if (this.settlingLayout) return;
this.cancelFade(); this.cancelFade();
this.applyWindowLayout(); this.applyWindowLayout();
this.win.setOpacity(this.appearance.opacity); if (this.residentEnabled) this.applyFocusable();
this.updateResidentPointer();
this.win.setOpacity(
this.appearance.opacity * (this.residentFaded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1),
);
if ( if (
!this.win.isVisible() && !this.win.isVisible() &&
!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual') !(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual')
@@ -1527,7 +1609,8 @@ class IslandController {
// 但**注入光标**(冒烟用 setHitTestCursor 模拟指针在岛上/岛外)照常生效, // 但**注入光标**(冒烟用 setHitTestCursor 模拟指针在岛上/岛外)照常生效,
// 否则「光标轮询校正命中」「岛外穿透」这类断言全会被自己的开关弄红。 // 否则「光标轮询校正命中」「岛外穿透」这类断言全会被自己的开关弄红。
const passthrough = this.testInputPassthrough && this.cursorOverride === null; const passthrough = this.testInputPassthrough && this.cursorOverride === null;
const next = passthrough const next =
passthrough || this.residentFaded
? false ? false
: this.touchMode || this.editingLayout || this.customLayout : this.touchMode || this.editingLayout || this.customLayout
? true ? true
@@ -1561,6 +1644,45 @@ class IslandController {
// 渲染进程每 ≥50ms 就会上报一次真实几何,正好拿来当这个判据。 // 渲染进程每 ≥50ms 就会上报一次真实几何,正好拿来当这个判据。
this.syncTouchLayout(); this.syncTouchLayout();
} }
setResidentControl(rect: { x: number; y: number; width: number; height: number } | null): void {
this.residentControl =
rect &&
[rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) &&
rect.width > 0 &&
rect.height > 0
? { ...rect }
: null;
}
noteResidentTouch(): void {
this.residentTouchUntil = Date.now() + 1000;
this.updateResidentPointer();
this.setInteractive(true, '常驻岛触摸操作');
}
private updateResidentPointer(cursor?: { x: number; y: number } | null): void {
if (!this.win || this.win.isDestroyed()) return;
const settings = getConfig().islandLayout;
const point = this.cursorLocal(cursor);
const control = this.residentControl;
const overControl = Boolean(
point &&
control &&
point.x >= control.x &&
point.x <= control.x + control.width &&
point.y >= control.y &&
point.y <= control.y + control.height,
);
const faded =
this.residentEnabled &&
settings.pointerFadeEnabled &&
!this.editingLayout &&
Date.now() >= this.residentTouchUntil &&
this.state.mode !== 'expanded' &&
this.isInsideHitRect(point) !== settings.pointerFadeReversed &&
!overControl;
if (faded === this.residentFaded) return;
this.residentFaded = faded;
this.win.setOpacity(this.appearance.opacity * (faded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1));
}
/** 当前岛体矩形(供冒烟验证) */ /** 当前岛体矩形(供冒烟验证) */
getHitRect(): { x: number; y: number; width: number; height: number } | null { getHitRect(): { x: number; y: number; width: number; height: number } | null {
@@ -1707,13 +1829,14 @@ class IslandController {
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */ /** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
private syncHitFromCursor(cursor?: { x: number; y: number } | null): void { private syncHitFromCursor(cursor?: { x: number; y: number } | null): void {
this.updateResidentPointer(cursor);
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) { if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
this.setInteractive(false, '窗口不可见'); this.setInteractive(false, '窗口不可见');
return; return;
} }
// 完全隐藏(含淡出过程中):一律不再接收鼠标,避免窗口在消失的几百毫秒里吞掉桌面点击。 // 完全隐藏(含淡出过程中):一律不再接收鼠标,避免窗口在消失的几百毫秒里吞掉桌面点击。
// 空闲细缝态(idleSliver)是"可见即可点",不走这条。 // 空闲细缝态(idleSliver)是"可见即可点",不走这条。
if (this.state.mode === 'hidden' && !this.appearance.idleSliver) { if (this.state.mode === 'hidden' && !this.appearance.idleSliver && !this.customLayout) {
this.setInteractive(false, '隐藏态'); this.setInteractive(false, '隐藏态');
return; return;
} }
@@ -2018,6 +2141,12 @@ export function registerIslandIpc(): void {
}); });
ipcMain.handle('island:edit-layout', () => island.editLayout()); ipcMain.handle('island:edit-layout', () => island.editLayout());
ipcMain.handle('island:get-editing', () => island.getEditingLayout()); ipcMain.handle('island:get-editing', () => island.getEditingLayout());
ipcMain.on('island:resident-control', (event, value) => {
if (event.sender === island.getWindow()?.webContents) island.setResidentControl(value);
});
ipcMain.on('island:resident-touch', (event) => {
if (event.sender === island.getWindow()?.webContents) island.noteResidentTouch();
});
ipcMain.handle('island:set-editing', (_event, value: unknown) => island.editLayout(value === true)); ipcMain.handle('island:set-editing', (_event, value: unknown) => island.editLayout(value === true));
ipcMain.on('island:content-size', (event, value) => { ipcMain.on('island:content-size', (event, value) => {
if (event.sender === island.getWindow()?.webContents) island.reportCustomSize(value); if (event.sender === island.getWindow()?.webContents) island.reportCustomSize(value);
@@ -3,6 +3,7 @@ import path from 'node:path';
import { island } from './island.js'; import { island } from './island.js';
import { getConfig, saveConfig } from './config.js'; import { getConfig, saveConfig } from './config.js';
import { defaultIslandLayouts, ISLAND_SHADOW_PAD } from '@classhelper/shared'; import { defaultIslandLayouts, ISLAND_SHADOW_PAD } from '@classhelper/shared';
import { verifyResidentSmoke } from './smoke-resident.js';
export async function verifyLayoutSmoke( export async function verifyLayoutSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>, check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
) { ) {
@@ -183,6 +184,7 @@ export async function verifyLayoutSmoke(
`document.querySelectorAll('.custom-card .component-node.notification, .custom-card .component-node.homework, .custom-card .component-node.call').length === 0`, `document.querySelectorAll('.custom-card .component-node.notification, .custom-card .component-node.homework, .custom-card .component-node.call').length === 0`,
); );
}); });
await verifyResidentSmoke(check);
} finally { } finally {
saveConfig({ islandLayout: original }); saveConfig({ islandLayout: original });
island.syncCustomLayout(); island.syncCustomLayout();
@@ -0,0 +1,285 @@
import fs from 'node:fs';
import path from 'node:path';
import {
defaultIslandLayouts,
islandNode,
normalizeIslandLayouts,
ISLAND_SHADOW_PAD,
} from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { island } from './island.js';
/** 常驻岛始终用隔离的冒烟配置和本地消息;不读取或标记真实班级通知。 */
export async function verifyResidentSmoke(
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
) {
const win = island.getWindow()!;
const originalTouchMode = island.getTouchMode();
island.setTouchMode(false);
island.setHitTestCursor({ x: 0, y: 0 });
const pause = (ms = 600) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function shot(name: string) {
if (!process.env.ISLAND_SHOTS_DIR) return;
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
fs.writeFileSync(
path.join(process.env.ISLAND_SHOTS_DIR, `${name}.png`),
(await win.webContents.capturePage()).toPNG(),
);
}
const fixture = defaultIslandLayouts();
fixture.layout = 'custom';
fixture.residentEnabled = true;
const compactText = islandNode('text', 'resident-compact-text');
compactText.settings.text = '常驻概览';
const expandedText = islandNode('text', 'resident-expanded-text');
expandedText.settings.text = '今日安排';
const scheme = fixture.schemes[0]!;
scheme.rows[0]!.nodes = [
islandNode('clock', 'resident-clock'),
islandNode('separator', 'resident-separator'),
compactText,
];
scheme.expandedRows[0]!.nodes = [islandNode('date', 'resident-date'), expandedText];
island.handleAction({ action: 'dismiss-all' });
await pause();
await check('旧岛配置默认关闭常驻,并兼容补齐空闲展开布局', () => {
const legacy = JSON.parse(JSON.stringify(fixture));
delete legacy.residentEnabled;
delete legacy.schemes[0].expandedRows;
const value = normalizeIslandLayouts(legacy);
const oversized = JSON.parse(JSON.stringify(fixture));
Object.assign(oversized.schemes[0], { scale: 99, radius: 99, opacity: -1, spacing: 999 });
const bounds = normalizeIslandLayouts(oversized).schemes[0]!;
return (
!value.residentEnabled &&
value.schemes[0]!.expandedRows[0]!.nodes.length > 0 &&
bounds.scale === 2.5 &&
bounds.radius === 20 &&
bounds.opacity === 0 &&
bounds.spacing === 150
);
});
saveConfig({ islandLayout: fixture });
island.syncCustomLayout();
await pause(800);
let compactWidth = 0;
await check('无通知常驻岛显示自定义内容,并为铃铛预留对齐空间', async () => {
const layout = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.custom-card'), strip=card?.querySelector('.resident-compact'), bell=card?.querySelector('.resident-bell'), clock=card?.querySelector('.clock'), text=card?.querySelector('.text span'), digits=clock?.querySelector('span');
if(!card||!strip||!bell||!clock||!text||!digits)return null;
const b=bell.getBoundingClientRect(), c=clock.getBoundingClientRect(), r=card.getBoundingClientRect();
const t=text.getBoundingClientRect(),d=digits.getBoundingClientRect();
return {width:r.width, height:r.height, content:card.textContent.includes('常驻概览'), hidden:bell.classList.contains('bell-empty')&&!bell.querySelector('span'), aligned:Math.abs(b.top+b.height/2-c.top-c.height/2)<=1 && Math.abs(t.top+t.height/2-d.top-d.height/2)<=4};
})()`);
compactWidth = layout?.width ?? 0;
return Boolean(
layout?.content &&
layout.hidden &&
layout.aligned &&
Math.abs(layout.height - 40) <= 1 &&
island.getState().mode === 'hidden',
);
});
await shot('resident-compact');
await check('鼠标移入常驻背景淡化至5%并穿透,移出恢复', async () => {
const normalOpacity = win.getOpacity();
const rect = await win.webContents.executeJavaScript(
`(() => {const r=document.querySelector('.clock').getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`,
);
const box = win.getBounds();
island.setHitTestCursor({ x: box.x + rect.x, y: box.y + rect.y });
await pause(120);
const faded = Math.abs(win.getOpacity() - normalOpacity * 0.05) < 0.01 && !island.getInteractive();
island.setHitTestCursor({ x: 0, y: 0 });
await pause(120);
return faded && Math.abs(win.getOpacity() - normalOpacity) < 0.01;
});
await check('右侧展开入口不避让,触摸交互暂停指针淡化', async () => {
const rect = await win.webContents.executeJavaScript(
`(() => {const r=document.querySelector('.resident-bell').getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`,
);
const box = win.getBounds(),
normalOpacity = win.getOpacity();
island.setHitTestCursor({ x: box.x + rect.x, y: box.y + rect.y });
await pause(120);
const controlAvailable =
Math.abs(win.getOpacity() - normalOpacity) < 0.01 && (!win.isVisible() || island.getInteractive());
island.setTouchMode(true);
island.noteResidentTouch();
island.setHitTestCursor({ x: box.x + 20, y: box.y + 20 });
await pause(120);
const touchAvailable = Math.abs(win.getOpacity() - normalOpacity) < 0.01;
island.setHitTestCursor({ x: 0, y: 0 });
island.setTouchMode(false);
await pause(1100);
return controlAvailable && touchAvailable;
});
await check('ClassIsland 反转淡化和关闭淡化设置生效', async () => {
const config = getConfig().islandLayout,
normalOpacity = win.getOpacity();
config.pointerFadeReversed = true;
saveConfig({ islandLayout: config });
island.syncCustomLayout();
await pause(120);
const reversed = Math.abs(win.getOpacity() - normalOpacity * 0.05) < 0.01;
config.pointerFadeEnabled = false;
saveConfig({ islandLayout: config });
island.syncCustomLayout();
await pause(120);
const disabled = Math.abs(win.getOpacity() - normalOpacity) < 0.01;
config.pointerFadeReversed = false;
config.pointerFadeEnabled = true;
saveConfig({ islandLayout: config });
island.syncCustomLayout();
return reversed && disabled;
});
await check('点击常驻内容展开无通知布局', async () => {
await win.webContents.executeJavaScript(`document.querySelector('.custom-card .clock')?.click()`);
await pause();
return (
island.getState().mode === 'expanded' &&
!island.getState().active &&
(await win.webContents.executeJavaScript(
`(() => {const card=document.querySelector('.custom-card');return Boolean(card?.querySelector('.resident-panel-heading')&&card.textContent.includes('今日安排')&&!card.textContent.includes('常驻概览'));})()`,
))
);
});
await shot('resident-empty-expanded');
await check('无通知展开页可以收回常驻岛且尺寸恢复', async () => {
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="收起常驻岛"]')?.click()`);
await pause(800);
return (
island.getState().mode === 'hidden' &&
(await win.webContents.executeJavaScript(
`Math.abs(document.querySelector('.custom-card').getBoundingClientRect().width-${compactWidth})<=2`,
))
);
});
const message = (id: string, title: string, priority: 'NORMAL' | 'URGENT') => ({
id,
title,
content: '本地隔离消息内容',
priority,
createdAt: new Date().toISOString(),
teacherName: '本地验证',
courseName: null,
});
await check('上课收到普通消息只亮铃铛,常驻内容不横移', async () => {
island.setClassState({ inClass: true, currentPeriodEnd: null, week: 1 });
island.pushNotification(message('resident-normal', '常驻通知一', 'NORMAL'));
await pause();
return (
island.getState().mode === 'pill' &&
(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'));
})()`))
);
});
await shot('resident-bell');
await check('紧急消息也先提示铃铛,重复消息不增加数量', async () => {
island.pushNotification(message('resident-urgent', '常驻通知二', 'URGENT'));
island.pushNotification(message('resident-urgent', '常驻通知二', 'URGENT'));
await pause();
return (
island.getState().mode === 'pill' &&
(await win.webContents.executeJavaScript(
`document.querySelector('.resident-bell')?.textContent.trim()==='2'`,
))
);
});
await check('点击铃铛展开原通知列表,上课时也可主动查看', async () => {
await win.webContents.executeJavaScript(`document.querySelector('.resident-bell')?.click()`);
await pause(1800);
return (
island.getState().mode === 'expanded' &&
(await win.webContents.executeJavaScript(
`(() => {const host=document.querySelector('#custom-alert-host');return Boolean(host&&host.getBoundingClientRect().height>0&&host.querySelectorAll('.list-row').length===2&&host.textContent.includes('常驻通知一')&&host.textContent.includes('常驻通知二')&&!document.querySelector('.resident-panel-heading'));})()`,
))
);
});
await shot('resident-notifications');
await check('收起通知保留铃铛,键盘可重新展开', async () => {
await win.webContents.executeJavaScript(
`document.querySelector('#custom-alert-host .icon-btn')?.click()`,
);
await pause(800);
const collapsed = island.getState().mode === 'pill';
await win.webContents.executeJavaScript(
`document.querySelector('.custom-card')?.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`,
);
await pause();
return collapsed && island.getState().mode === 'expanded';
});
await check('处理完通知清除铃铛,恢复常驻内容与窗口留白', async () => {
await win.webContents.executeJavaScript(
`document.querySelector('#custom-alert-host .solid-btn')?.click()`,
);
await pause(800);
const box = win.getBounds();
const dimensions = await win.webContents.executeJavaScript(
`(() => {const card=document.querySelector('.custom-card'), r=card.getBoundingClientRect();return {width:r.width,height:r.height,empty:card.querySelector('.resident-bell')?.classList.contains('bell-empty'),content:card.textContent.includes('常驻概览')};})()`,
);
return (
!island.getState().active &&
island.getState().queued.length === 0 &&
dimensions.empty === true &&
dimensions.content &&
Math.abs(box.width - dimensions.width - 2 * ISLAND_SHADOW_PAD) <= 2 &&
Math.abs(box.height - dimensions.height - 2 * ISLAND_SHADOW_PAD) <= 2
);
});
await check('空闲展开时收到消息返回铃铛提示', async () => {
island.handleAction({ action: 'expand' });
await pause();
island.pushNotification(message('resident-after-open', '展开后新消息', 'NORMAL'));
await pause(800);
const collapsed = island.getState().mode === 'pill';
island.handleAction({ action: 'dismiss-all' });
await pause();
return collapsed;
});
await check('常驻和无通知展开内容可独立编辑并持久化', async () => {
island.editLayout();
await pause();
await win.webContents.executeJavaScript(
`(() => {const button=[...document.querySelectorAll('.surface-tabs button')].find(b=>b.textContent.includes('无通知'));button?.click();})()`,
);
await pause(200);
await win.webContents.executeJavaScript(
`document.querySelector('[data-node="resident-expanded-text"]')?.click()`,
);
await pause(200);
await win.webContents.executeJavaScript(
`(() => {const input=document.querySelector('.settings textarea');if(!input)throw Error('未找到展开文本设置');input.value='放学前检查';input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));})()`,
);
await pause();
const saved = getConfig().islandLayout.schemes[0]!;
const independent =
saved.rows[0]!.nodes.find((n) => n.id === 'resident-compact-text')?.settings.text === '常驻概览' &&
saved.expandedRows[0]!.nodes.find((n) => n.id === 'resident-expanded-text')?.settings.text ===
'放学前检查';
await shot('resident-editor');
island.editLayout(false);
await pause();
const roundtrip = normalizeIslandLayouts(JSON.parse(JSON.stringify(getConfig().islandLayout)));
return (
independent &&
roundtrip.residentEnabled &&
roundtrip.schemes[0]!.expandedRows[0]!.nodes.some((n) => n.settings.text === '放学前检查')
);
});
await check('关闭常驻并选择经典岛后恢复原有隐藏方式', async () => {
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
saveConfig({ islandLayout: defaultIslandLayouts() });
island.syncCustomLayout();
await pause();
return (
!win.isVisible() &&
!getConfig().islandLayout.residentEnabled &&
(await win.webContents.executeJavaScript(`!document.querySelector('.custom-card')`))
);
});
island.setHitTestCursor(null);
island.setTouchMode(originalTouchMode);
}
@@ -17,6 +17,8 @@ export interface IslandBridge {
setEditing(value: boolean): Promise<void>; setEditing(value: boolean): Promise<void>;
onEditing(handler: (value: boolean) => void): void; onEditing(handler: (value: boolean) => void): void;
reportSize(value: { width: number; height: number }): void; 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;
onSchedules(handler: (value: ContractScheduleDto[]) => void): void; onSchedules(handler: (value: ContractScheduleDto[]) => void): void;
@@ -63,6 +65,8 @@ const bridge: IslandBridge = {
ipcRenderer.on('island:editing', (_event, value) => handler(value)); ipcRenderer.on('island:editing', (_event, value) => handler(value));
}, },
reportSize: (value) => ipcRenderer.send('island:content-size', value), reportSize: (value) => ipcRenderer.send('island:content-size', value),
reportResidentControl: (value) => ipcRenderer.send('island:resident-control', value),
noteResidentTouch: () => ipcRenderer.send('island:resident-touch'),
onViewport: (handler) => { onViewport: (handler) => {
ipcRenderer.on('island:viewport', (_event, value) => handler(value)); ipcRenderer.on('island:viewport', (_event, value) => handler(value));
}, },
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { confirmAction } from '../../composables/confirm.js'; import { confirmAction } from '../../composables/confirm.js';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { import {
DEFAULT_ISLAND_APPEARANCE, DEFAULT_ISLAND_APPEARANCE,
@@ -28,18 +28,56 @@ function editLayout() {
void window.desktop?.islandEditLayout(); void window.desktop?.islandEditLayout();
} }
const layoutMode = ref<'fixed' | 'custom'>('fixed'); const layoutMode = ref<'fixed' | 'custom'>('fixed');
const residentEnabled = ref(false);
const pointerFadeEnabled = ref(true);
const pointerFadeReversed = ref(false);
async function changeLayout() { async function changeLayout() {
const config = await window.desktop?.getConfig(); const config = await window.desktop?.getConfig();
if (config) if (config)
await window.desktop?.saveConfig({ islandLayout: { ...config.islandLayout, layout: layoutMode.value } }); await window.desktop?.saveConfig({
islandLayout: {
...config.islandLayout,
layout: layoutMode.value,
residentEnabled: residentEnabled.value,
},
});
} }
async function chooseLayout(mode: 'fixed' | 'custom') { async function chooseLayout(mode: 'fixed' | 'custom') {
layoutMode.value = mode; layoutMode.value = mode;
if (mode === 'fixed') residentEnabled.value = false;
await changeLayout(); await changeLayout();
} }
onMounted(async () => { async function changeResident(value: boolean | string | number) {
layoutMode.value = (await window.desktop?.getConfig())?.islandLayout.layout ?? 'fixed'; residentEnabled.value = value === true;
if (residentEnabled.value) layoutMode.value = 'custom';
await changeLayout();
}
async function changePointerFade(
key: 'pointerFadeEnabled' | 'pointerFadeReversed',
value: boolean | string | number,
) {
const config = await window.desktop?.getConfig();
if (!config) return;
const saved = await window.desktop?.saveConfig({
islandLayout: { ...config.islandLayout, [key]: value === true },
}); });
if (saved) {
pointerFadeEnabled.value = saved.islandLayout.pointerFadeEnabled;
pointerFadeReversed.value = saved.islandLayout.pointerFadeReversed;
}
}
async function loadLayout() {
const settings = (await window.desktop?.getConfig())?.islandLayout;
layoutMode.value = settings?.layout ?? 'fixed';
residentEnabled.value = settings?.residentEnabled ?? false;
pointerFadeEnabled.value = settings?.pointerFadeEnabled ?? true;
pointerFadeReversed.value = settings?.pointerFadeReversed ?? false;
}
onMounted(async () => {
await loadLayout();
window.addEventListener('focus', loadLayout);
});
onUnmounted(() => window.removeEventListener('focus', loadLayout));
/** /**
* 左右边距是否有效:只在停靠左 / 右时生效。 * 左右边距是否有效:只在停靠左 / 右时生效。
@@ -140,13 +178,49 @@ onMounted(async () => {
<span>在岛内排列时钟、课程和消息等组件</span> <span>在岛内排列时钟、课程和消息等组件</span>
</button> </button>
</div> </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 v-if="layoutMode === 'custom'" class="layout-edit-entry">
<div> <div>
<strong>按你的习惯布置岛内内容</strong> <strong>按你的习惯布置岛内内容</strong>
<p>在预览中添加和排列组件,点选组件即可设置;更改自动保存到本机。</p> <p>
{{
residentEnabled
? '分别编辑「收起时」与「无通知展开时」的内容,点选组件即可设置。'
: '在预览中添加和排列组件,点选组件即可设置;更改自动保存到本机。'
}}
</p>
</div> </div>
<el-button type="primary" @click="editLayout">编辑岛内组件</el-button> <el-button type="primary" @click="editLayout">编辑岛内组件</el-button>
</div> </div>
<div v-if="residentEnabled" class="resident-window-options">
<div>
<strong>ClassIsland 窗口设置</strong>
<p>
常驻行基准高度 40 px。指针移入背景后淡化至 5%
并穿透,右侧铃铛或展开按钮保持可用;编辑、展开和触摸交互期间暂停避让。
</p>
</div>
<label>
<span>指针移入淡化</span>
<el-switch
:model-value="pointerFadeEnabled"
@change="(value: boolean | string | number) => changePointerFade('pointerFadeEnabled', value)"
/>
</label>
<label v-if="pointerFadeEnabled">
<span>反转指针移入淡化</span>
<el-switch
:model-value="pointerFadeReversed"
@change="(value: boolean | string | number) => changePointerFade('pointerFadeReversed', value)"
/>
</label>
</div>
</el-card> </el-card>
<el-card shadow="never"> <el-card shadow="never">
@@ -326,6 +400,47 @@ onMounted(async () => {
.layout-mode-card { .layout-mode-card {
margin-bottom: 20px; margin-bottom: 20px;
} }
.resident-setting {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color-lighter);
}
.resident-setting strong {
font-size: 14px;
}
.resident-setting p {
max-width: 620px;
margin: 8px 0 0;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.8;
}
.resident-window-options {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color-lighter);
}
.resident-window-options strong {
font-size: 14px;
}
.resident-window-options p {
margin: 8px 0 14px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.8;
}
.resident-window-options label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 40px;
font-size: 13px;
}
.layout-choices { .layout-choices {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
+79 -20
View File
@@ -13,6 +13,24 @@ export const ISLAND_COMPONENTS = [
'homework', 'homework',
'call', 'call',
] as const; ] as const;
/** ClassIsland master:MainWindowLine 与 Models/Settings.cs(2026-10-17 核对)。 */
export const CLASSISLAND_ISLAND_METRICS = {
rowHeight: 40,
horizontalPadding: 12,
radius: 8,
opacity: 0.5,
scale: 1,
scaleMin: 0.75,
scaleMax: 2.5,
radiusMax: 20,
rowSpacingMax: 150,
secondaryFontSize: 14,
bodyFontSize: 16,
emphasizedFontSize: 18,
largeFontSize: 20,
rowSpacing: 5,
fadedOpacity: 0.05,
} as const;
export type IslandComponentKind = (typeof ISLAND_COMPONENTS)[number]; export type IslandComponentKind = (typeof ISLAND_COMPONENTS)[number];
export const ISLAND_COMPONENT_LABELS: Record<IslandComponentKind, string> = { export const ISLAND_COMPONENT_LABELS: Record<IslandComponentKind, string> = {
date: '日期', date: '日期',
@@ -83,6 +101,8 @@ export interface IslandLayoutScheme {
id: string; id: string;
name: string; name: string;
rows: IslandLayoutRow[]; rows: IslandLayoutRow[];
/** 常驻岛没有待处理通知时,点击展开所显示的内容。 */
expandedRows: IslandLayoutRow[];
scale: number; scale: number;
color: string; color: string;
foreground: string; foreground: string;
@@ -93,9 +113,26 @@ export interface IslandLayoutScheme {
} }
export interface IslandLayoutSettings { export interface IslandLayoutSettings {
layout: 'fixed' | 'custom'; layout: 'fixed' | 'custom';
residentEnabled: boolean;
pointerFadeEnabled: boolean;
pointerFadeReversed: boolean;
activeSchemeId: string; activeSchemeId: string;
schemes: IslandLayoutScheme[]; schemes: IslandLayoutScheme[];
} }
function defaultExpandedRows(): IslandLayoutRow[] {
const course = islandNode('schedule', 'expanded-schedule');
course.settings.display = 'today';
return [
{
id: 'expanded-main',
name: '展开内容',
main: true,
allowNotifications: false,
gap: 12,
nodes: [islandNode('date', 'expanded-date'), course],
},
];
}
export function islandNode(kind: IslandComponentKind, id: string): IslandComponentNode { export function islandNode(kind: IslandComponentKind, id: string): IslandComponentNode {
return { return {
id, id,
@@ -117,10 +154,10 @@ export function islandNode(kind: IslandComponentKind, id: string): IslandCompone
}, },
appearance: { appearance: {
resourceOverridingEnabled: false, resourceOverridingEnabled: false,
secondaryFontSize: 12, secondaryFontSize: 14,
bodyFontSize: 16, bodyFontSize: 16,
emphasizedFontSize: 20, emphasizedFontSize: 18,
largeFontSize: 28, largeFontSize: 20,
customForegroundEnabled: false, customForegroundEnabled: false,
foreground: '#ffffff', foreground: '#ffffff',
customBackgroundEnabled: false, customBackgroundEnabled: false,
@@ -137,11 +174,15 @@ export function islandNode(kind: IslandComponentKind, id: string): IslandCompone
export function defaultIslandLayouts(): IslandLayoutSettings { export function defaultIslandLayouts(): IslandLayoutSettings {
return { return {
layout: 'fixed', layout: 'fixed',
residentEnabled: false,
pointerFadeEnabled: true,
pointerFadeReversed: false,
activeSchemeId: 'default', activeSchemeId: 'default',
schemes: [ schemes: [
{ {
id: 'default', id: 'default',
name: '默认组件方案', name: '默认组件方案',
expandedRows: defaultExpandedRows(),
rows: [ rows: [
{ {
id: 'main', id: 'main',
@@ -158,12 +199,12 @@ export function defaultIslandLayouts(): IslandLayoutSettings {
}, },
], ],
scale: 1, scale: 1,
color: '#202024', color: '#000000',
foreground: '#ffffff', foreground: '#ffffff',
opacity: 0.85, opacity: 0.5,
radius: 24, radius: 8,
separated: false, separated: false,
spacing: 8, spacing: 5,
}, },
], ],
}; };
@@ -173,6 +214,7 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
const fallback = defaultIslandLayouts(); const fallback = defaultIslandLayouts();
if (!input || typeof input !== 'object') return fallback; if (!input || typeof input !== 'object') return fallback;
const raw = input as IslandLayoutSettings; const raw = input as IslandLayoutSettings;
const resident = raw.layout === 'custom' && raw.residentEnabled === true;
const clamp = (n: unknown, min: number, max: number, value: number) => const clamp = (n: unknown, min: number, max: number, value: number) =>
typeof n === 'number' && Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : value; typeof n === 'number' && Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : value;
const color = (v: unknown, value: string) => const color = (v: unknown, value: string) =>
@@ -206,10 +248,10 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
}, },
appearance: { appearance: {
resourceOverridingEnabled: a.resourceOverridingEnabled === true, resourceOverridingEnabled: a.resourceOverridingEnabled === true,
secondaryFontSize: clamp(a.secondaryFontSize, 8, 96, 12), secondaryFontSize: clamp(a.secondaryFontSize, 8, 96, 14),
bodyFontSize: clamp(a.bodyFontSize, 8, 96, 16), bodyFontSize: clamp(a.bodyFontSize, 8, 96, 16),
emphasizedFontSize: clamp(a.emphasizedFontSize, 8, 96, 20), emphasizedFontSize: clamp(a.emphasizedFontSize, 8, 96, 18),
largeFontSize: clamp(a.largeFontSize, 8, 128, 28), largeFontSize: clamp(a.largeFontSize, 8, 128, 20),
customForegroundEnabled: a.customForegroundEnabled === true, customForegroundEnabled: a.customForegroundEnabled === true,
foreground: color(a.foreground, '#ffffff'), foreground: color(a.foreground, '#ffffff'),
customBackgroundEnabled: a.customBackgroundEnabled === true, customBackgroundEnabled: a.customBackgroundEnabled === true,
@@ -233,10 +275,8 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
: [], : [],
}; };
}; };
const schemes: IslandLayoutScheme[] = []; const normalizeRows = (input: IslandLayoutRow[]): IslandLayoutRow[] => {
for (const scheme of (Array.isArray(raw.schemes) ? raw.schemes : []).slice(0, 20)) { const rows = input
if (!scheme || !Array.isArray(scheme.rows)) continue;
const rows = scheme.rows
.filter((r) => r && typeof r === 'object') .filter((r) => r && typeof r === 'object')
.slice(0, 12) .slice(0, 12)
.map((r, i) => ({ .map((r, i) => ({
@@ -249,28 +289,47 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
.map((n) => node(n, 0)) .map((n) => node(n, 0))
.filter((n): n is IslandComponentNode => Boolean(n)), .filter((n): n is IslandComponentNode => Boolean(n)),
})); }));
if (!rows.length) continue; if (!rows.length) return [];
if (!rows.some((r) => r.main)) rows[0]!.main = true; if (!rows.some((r) => r.main)) rows[0]!.main = true;
let main = false; let main = false;
for (const r of rows) { for (const r of rows) {
if (r.main && main) r.main = false; if (r.main && main) r.main = false;
if (r.main) main = true; if (r.main) main = true;
} }
return rows;
};
const schemes: IslandLayoutScheme[] = [];
for (const scheme of (Array.isArray(raw.schemes) ? raw.schemes : []).slice(0, 20)) {
if (!scheme || !Array.isArray(scheme.rows)) continue;
const rows = normalizeRows(scheme.rows);
if (!rows.length) continue;
const expandedRows = normalizeRows(
Array.isArray(scheme.expandedRows) ? scheme.expandedRows : defaultExpandedRows(),
);
schemes.push({ schemes.push({
id: String(scheme.id || `scheme-${schemes.length}`).slice(0, 100), id: String(scheme.id || `scheme-${schemes.length}`).slice(0, 100),
name: String(scheme.name || '组件方案').slice(0, 64), name: String(scheme.name || '组件方案').slice(0, 64),
rows, rows,
scale: clamp(scheme.scale, 0.5, 3, 1), expandedRows: expandedRows.length ? expandedRows : normalizeRows(defaultExpandedRows()),
color: color(scheme.color, '#202024'), scale: clamp(
scheme.scale,
resident ? CLASSISLAND_ISLAND_METRICS.scaleMin : 0.5,
resident ? CLASSISLAND_ISLAND_METRICS.scaleMax : 3,
1,
),
color: color(scheme.color, '#000000'),
foreground: color(scheme.foreground, '#ffffff'), foreground: color(scheme.foreground, '#ffffff'),
opacity: clamp(scheme.opacity, 0.1, 1, 0.85), opacity: clamp(scheme.opacity, resident ? 0 : 0.1, 1, 0.5),
radius: clamp(scheme.radius, 0, 100, 24), radius: clamp(scheme.radius, 0, resident ? CLASSISLAND_ISLAND_METRICS.radiusMax : 100, 8),
separated: scheme.separated === true, separated: scheme.separated === true,
spacing: clamp(scheme.spacing, 0, 100, 8), spacing: clamp(scheme.spacing, 0, resident ? CLASSISLAND_ISLAND_METRICS.rowSpacingMax : 100, 5),
}); });
} }
return { return {
layout: raw.layout === 'custom' ? 'custom' : 'fixed', layout: raw.layout === 'custom' ? 'custom' : 'fixed',
residentEnabled: raw.layout === 'custom' && raw.residentEnabled === true,
pointerFadeEnabled: raw.pointerFadeEnabled !== false,
pointerFadeReversed: raw.pointerFadeReversed === true,
schemes: schemes.length ? schemes : fallback.schemes, schemes: schemes.length ? schemes : fallback.schemes,
activeSchemeId: schemes.some((s) => s.id === raw.activeSchemeId) activeSchemeId: schemes.some((s) => s.id === raw.activeSchemeId)
? raw.activeSchemeId ? raw.activeSchemeId