diff --git a/README.md b/README.md index 2630402..de6c22a 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,11 @@ ClassHelper是给中小学班级用的一套信息管理系统。 - [x] 通知、新作业、叫人都会上岛:先是胶囊,点开是详情卡 - [x] 多条消息展开为竖排列表,按重要程度排序,放不下时提示去应用内看 -- [x] **上课时段不打扰**:普通通知暂存到下课再弹;紧急通知与叫人立刻弹 +- [x] **默认提醒模式上课时段不打扰**:普通通知暂存到下课再弹;紧急通知与叫人立刻弹 - [x] 尺寸、圆角、字号、主题色、停靠位置(顶部 / 底部 × 左 / 中 / 右)都可以调 - [x] 纯黑 / 毛玻璃 / 主题色渐变三种底 - [x] 触摸屏(希沃白板这类一体机)也能点开 +- [x] 可选常驻岛:自定义收起与无通知展开内容,收到消息显示铃铛,点击查看通知 ### 部署与运维 diff --git a/docs/dev/20261017-implementation.md b/docs/dev/20261017-implementation.md index f884474..cf7bc10 100644 --- a/docs/dev/20261017-implementation.md +++ b/docs/dev/20261017-implementation.md @@ -64,6 +64,26 @@ 本轮视觉回归通过 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` 课程表组件。 diff --git a/docs/reference.md b/docs/reference.md index e00503f..944e837 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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 作为审计来源;新门面只写新模型。重复占用同一时间点的旧行无法同时成为一节课, diff --git a/packages/desktop-client/src/island/ComponentNode.vue b/packages/desktop-client/src/island/ComponentNode.vue index 22aed04..a76652b 100644 --- a/packages/desktop-client/src/island/ComponentNode.vue +++ b/packages/desktop-client/src/island/ComponentNode.vue @@ -17,6 +17,11 @@ const props = defineProps<{ parentHasWidth?: boolean; }>(); 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 current = computed(() => { const d = new Date(props.now), @@ -101,7 +106,7 @@ const style = computed(() => { width: n.advanced.width ? `${n.advanced.width}px` : undefined, maxWidth: n.advanced.maxWidth ? `${n.advanced.maxWidth}px` : undefined, alignSelf: !props.parentHasWidth - ? 'stretch' + ? undefined : ({ stretch: 'stretch', start: 'flex-start', center: 'center', end: 'flex-end' } as const)[ n.advanced.align ], @@ -114,8 +119,8 @@ const style = computed(() => { '--emphasized': override ? `${a.emphasizedFontSize}px` : undefined, '--large': override ? `${a.largeFontSize}px` : undefined, '--rolling-duration': `${Math.max(1, 200 / n.settings.speed)}s`, - flexDirection: n.settings.direction === 'vertical' ? 'column' : 'row', - gap: `${n.settings.gap}px`, + flexDirection: container.value ? (n.settings.direction === 'vertical' ? 'column' : 'row') : undefined, + gap: container.value ? `${n.settings.gap}px` : undefined, display: container.value ? 'flex' : undefined, }; }); @@ -127,7 +132,7 @@ const style = computed(() => { :class="[{ selected: editing && selectedId === node.id, container, 'node-editing': editing }, node.kind]" :style="style" :data-node="node.id" - @click.stop="editing && emit('select', node.id)" + @click="select" > (() => { {{ ISLAND_COMPONENT_LABELS[node.kind] }}内容组件 - + {{ content }} @@ -181,6 +186,34 @@ const style = computed(() => { flex-shrink: 0; 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 { align-items: center; } diff --git a/packages/desktop-client/src/island/CustomIsland.vue b/packages/desktop-client/src/island/CustomIsland.vue index 5add0de..e9500b8 100644 --- a/packages/desktop-client/src/island/CustomIsland.vue +++ b/packages/desktop-client/src/island/CustomIsland.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from 'vue'; import { ISLAND_COMPONENT_LABELS, + CLASSISLAND_ISLAND_METRICS as CI, ISLAND_SHADOW_PAD, islandNode, normalizeIslandLayouts, @@ -37,13 +38,68 @@ watch( { deep: true }, ); 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(''), selectedKind = ref('clock'); const panel = ref<'component' | 'row' | 'appearance'>('appearance'); const selectedRowId = ref(''); -const activeRow = computed( - () => scheme.value.rows.find((r) => r.id === selectedRowId.value) || scheme.value.rows[0]!, -); +const activeRow = computed(() => rows.value.find((r) => r.id === selectedRowId.value) || rows.value[0]!); const componentGroups = [ { name: '日常信息', kinds: ['clock', 'date', 'schedule', 'countdown', 'text', 'separator'] }, { name: '消息提醒', kinds: ['notification', 'homework', 'call'] }, @@ -75,14 +131,14 @@ function allNodes(nodes: IslandComponentNode[]): IslandComponentNode[] { return nodes.flatMap((n) => [n, ...allNodes(n.children)]); } 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( () => - scheme.value.rows.find((r) => r.main && r.allowNotifications) || - scheme.value.rows.find((r) => r.allowNotifications), + rows.value.find((r) => r.main && r.allowNotifications) || rows.value.find((r) => r.allowNotifications), ); const alertNodeId = computed(() => { + if (residentCompact.value || idleExpanded.value) return ''; const kind = props.state.active?.kind ?? 'notification'; return ( (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(() => ({ width: viewport.value.width ? `${viewport.value.width}px` : 'max-content', minHeight: !props.editing && viewport.value.height ? `${viewport.value.height}px` : undefined, @@ -103,8 +165,17 @@ const shellStyle = computed(() => ({ Math.round(scheme.value.opacity * 255) .toString(16) .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`, + ...(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`, })); function id() { @@ -117,7 +188,7 @@ function save() { savedAt.value = '已自动保存'; } function addRow(after: number) { - scheme.value.rows.splice(after + 1, 0, { + rows.value.splice(after + 1, 0, { id: id(), name: '新主界面行', main: false, @@ -125,7 +196,7 @@ function addRow(after: number) { gap: 8, nodes: [], }); - selectRow(scheme.value.rows[after + 1]!.id); + selectRow(rows.value[after + 1]!.id); save(); } function add(kind = selectedKind.value, row = activeRow.value) { @@ -136,7 +207,7 @@ function add(kind = selectedKind.value, row = activeRow.value) { save(); } 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; for (const n of list) { const found = findList(nodeId, n.children); @@ -182,7 +253,7 @@ function newScheme() { const copy = JSON.parse(JSON.stringify(scheme.value)); copy.id = id(); copy.name = name; - for (const r of copy.rows) { + for (const r of [...copy.rows, ...copy.expandedRows]) { r.id = id(); for (const n of allNodes(r.nodes)) n.id = id(); } @@ -256,16 +327,16 @@ function drop() { if (!drag) return; const moving = drag; 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 (moving.type === 'library') { add(moving.kind, row); return; } if (moving.type === 'row') { - const from = scheme.value.rows.findIndex((r) => r.id === moving.id), - to = scheme.value.rows.indexOf(row); - if (from >= 0) scheme.value.rows.splice(to, 0, scheme.value.rows.splice(from, 1)[0]!); + const from = rows.value.findIndex((r) => r.id === moving.id), + to = rows.value.indexOf(row); + if (from >= 0) rows.value.splice(to, 0, rows.value.splice(from, 1)[0]!); save(); return; } @@ -316,6 +387,10 @@ function measure() { 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); } @@ -336,6 +411,7 @@ onUnmounted(() => { { 添加到 - {{ row.name }} + {{ row.name }} @@ -419,7 +495,15 @@ onUnmounted(() => { - 岛内预览 + + + 收起时 + + + 无通知展开时 + + + 岛内预览 { - - - - - ⠿ - - - {{ row.name }} - 主要行 - - - 行设置 - - - - + + + 常驻岛 + 收起 ↑ + + + + + + ⠿ + + + {{ row.name }} + 主要行 + + + 行设置 + + + + + startDrag(e, 'node', id)" + > + + + + + + ['notification', 'homework', 'call'].includes(n.kind), )" :key="n.id" :node="n" @@ -493,39 +619,44 @@ onUnmounted(() => { + + 拖入组件,或从左侧添加 - startDrag(e, 'node', id)" - > - - - - - - 拖入组件,或从左侧添加 - - + + + + + + + + {{ pendingCount > 99 ? '99+' : pendingCount }} + r.id === activeRow.id))" + @click="addRow(rows.findIndex((r) => r.id === activeRow.id))" > + 在下方新建主界面行 @@ -584,7 +715,7 @@ onUnmounted(() => { class="wide-button" :disabled="activeRow.main" @click=" - scheme.rows.forEach((r) => (r.main = r.id === activeRow.id)); + rows.forEach((r) => (r.main = r.id === activeRow.id)); save(); " > @@ -593,14 +724,12 @@ onUnmounted(() => { 提醒优先显示在主要行内;若该行禁用提醒,则显示在下一条允许提醒的行内。 - - 在此行下方新增行 - + 在此行下方新增行 { 方案设置 整体外观 - 所有组件共用一个灵动岛 + + {{ + draft.residentEnabled + ? editingSurface === 'compact' + ? '正在编辑收起时的常驻内容' + : '正在编辑没有通知时的展开内容' + : '所有组件共用一个灵动岛' + }} + + + + 常驻岛 + + + + 开启后始终显示收起内容,收到消息时显示铃铛,点击查看通知;没有通知时展开另一套自定义内容。 + + + + ClassIsland 窗口设置 + + 基准岛高 40 px,默认缩放 1×;正文 16、时钟 18 px。背景移入淡化并穿透,右侧展开按钮保持可用。 + + + 指针移入淡化 + + + + 反转指针移入淡化 + + + + 恢复 ClassIsland 默认外观 + + 当前方案 @@ -624,8 +787,14 @@ onUnmounted(() => { - 内容缩放 - + 界面缩放 + @@ -641,20 +810,38 @@ onUnmounted(() => { 背景透明度 - + {{ Math.round(scheme.opacity * 100) }}% 圆角 - + 多行布局 行间距 - + 各行分离显示 diff --git a/packages/desktop-client/src/island/custom-island.css b/packages/desktop-client/src/island/custom-island.css index ce416f9..607253b 100644 --- a/packages/desktop-client/src/island/custom-island.css +++ b/packages/desktop-client/src/island/custom-island.css @@ -19,6 +19,129 @@ flex-direction: column; 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 { display: flex; flex-direction: column; diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index c36735b..7134d76 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url'; import { BrowserWindow, ipcMain, screen } from 'electron'; import { DEFAULT_ISLAND_APPEARANCE, + CLASSISLAND_ISLAND_METRICS, normalizeIslandLayouts, ISLAND_POSITIONS, ISLAND_SHADOW_PAD, @@ -201,14 +202,46 @@ class IslandController { private get customLayout(): boolean { 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 { return this.editingLayout; } 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.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 { + if (enabled) this.residentFaded = false; if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer); this.layoutSettleTimer = null; this.settlingLayout = false; @@ -238,12 +271,7 @@ class IslandController { if (this.customLayout) { // 退出编辑先保持隐藏,等行工具栏移除、渲染器防抖测量和主进程防抖完成。 // 避免把编辑器的占位高度带回常态岛;收缩仍全部发生在不可见期间。 - this.settlingLayout = true; - this.layoutSettleTimer = setTimeout(() => { - this.layoutSettleTimer = null; - this.settlingLayout = false; - this.syncWindow(); - }, 320); + this.settleCustomLayout(); } else this.syncWindow(); } } @@ -418,7 +446,7 @@ class IslandController { if (this.editingLayout) 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; const sinceActivated = Date.now() - this.state.updatedAt; @@ -483,6 +511,10 @@ class IslandController { // 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值 this.emitAppearance(); this.emit(); + if (this.residentEnabled) { + this.residentWasEnabled = true; + this.settleCustomLayout(); + } logger.info('灵动岛已就绪(置顶透明窗口,顶部居中)'); } @@ -636,6 +668,22 @@ class IslandController { } if (this.previewId && this.previewId !== notification.id) this.endPreview(this.previewId); + if (this.residentEnabled) { + // 常驻模式按用户选择先亮铃铛,普通/紧急/叫人均由点击打开;已经在看通知时继续显示列表。 + const showingNotifications = this.state.mode === 'expanded' && this.state.active !== null; + if (this.state.active && this.state.active.id !== notification.id) + this.state.queued = [ + ...this.state.queued.filter((item) => item.id !== this.state.active!.id), + this.state.active, + ]; + this.activate( + notification, + notification.priority === 'URGENT' ? 'urgent' : 'new', + showingNotifications ? 'expanded' : 'pill', + ); + return; + } + if (this.isImmediate(notification)) { // 插播:如果正在展示别的消息,把它放回队列 if (this.state.active && this.state.active.id !== notification.id) { @@ -693,6 +741,12 @@ class IslandController { this.state.inClass = payload.inClass; this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null; + if (this.residentEnabled) { + this.state.updatedAt = Date.now(); + this.emit(); + return; + } + if (payload.inClass) { if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏'); this.explicitShowId = null; @@ -743,6 +797,16 @@ class IslandController { ); switch (payload.action) { 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; @@ -816,6 +880,11 @@ class IslandController { break; } 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)) { @@ -876,6 +945,10 @@ class IslandController { this.clearTimers(); this.explicitShowId = null; if (!this.win || this.win.isDestroyed()) return; + if (this.residentEnabled) { + this.setState({ mode: 'hidden', reason: null }); + return; + } // 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏, // 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。 if (this.appearance.idleSliver) { @@ -975,6 +1048,9 @@ class IslandController { destroy(): void { this.clearTimers(); + if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer); + this.layoutSettleTimer = null; + this.settlingLayout = false; this.stopHitPoll(); this.cancelBoundsAnimation(); if (this.win && !this.win.isDestroyed()) this.win.destroy(); @@ -1155,6 +1231,8 @@ class IslandController { /** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */ private setState(patch: Partial, 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() }; // 离开展开态就把"展开更多"复位:下次再点开时默认回到"只显示前三条" if (this.state.mode !== 'expanded') this.state.listExpanded = false; @@ -1193,7 +1271,11 @@ class IslandController { if (this.settlingLayout) return; this.cancelFade(); 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 ( !this.win.isVisible() && !(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual') @@ -1527,11 +1609,12 @@ class IslandController { // 但**注入光标**(冒烟用 setHitTestCursor 模拟指针在岛上/岛外)照常生效, // 否则「光标轮询校正命中」「岛外穿透」这类断言全会被自己的开关弄红。 const passthrough = this.testInputPassthrough && this.cursorOverride === null; - const next = passthrough - ? false - : this.touchMode || this.editingLayout || this.customLayout - ? true - : interactive; + const next = + passthrough || this.residentFaded + ? false + : this.touchMode || this.editingLayout || this.customLayout + ? true + : interactive; if (this.interactive === next) return; this.interactive = next; logger.info( @@ -1561,6 +1644,45 @@ class IslandController { // 渲染进程每 ≥50ms 就会上报一次真实几何,正好拿来当这个判据。 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 { @@ -1707,13 +1829,14 @@ class IslandController { /** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */ private syncHitFromCursor(cursor?: { x: number; y: number } | null): void { + this.updateResidentPointer(cursor); if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) { this.setInteractive(false, '窗口不可见'); return; } // 完全隐藏(含淡出过程中):一律不再接收鼠标,避免窗口在消失的几百毫秒里吞掉桌面点击。 // 空闲细缝态(idleSliver)是"可见即可点",不走这条。 - if (this.state.mode === 'hidden' && !this.appearance.idleSliver) { + if (this.state.mode === 'hidden' && !this.appearance.idleSliver && !this.customLayout) { this.setInteractive(false, '隐藏态'); return; } @@ -2018,6 +2141,12 @@ export function registerIslandIpc(): void { }); ipcMain.handle('island:edit-layout', () => island.editLayout()); 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.on('island:content-size', (event, value) => { if (event.sender === island.getWindow()?.webContents) island.reportCustomSize(value); diff --git a/packages/desktop-client/src/main/smoke-layout.ts b/packages/desktop-client/src/main/smoke-layout.ts index 4ac8097..a6d025a 100644 --- a/packages/desktop-client/src/main/smoke-layout.ts +++ b/packages/desktop-client/src/main/smoke-layout.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { island } from './island.js'; import { getConfig, saveConfig } from './config.js'; import { defaultIslandLayouts, ISLAND_SHADOW_PAD } from '@classhelper/shared'; +import { verifyResidentSmoke } from './smoke-resident.js'; export async function verifyLayoutSmoke( check: (name: string, action: () => Promise | boolean) => Promise, ) { @@ -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`, ); }); + await verifyResidentSmoke(check); } finally { saveConfig({ islandLayout: original }); island.syncCustomLayout(); diff --git a/packages/desktop-client/src/main/smoke-resident.ts b/packages/desktop-client/src/main/smoke-resident.ts new file mode 100644 index 0000000..331cab5 --- /dev/null +++ b/packages/desktop-client/src/main/smoke-resident.ts @@ -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) => Promise, +) { + const win = island.getWindow()!; + const originalTouchMode = island.getTouchMode(); + island.setTouchMode(false); + island.setHitTestCursor({ x: 0, y: 0 }); + const pause = (ms = 600) => new Promise((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); +} diff --git a/packages/desktop-client/src/preload/island.ts b/packages/desktop-client/src/preload/island.ts index d919e5b..3f612e9 100644 --- a/packages/desktop-client/src/preload/island.ts +++ b/packages/desktop-client/src/preload/island.ts @@ -17,6 +17,8 @@ export interface IslandBridge { setEditing(value: boolean): Promise; onEditing(handler: (value: boolean) => void): 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; onSchedules(handler: (value: ContractScheduleDto[]) => void): void; @@ -63,6 +65,8 @@ const bridge: IslandBridge = { ipcRenderer.on('island:editing', (_event, value) => handler(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) => { ipcRenderer.on('island:viewport', (_event, value) => handler(value)); }, diff --git a/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue b/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue index b79db89..15468b8 100644 --- a/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue +++ b/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue @@ -1,6 +1,6 @@
提醒优先显示在主要行内;若该行禁用提醒,则显示在下一条允许提醒的行内。
所有组件共用一个灵动岛
+ {{ + draft.residentEnabled + ? editingSurface === 'compact' + ? '正在编辑收起时的常驻内容' + : '正在编辑没有通知时的展开内容' + : '所有组件共用一个灵动岛' + }} +
+ 开启后始终显示收起内容,收到消息时显示铃铛,点击查看通知;没有通知时展开另一套自定义内容。 +
+ 基准岛高 40 px,默认缩放 1×;正文 16、时钟 18 px。背景移入淡化并穿透,右侧展开按钮保持可用。 +