fix(island): 将组件与提醒合入岛体并重写布局设置

This commit is contained in:
LoveChengke committed 2026-10-17 13:45:26 +08:00
1 parent ead66f0b93
commit c83b03d8e6
10 files changed
+1451 -411

No files matched your search

+12
View File
@@ -52,6 +52,18 @@
本机方案存 config.json 的决定予以保留:这是设备个体偏好,不增加服务端配置或同步通道。通知/作业/叫人使用同一个 IslandApp.vue 实例及原状态机,custom 模式仅通过 Teleport 把它放到组件槽;不是固定层盖在自定义层上,也没有第二个岛窗口。主要行禁用提醒时回落到允许提醒的第一行;全部禁用时不展示提醒槽。多种内容仍服从原队列/列表规则,不会在多个位置重复投递。 本机方案存 config.json 的决定予以保留:这是设备个体偏好,不增加服务端配置或同步通道。通知/作业/叫人使用同一个 IslandApp.vue 实例及原状态机,custom 模式仅通过 Teleport 把它放到组件槽;不是固定层盖在自定义层上,也没有第二个岛窗口。主要行禁用提醒时回落到允许提醒的第一行;全部禁用时不展示提醒槽。多种内容仍服从原队列/列表规则,不会在多个位置重复投递。
## 自定义灵动岛界面重写
按后续反馈,将组件栏明确收进同一个岛体:普通组件和提醒共用外层背景、圆角与阴影,嵌入提醒只保留原渲染器的内容、动画与操作,去掉原卡片的第二层背景形状。每行的普通组件与提醒上下排布,岛宽取两者中较宽的一项,避免横向累加造成留白。没有消息的通知、作业、叫人组件不占位。固定模式继续使用原有完整卡片。
编辑界面重写为左侧组件库、中央岛内预览、右侧当前对象设置。组件库按日常信息、消息提醒、组合容器分组,采用统一 SVG 线性图标;组件设置分为内容、外观、高级三个标签,独立外观关闭时禁用覆盖项。行名称、间距、主要行和通知开关放在行设置中,整体配色、缩放和多行选项放在整体外观中;方案复制与导入导出收在顶部菜单。
主设置页以两个选项选择经典或自定义灵动岛,自定义模式直接进入「编辑岛内组件」,隐藏不适用的固定宽高等选项。退出全屏编辑时,先在隐藏期间等待常态组件重新测量,避免保留行工具栏的空白高度;自然宽度测量也不再被上一轮获准窗口宽度撑住。分离多行时按实际外层留白计算尺寸。
新增回归检查设置标签隔离、共用岛体且无第二层形状、无消息不占位,以及退出编辑后的自然内容尺寸;关闭提醒通过原渲染器的「知道了」按钮验证。最终截图留档在 `.cache/island-rewrite-final-shots/`。
本轮视觉回归通过 102/102,随后补充的岛宽修正与展开可见性检查通过后台 28/28,并通过二次启动。后台最终几何检查先等待原渲染器的形变兜底和尺寸防抖完成,避免在隐藏窗口的中间动画帧判断最终尺寸。客户端构建、类型检查、lint、改动文件格式检查及文档严格构建均通过。
## 十项自检 ## 十项自检
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 -4
View File
@@ -1567,12 +1567,12 @@ makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列
[组件文档](https://docs.classisland.tech/app/component/)。复刻能力与语义,不引入 .NET 或已退役插件。 [组件文档](https://docs.classisland.tech/app/component/)。复刻能力与语义,不引入 .NET 或已退役插件。
| 需求 | 实现入口 | 验收入口 | | 需求 | 实现入口 | 验收入口 |
| -------------------------------------------- | --------------------------------------------- | --------------------------------------- | | -------------------------------------------- | ---------------------------------------------- | --------------------------------------- |
| 日课表、1–9 周轮换、四种时间点、显式位置外键 | 时间表与课表编辑页、class-plans 模块 | `packages/server/scripts/verify-v14.ts` | | 日课表、1–9 周轮换、四种时间点、显式位置外键 | 时间表与课表编辑页、class-plans 模块 | `packages/server/scripts/verify-v14.ts` |
| 换课/临时层/预定/群组,原表不变 | schedules 与 class-plan-groups | 同上 | | 换课/临时层/预定/群组,原表不变 | schedules 与 class-plan-groups | 同上 |
| 本机多行、多方案、组件独立设置与触摸拖动 | 设置→灵动岛→编辑布局;托盘同名入口 | `verify:desktop` 的组件布局用例 | | 本机多行、多方案、组件独立设置与触摸拖动 | 设置→灵动岛→编辑岛内组件;托盘同名入口 | `verify:desktop` 的组件布局用例 |
| 容器、规则隐藏、四档字体与外观覆盖 | 编辑器右侧设置 | 同上 + 显式视觉回归 | | 容器、规则隐藏、四档字体与外观覆盖 | 右侧按内容/外观/高级分栏;行设置和整体外观独立 | 同上 + 显式视觉回归 |
| 通知/作业/叫人组件 | 主要且允许通知的行,复用 IslandApp 卡片与操作 | 原有固定模式回归 + 组件布局回归 | | 通知/作业/叫人组件 | 主要且允许通知的行内,共用岛体和原提醒操作 | 原有固定模式回归 + 组件布局回归 |
| 换服务端重置退避与令牌 | config-events / client-agent | 本地假服务端心跳用例 | | 换服务端重置退避与令牌 | config-events / client-agent | 本地假服务端心跳用例 |
| 即时远控与回执 | clients / client-agent | 冒烟假执行器;不调用系统命令 | | 即时远控与回执 | clients / client-agent | 冒烟假执行器;不调用系统命令 |
| 下载与安装阶段进度 | 客户端管理页/桌面关于页 | 进度 GET、ADMIN 房间事件、IPC | | 下载与安装阶段进度 | 客户端管理页/桌面关于页 | 进度 GET、ADMIN 房间事件、IPC |
@@ -1582,6 +1582,9 @@ makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列
| 四种内置岗位组与只读名单 | 权限组页/学生名单页 | 跨年级、跨部 403 | | 四种内置岗位组与只读名单 | 权限组页/学生名单页 | 跨年级、跨部 403 |
天气组件本轮不做:没有可离线使用的数据源,验收不依赖外网。 天气组件本轮不做:没有可离线使用的数据源,验收不依赖外网。
自定义组件栏位于灵动岛内部,提醒内容也在同一个岛体内展开,不再嵌套另一张带底色的岛卡片。
编辑器左侧按用途添加组件、中央预览岛内排列、右侧只显示当前对象的设置;方案操作收在顶部菜单。
主设置页选择经典或自定义灵动岛,自定义模式的组件尺寸、配色在编辑器中调整,主设置页保留位置与提醒选项。
迁移保留旧 Schedule 作为审计来源;新门面只写新模型。重复占用同一时间点的旧行无法同时成为一节课, 迁移保留旧 Schedule 作为审计来源;新门面只写新模型。重复占用同一时间点的旧行无法同时成为一节课,
迁移选择同一时段 ID 最小的行,全部原行仍保留;旧数据未记录的群组、临时层和时间点名称无法恢复。 迁移选择同一时段 ID 最小的行,全部原行仍保留;旧数据未记录的群组、临时层和时间点名称无法恢复。
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { IslandComponentKind } from '@classhelper/shared';
defineProps<{ kind: IslandComponentKind }>();
const paths: Record<IslandComponentKind, string> = {
clock: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM12 7v5l3 2',
date: 'M6 5h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM8 3v4m8-4v4M4 10h16M8 14h2m4 0h2m-8 3h2',
schedule: 'M9 5h11M9 12h11M9 19h11M4 5h1m-1 7h1m-1 7h1',
countdown: 'M6 3h12M6 21h12M8 3v4l4 5-4 5v4m8-18v4l-4 5 4 5v4',
text: 'M5 6V4h14v2M12 4v16m-4 0h8',
separator: 'M12 4v16M5 8v8m14-8v8',
notification: 'M9 19a3 3 0 0 0 6 0M6 10a6 6 0 0 1 12 0v4l2 3H4l2-3v-4Z',
homework: 'M9 4H6a2 2 0 0 0-2 2v14h16V6a2 2 0 0 0-2-2h-3M9 3h6v4H9V3Zm-1 9h8m-8 4h5',
call: 'M4 10h4l6-5v14l-6-5H4v-4Zm13-2a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14',
group: 'M3 5h18v14H3V5Zm4 4h4v6H7V9Zm8 0h2v6h-2V9Z',
stack: 'm12 3 9 5-9 5-9-5 9-5ZM3 12l9 5 9-5M3 16l9 5 9-5',
slide: 'M6 5h12v14H6V5ZM2 8v8m20-8v8m-12 7h4',
rolling: 'M3 8h17m-4-4 4 4-4 4M21 16H4m4-4-4 4 4 4',
};
</script>
<template>
<svg
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path :d="paths[kind]" />
</svg>
</template>
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
ISLAND_COMPONENT_LABELS,
type IslandComponentKind,
type IslandComponentNode,
} from '@classhelper/shared';
const props = defineProps<{ node: IslandComponentNode }>();
const node = computed(() => props.node);
const emit = defineEmits<{
change: [];
remove: [];
duplicate: [];
wrap: [kind: IslandComponentKind];
}>();
const tab = ref('content');
watch(
() => props.node.id,
() => (tab.value = 'content'),
);
const fonts = {
secondaryFontSize: '辅助文字',
bodyFontSize: '正文',
emphasizedFontSize: '强调文字',
largeFontSize: '大号文字',
} as const;
</script>
<template>
<div class="inspector-heading">
<span class="eyebrow">组件设置</span>
<h2>{{ ISLAND_COMPONENT_LABELS[node.kind] }}</h2>
<p>只影响当前选中的组件</p>
</div>
<nav class="panel-tabs" aria-label="组件设置分类">
<button
v-for="item in [
['content', '内容'],
['appearance', '外观'],
['advanced', '高级'],
]"
:key="item[0]"
:class="{ active: tab === item[0] }"
@click="tab = item[0]!"
>
{{ item[1] }}
</button>
</nav>
<div class="inspector-body" @change="emit('change')">
<template v-if="tab === 'content'">
<section class="setting-section">
<h3>显示内容</h3>
<template v-if="node.kind === 'clock'">
<label class="toggle-field">
<span>12 小时制</span>
<input type="checkbox" v-model="node.settings.hour12" />
</label>
<label class="toggle-field">
<span>显示秒</span>
<input type="checkbox" v-model="node.settings.seconds" />
</label>
</template>
<label v-else-if="node.kind === 'countdown'" class="field">
倒计时目标
<select v-model="node.settings.target">
<option value="next">下一节课</option>
<option value="school">放学</option>
</select>
</label>
<template v-else-if="node.kind === 'text'">
<label class="field">
文字内容
<textarea rows="4" v-model="node.settings.text" />
</label>
<label class="toggle-field">
<span>使用模板</span>
<input type="checkbox" v-model="node.settings.template" />
</label>
<p class="field-hint">可用变量:{date} 日期、{time} 时间、{course} 当前课程。</p>
</template>
<template v-else-if="node.kind === 'schedule'">
<label class="field">
显示方式
<select v-model="node.settings.display">
<option value="current">当前课程</option>
<option value="next">下一节课</option>
<option value="today">今日课表</option>
</select>
</label>
<label v-if="node.settings.display === 'today'" class="field inline-field">
显示节数
<input type="number" min="1" max="30" v-model.number="node.settings.count" />
</label>
<label class="toggle-field">
<span>显示节次</span>
<input type="checkbox" v-model="node.settings.showPeriod" />
</label>
</template>
<template v-else-if="['group', 'stack', 'slide', 'rolling'].includes(node.kind)">
<label class="field">
排列方向
<select v-model="node.settings.direction">
<option value="horizontal">横向</option>
<option value="vertical">纵向</option>
</select>
</label>
<label class="field inline-field">
组件间距
<input type="number" min="0" max="80" v-model.number="node.settings.gap" />
</label>
<label v-if="node.kind === 'slide'" class="field inline-field">
切换间隔(秒)
<input type="number" min="1" max="300" v-model.number="node.settings.interval" />
</label>
<label v-if="node.kind === 'rolling'" class="field inline-field">
滚动速度
<input type="number" min="1" max="200" v-model.number="node.settings.speed" />
</label>
<p class="field-hint">将组件拖到此容器内,即可一起排列。</p>
</template>
<p v-else class="field-hint">
{{
['notification', 'homework', 'call'].includes(node.kind)
? '收到消息后在岛内显示,沿用原有的展开、已读和关闭操作。没有消息时不占空间。'
: '此组件无需额外内容设置,可在「外观」中调整显示。'
}}
</p>
</section>
<section class="setting-section">
<h3>组件操作</h3>
<button class="wide-button" @click="emit('duplicate')">创建副本</button>
<label class="field">
包裹到新容器
<select
data-action="wrap"
:value="''"
@change="emit('wrap', ($event.target as HTMLSelectElement).value as IslandComponentKind)"
>
<option value="" disabled>选择容器类型</option>
<option v-for="kind in ['slide', 'rolling', 'group', 'stack'] as const" :key="kind" :value="kind">
{{ ISLAND_COMPONENT_LABELS[kind] }}
</option>
</select>
</label>
<button class="wide-button danger-button" @click="emit('remove')">删除组件</button>
</section>
</template>
<template v-else-if="tab === 'appearance'">
<section class="setting-section">
<label class="toggle-field">
<span>独立外观</span>
<input type="checkbox" v-model="node.appearance.resourceOverridingEnabled" />
</label>
<p class="field-hint">关闭时使用灵动岛的整体外观。</p>
</section>
<fieldset :disabled="!node.appearance.resourceOverridingEnabled">
<section class="setting-section">
<h3>字体大小</h3>
<label v-for="(label, key) in fonts" :key="key" class="field inline-field">
{{ label }}
<input type="number" min="8" max="96" v-model.number="node.appearance[key]" />
</label>
</section>
<section class="setting-section">
<h3>颜色与形状</h3>
<label class="toggle-field">
<span>自定义文字颜色</span>
<input type="checkbox" v-model="node.appearance.customForegroundEnabled" />
</label>
<label v-if="node.appearance.customForegroundEnabled" class="field inline-field">
文字颜色
<input type="color" v-model="node.appearance.foreground" />
</label>
<label class="toggle-field">
<span>自定义背景颜色</span>
<input type="checkbox" v-model="node.appearance.customBackgroundEnabled" />
</label>
<label v-if="node.appearance.customBackgroundEnabled" class="field inline-field">
背景颜色
<input type="color" v-model="node.appearance.background" />
</label>
<label class="toggle-field">
<span>自定义背景透明度</span>
<input type="checkbox" v-model="node.appearance.customBackgroundOpacityEnabled" />
</label>
<label v-if="node.appearance.customBackgroundOpacityEnabled" class="field inline-field">
背景透明度
<input
type="number"
min="0"
max="1"
step="0.05"
v-model.number="node.appearance.backgroundOpacity"
/>
</label>
<label class="toggle-field">
<span>自定义圆角</span>
<input type="checkbox" v-model="node.appearance.customCornerRadiusEnabled" />
</label>
<label v-if="node.appearance.customCornerRadiusEnabled" class="field inline-field">
圆角
<input type="number" min="0" max="100" v-model.number="node.appearance.cornerRadius" />
</label>
</section>
</fieldset>
<section class="setting-section">
<label class="field inline-field">
组件透明度
<input type="number" min="0" max="1" step="0.05" v-model.number="node.appearance.opacity" />
</label>
</section>
</template>
<template v-else>
<section class="setting-section">
<h3>尺寸与对齐</h3>
<label class="field">
对齐方式
<select v-model="node.advanced.align">
<option value="stretch">拉伸</option>
<option value="start">起始</option>
<option value="center">居中</option>
<option value="end">末端</option>
</select>
</label>
<p class="field-hint">父容器设有宽度时生效。</p>
<label class="field inline-field">
固定宽度
<input type="number" min="0" max="2000" v-model.number="node.advanced.width" />
</label>
<label class="field inline-field">
最大宽度
<input type="number" min="0" max="2000" v-model.number="node.advanced.maxWidth" />
</label>
<p class="field-hint">单位为像素,0 表示自适应;超过最大宽度的内容从右侧截断。</p>
</section>
<section class="setting-section">
<h3>隐藏条件</h3>
<p class="field-hint">任一条件满足时隐藏,且不占用空间。</p>
<label
v-for="(label, rule) in {
'in-class': '上课时',
'no-message': '没有消息时',
'no-class': '没有当前课程时',
}"
:key="rule"
class="toggle-field"
>
<span>{{ label }}</span>
<input type="checkbox" v-model="node.advanced.hideRules" :value="rule" />
</label>
</section>
</template>
</div>
</template>
@@ -31,12 +31,14 @@ const next = computed(() => {
const hidden = computed( const hidden = computed(
() => () =>
!props.editing && !props.editing &&
((['notification', 'homework', 'call'].includes(props.node.kind) &&
(!props.state.active || props.state.mode === 'hidden' || props.node.id !== props.alertNodeId)) ||
props.node.advanced.hideRules.some( props.node.advanced.hideRules.some(
(r) => (r) =>
(r === 'in-class' && props.state.inClass) || (r === 'in-class' && props.state.inClass) ||
(r === 'no-message' && !props.state.active) || (r === 'no-message' && !props.state.active) ||
(r === 'no-class' && !current.value), (r === 'no-class' && !current.value),
), )),
); );
const visibleChildren = computed(() => const visibleChildren = computed(() =>
props.node.kind === 'slide' props.node.kind === 'slide'
@@ -122,7 +124,7 @@ const style = computed<CSSProperties>(() => {
<div <div
v-if="!hidden" v-if="!hidden"
class="component-node" class="component-node"
:class="[{ selected: editing && selectedId === node.id, container }, 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.stop="editing && emit('select', node.id)"
@@ -192,14 +194,46 @@ const style = computed<CSSProperties>(() => {
animation: rolling var(--rolling-duration) linear infinite; animation: rolling var(--rolling-duration) linear infinite;
} }
.selected { .selected {
outline: 2px solid var(--ch-accent, #5b9cff); outline: 2px solid #85aaff;
border-radius: 8px;
background: #759cff14;
} }
.node-handle { .node-handle {
touch-action: none; touch-action: none;
cursor: grab; cursor: grab;
display: block; display: block;
font: inherit; font:
10px 'Microsoft YaHei',
sans-serif;
padding: 3px 6px;
border: 0;
border-radius: 4px;
min-height: 24px;
margin-bottom: 4px;
color: #ffffff80;
background: #ffffff0d;
}
.node-handle:hover {
background: #ffffff20;
color: #fff;
}
.notification,
.homework,
.call {
padding: 0;
}
.notification.node-editing,
.homework.node-editing,
.call.node-editing {
padding: 6px; padding: 6px;
flex-basis: auto;
}
.node-editing {
cursor: pointer;
}
.node-editing:hover {
box-shadow: inset 0 0 0 1px #ffffff28;
border-radius: 8px;
} }
small { small {
font-size: var(--secondary, 12px); font-size: var(--secondary, 12px);
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
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_COMPONENTS,
ISLAND_COMPONENT_LABELS, ISLAND_COMPONENT_LABELS,
ISLAND_SHADOW_PAD, ISLAND_SHADOW_PAD,
islandNode, islandNode,
@@ -13,6 +12,8 @@ import {
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import ComponentNode from './ComponentNode.vue'; import ComponentNode from './ComponentNode.vue';
import ComponentInspector from './ComponentInspector.vue';
import ComponentIcon from './ComponentIcon.vue';
const props = defineProps<{ const props = defineProps<{
settings: IslandLayoutSettings; settings: IslandLayoutSettings;
state: IslandState; state: IslandState;
@@ -38,6 +39,32 @@ watch(
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 selectedId = ref(''), const selectedId = ref(''),
selectedKind = ref<IslandComponentKind>('clock'); selectedKind = ref<IslandComponentKind>('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 componentGroups = [
{ name: '日常信息', kinds: ['clock', 'date', 'schedule', 'countdown', 'text', 'separator'] },
{ name: '消息提醒', kinds: ['notification', 'homework', 'call'] },
{ name: '组合容器', kinds: ['group', 'stack', 'slide', 'rolling'] },
] as const;
const savedAt = ref('');
function selectNode(nodeId: string) {
selectedId.value = nodeId;
panel.value = 'component';
}
function selectRow(rowId: string) {
selectedRowId.value = rowId;
selectedId.value = '';
panel.value = 'row';
}
function switchScheme() {
selectedId.value = '';
selectedRowId.value = '';
panel.value = 'appearance';
save();
}
const now = ref(Date.now()), const now = ref(Date.now()),
canvasScale = ref(1), canvasScale = ref(1),
pan = ref({ x: 0, y: 0 }); pan = ref({ x: 0, y: 0 });
@@ -66,7 +93,7 @@ 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));
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: viewport.value.height ? `${viewport.value.height}px` : undefined, minHeight: !props.editing && viewport.value.height ? `${viewport.value.height}px` : undefined,
maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`, maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`,
maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`, maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`,
color: scheme.value.foreground, color: scheme.value.foreground,
@@ -77,6 +104,7 @@ const shellStyle = computed<CSSProperties>(() => ({
.toString(16) .toString(16)
.padStart(2, '0'), .padStart(2, '0'),
'--custom-radius': `${scheme.value.radius}px`, '--custom-radius': `${scheme.value.radius}px`,
'--custom-row-gap': `${scheme.value.spacing}px`,
gap: `${scheme.value.spacing}px`, gap: `${scheme.value.spacing}px`,
})); }));
function id() { function id() {
@@ -86,6 +114,7 @@ function save() {
const value = normalizeIslandLayouts(JSON.parse(JSON.stringify(draft.value))); const value = normalizeIslandLayouts(JSON.parse(JSON.stringify(draft.value)));
draft.value = value; draft.value = value;
emit('save', value); emit('save', value);
savedAt.value = '已自动保存';
} }
function addRow(after: number) { function addRow(after: number) {
scheme.value.rows.splice(after + 1, 0, { scheme.value.rows.splice(after + 1, 0, {
@@ -96,13 +125,14 @@ function addRow(after: number) {
gap: 8, gap: 8,
nodes: [], nodes: [],
}); });
selectRow(scheme.value.rows[after + 1]!.id);
save(); save();
} }
function add(kind = selectedKind.value, row = scheme.value.rows[0]) { function add(kind = selectedKind.value, row = activeRow.value) {
if (!row) return; if (!row) return;
const n = islandNode(kind, id()); const n = islandNode(kind, id());
row.nodes.push(n); row.nodes.push(n);
selectedId.value = n.id; selectNode(n.id);
save(); save();
} }
function findList(nodeId: string, nodes?: IslandComponentNode[]): IslandComponentNode[] | null { function findList(nodeId: string, nodes?: IslandComponentNode[]): IslandComponentNode[] | null {
@@ -123,6 +153,7 @@ function remove() {
1, 1,
); );
selectedId.value = ''; selectedId.value = '';
panel.value = 'row';
save(); save();
} }
function duplicate() { function duplicate() {
@@ -132,7 +163,7 @@ function duplicate() {
const copy = JSON.parse(JSON.stringify(n)) as IslandComponentNode; const copy = JSON.parse(JSON.stringify(n)) as IslandComponentNode;
for (const child of allNodes([copy])) child.id = id(); for (const child of allNodes([copy])) child.id = id();
list.splice(list.indexOf(n) + 1, 0, copy); list.splice(list.indexOf(n) + 1, 0, copy);
selectedId.value = copy.id; selectNode(copy.id);
save(); save();
} }
function wrap(kind: IslandComponentKind) { function wrap(kind: IslandComponentKind) {
@@ -143,7 +174,7 @@ function wrap(kind: IslandComponentKind) {
const container = islandNode(kind, id()); const container = islandNode(kind, id());
container.children = [n]; container.children = [n];
list.splice(list.indexOf(n), 1, container); list.splice(list.indexOf(n), 1, container);
selectedId.value = container.id; selectNode(container.id);
save(); save();
} }
function newScheme() { function newScheme() {
@@ -157,7 +188,7 @@ function newScheme() {
} }
draft.value.schemes.push(copy); draft.value.schemes.push(copy);
draft.value.activeSchemeId = copy.id; draft.value.activeSchemeId = copy.id;
save(); switchScheme();
} }
function exportScheme() { function exportScheme() {
const url = URL.createObjectURL( const url = URL.createObjectURL(
@@ -187,7 +218,8 @@ async function importScheme(event: Event) {
s.id = id(); s.id = id();
draft.value.schemes.push(s); draft.value.schemes.push(s);
draft.value.activeSchemeId = s.id; draft.value.activeSchemeId = s.id;
save(); switchScheme();
error.value = '';
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
} }
@@ -247,7 +279,7 @@ function drop() {
const targetList = findList(target.id)!; const targetList = findList(target.id)!;
targetList.splice(targetList.indexOf(target), 0, node); targetList.splice(targetList.indexOf(target), 0, node);
} else row.nodes.push(node); } else row.nodes.push(node);
selectedId.value = node.id; selectNode(node.id);
save(); save();
} }
let panning: { x: number; y: number; originX: number; originY: number } | null = null; let panning: { x: number; y: number; originX: number; originY: number } | null = null;
@@ -268,11 +300,21 @@ function measure() {
clearTimeout(measureTimer); clearTimeout(measureTimer);
measureTimer = setTimeout(() => { measureTimer = setTimeout(() => {
if (inner.value) { if (inner.value) {
// 内部组件的自然尺寸决定整个岛体;提醒不再拥有另一层岛壳。
const rect = inner.value.getBoundingClientRect(), const rect = inner.value.getBoundingClientRect(),
padding = shell.value ? getComputedStyle(shell.value) : null,
scale = props.editing ? canvasScale.value : 1; scale = props.editing ? canvasScale.value : 1;
window.island?.reportSize({ window.island?.reportSize({
width: Math.ceil(rect.width / scale + 24), width: Math.ceil(
height: Math.ceil(rect.height / scale + 24), rect.width / scale +
parseFloat(padding?.paddingLeft || '0') +
parseFloat(padding?.paddingRight || '0'),
),
height: Math.ceil(
rect.height / scale +
parseFloat(padding?.paddingTop || '0') +
parseFloat(padding?.paddingBottom || '0'),
),
}); });
} }
}, 120); }, 120);
@@ -304,36 +346,34 @@ onUnmounted(() => {
panning = null; panning = null;
" "
> >
<header v-if="editing"> <header v-if="editing" class="editor-header">
<strong>编辑灵动岛</strong> <div class="editor-title">
<select <span class="editor-mark" aria-hidden="true">◒</span>
v-model="draft.activeSchemeId" <div>
@change=" <h1>自定义灵动岛</h1>
selectedId = ''; <p>把需要的信息,放进岛里</p>
save(); </div>
" </div>
> <div class="scheme-picker">
<span>当前方案</span>
<select aria-label="当前方案" v-model="draft.activeSchemeId" @change="switchScheme">
<option v-for="s in draft.schemes" :key="s.id" :value="s.id">{{ s.name }}</option> <option v-for="s in draft.schemes" :key="s.id" :value="s.id">{{ s.name }}</option>
</select> </select>
<button @click="newScheme">新方案</button> <details class="scheme-menu">
<button @click="exportScheme">导出</button> <summary aria-label="方案操作">•••</summary>
<label> <div class="scheme-menu-body">
导入 <button @click="newScheme">复制为新方案</button>
<button @click="exportScheme">导出方案</button>
<label class="import-button">
导入方案
<input type="file" accept="application/json" @change="importScheme" /> <input type="file" accept="application/json" @change="importScheme" />
</label> </label>
</div>
</details>
</div>
<span class="save-state" role="status">{{ savedAt || '更改会自动保存' }}</span>
<button <button
@click=" class="primary-button done-button"
pan = { x: 0, y: 0 };
canvasScale = 1;
"
>
重置视图
</button>
<label>
画布缩放
<input type="range" v-model.number="canvasScale" min="0.3" max="2" step="0.1" />
</label>
<button
@click=" @click="
save(); save();
emit('done'); emit('done');
@@ -343,113 +383,101 @@ onUnmounted(() => {
</button> </button>
</header> </header>
<aside v-if="editing" class="library"> <aside v-if="editing" class="library">
<h3>组件库</h3> <div class="panel-heading">
<h2>添加组件</h2>
<p>拖入岛内,或选中后添加</p>
</div>
<section v-for="group in componentGroups" :key="group.name" class="library-group">
<h3>{{ group.name }}</h3>
<div class="component-grid">
<button <button
v-for="kind in ISLAND_COMPONENTS" v-for="kind in group.kinds"
:key="kind" :key="kind"
class="library-item"
:class="{ active: selectedKind === kind }"
:aria-pressed="selectedKind === kind"
@pointerdown="startDrag($event, 'library', '', kind)" @pointerdown="startDrag($event, 'library', '', kind)"
@click="selectedKind = kind" @click="selectedKind = kind"
@dblclick="add(kind)"
> >
{{ ISLAND_COMPONENT_LABELS[kind as IslandComponentKind] }} <span class="component-symbol"><ComponentIcon :kind="kind" /></span>
<span>{{ ISLAND_COMPONENT_LABELS[kind] }}</span>
</button> </button>
<select v-model="selectedKind"> </div>
<option v-for="kind in ISLAND_COMPONENTS" :key="kind" :value="kind"> </section>
{{ ISLAND_COMPONENT_LABELS[kind as IslandComponentKind] }} <div class="library-add">
</option> <label class="field">
添加到
<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>
</select> </select>
<button @click="add()">点选并添加</button>
<p>拖到组件栏;拖到容器可嵌套。支持触摸。</p>
<h3>外观</h3>
<label>
方案名称
<input v-model="scheme.name" @change="save" />
</label>
<label>
缩放
<input type="number" v-model.number="scheme.scale" min="0.5" max="3" step="0.1" @change="save" />
</label>
<label>
背景
<input type="color" v-model="scheme.color" @change="save" />
</label>
<label>
前景
<input type="color" v-model="scheme.foreground" @change="save" />
</label>
<label>
透明度
<input type="number" v-model.number="scheme.opacity" min="0.1" max="1" step="0.05" @change="save" />
</label>
<label>
圆角
<input type="number" v-model.number="scheme.radius" min="0" max="100" @change="save" />
</label>
<label>
<input type="checkbox" v-model="scheme.separated" @change="save" />
分离成多块
</label>
<label>
行间距
<input type="number" v-model.number="scheme.spacing" @change="save" />
</label> </label>
<button class="primary-button wide-button" @click="add()">
添加{{ ISLAND_COMPONENT_LABELS[selectedKind] }}
</button>
</div>
</aside> </aside>
<main :class="{ canvas: editing }" @pointerdown="panStart"> <main :class="{ canvas: editing }" @pointerdown="panStart">
<div v-if="editing" class="canvas-toolbar">
<span>岛内预览</span>
<div>
<button
:class="{ active: panel === 'appearance' }"
@click="
panel = 'appearance';
selectedId = '';
"
>
整体外观
</button>
<button
@click="
pan = { x: 0, y: 0 };
canvasScale = 1;
"
>
重置视图
</button>
</div>
</div>
<div
class="preview-stage"
:style="editing ? { transform: `translate(${pan.x}px,${pan.y}px) scale(${canvasScale})` } : {}"
>
<div <div
ref="shell" ref="shell"
class="custom-card" class="custom-card"
:class="{ separated: scheme.separated }" :class="{ separated: scheme.separated, 'has-alert': hasMessage }"
:style="[ :data-priority="hasMessage ? state.active?.priority : undefined"
shellStyle, :style="[shellStyle, editing ? { width: 'max-content', maxWidth: 'none', maxHeight: 'none' } : {}]"
editing
? {
transform: `translate(${pan.x}px,${pan.y}px) scale(${canvasScale})`,
width: 'max-content',
maxWidth: 'none',
maxHeight: 'none',
}
: {},
]"
> >
<div ref="inner" class="custom-inner" :style="{ zoom: scheme.scale }"> <div ref="inner" class="custom-inner" :style="{ zoom: scheme.scale }">
<section <section
v-for="(row, i) in scheme.rows" v-for="row in scheme.rows"
:key="row.id" :key="row.id"
:data-row="row.id" :data-row="row.id"
class="layout-row" class="layout-row"
:style="{ gap: `${row.gap}px` }" :class="{ 'row-selected': editing && selectedRowId === row.id, 'row-editing': editing }"
:style="{ '--row-gap': `${row.gap}px` }"
> >
<div v-if="editing" class="row-tools"> <div v-if="editing" class="row-tools">
<button @pointerdown="startDrag($event, 'row', row.id)">⠿ 拖动行</button> <button class="row-drag" aria-label="拖动行" @pointerdown="startDrag($event, 'row', row.id)">
<input v-model="row.name" @change="save" /> ⠿
<label>
间距
<input type="number" v-model.number="row.gap" @change="save" />
</label>
<button
@click="
scheme.rows.forEach((r) => (r.main = r === row));
save();
"
>
{{ row.main ? '主要行' : '设为主要行' }}
</button> </button>
<label> <button class="row-name" @click="selectRow(row.id)">
<input type="checkbox" v-model="row.allowNotifications" @change="save" /> {{ row.name }}
允许通知 <span v-if="row.main">主要行</span>
</label> </button>
<button <button class="row-settings" :aria-label="`${row.name}设置`" @click="selectRow(row.id)">
:disabled="scheme.rows.length < 2" 行设置
@click="
scheme.rows.splice(i, 1);
save();
"
>
删除行
</button> </button>
</div> </div>
<div class="row-content"> <div class="row-content" @click.self="editing && selectRow(row.id)">
<div class="row-components">
<ComponentNode <ComponentNode
v-for="n in row.nodes" 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"
@@ -458,10 +486,30 @@ onUnmounted(() => {
:editing="editing" :editing="editing"
:selected-id="selectedId" :selected-id="selectedId"
:alert-node-id="alertNodeId" :alert-node-id="alertNodeId"
@select="selectedId = $event" @select="selectNode"
@drag="(e, id) => startDrag(e, 'node', id)" @drag="(e, id) => startDrag(e, 'node', id)"
> >
<template #alerts><div id="custom-alert-host" :ref="hostRef" v-show="hasMessage" /></template> <template #alerts>
<div id="custom-alert-host" :ref="hostRef" v-show="hasMessage" />
</template>
</ComponentNode>
</div>
<ComponentNode
v-for="n in row.nodes.filter((n) => ['notification', 'homework', 'call'].includes(n.kind))"
:key="n.id"
:node="n"
:state="state"
:now="now"
:schedules="schedules"
:editing="editing"
:selected-id="selectedId"
:alert-node-id="alertNodeId"
@select="selectNode"
@drag="(e, id) => startDrag(e, 'node', id)"
>
<template #alerts>
<div id="custom-alert-host" :ref="hostRef" v-show="hasMessage" />
</template>
</ComponentNode> </ComponentNode>
<div <div
v-if="row.id === alertRow?.id && !alertNodeId" v-if="row.id === alertRow?.id && !alertNodeId"
@@ -469,287 +517,155 @@ onUnmounted(() => {
:ref="hostRef" :ref="hostRef"
v-show="hasMessage" v-show="hasMessage"
/> />
<span v-if="editing && !row.nodes.length" class="empty-row">拖入组件,或从左侧添加</span>
</div> </div>
<button v-if="editing" @click="addRow(i)">在下方新建主界面行</button>
</section> </section>
</div> </div>
</div> </div>
<button
v-if="editing"
class="add-row-button"
@click="addRow(scheme.rows.findIndex((r) => r.id === activeRow.id))"
>
+ 在下方新建主界面行
</button>
</div>
<div v-if="editing" class="canvas-footer">
<span>点选组件以设置 · 拖动句柄以排序 · 空白处可平移</span>
<label>
缩放
<input
aria-label="画布缩放"
type="range"
v-model.number="canvasScale"
min="0.3"
max="2"
step="0.1"
/>
<output>{{ Math.round(canvasScale * 100) }}%</output>
</label>
</div>
</main> </main>
<div v-if="!alertRow" id="custom-alert-host" :ref="hostRef" style="display: none" /> <div v-if="!alertRow" id="custom-alert-host" :ref="hostRef" style="display: none" />
<aside v-if="editing" class="settings"> <aside v-if="editing" class="settings">
<template v-if="selected"> <ComponentInspector
<h3>{{ ISLAND_COMPONENT_LABELS[selected.kind] }} 组件设置</h3> v-if="panel === 'component' && selected"
<button @click="remove">删除组件</button> :node="selected"
<button @click="duplicate">创建副本</button> @change="save"
<label> @remove="remove"
包裹到新容器 @duplicate="duplicate"
<select @change="wrap(($event.target as HTMLSelectElement).value as IslandComponentKind)"> @wrap="wrap"
<option value="">选择容器</option> />
<option v-for="kind in ['slide', 'rolling', 'group', 'stack']" :key="kind" :value="kind"> <template v-else-if="panel === 'row'">
{{ ISLAND_COMPONENT_LABELS[kind as IslandComponentKind] }} <div class="inspector-heading">
</option> <span class="eyebrow">布局设置</span>
</select> <h2>组件行</h2>
<p>管理这一行的排列与提醒</p>
</div>
<div class="inspector-body" @change="save">
<section class="setting-section">
<h3>基本设置</h3>
<label class="field">
行名称
<input v-model="activeRow.name" />
</label> </label>
<template v-if="selected.kind === 'clock'"> <label class="field inline-field">
<label> 组件间距
<input type="checkbox" v-model="selected.settings.hour12" @change="save" /> <input type="number" min="0" max="80" v-model.number="activeRow.gap" />
12 小时制
</label> </label>
<label> </section>
<input type="checkbox" v-model="selected.settings.seconds" @change="save" /> <section class="setting-section">
显示秒 <h3>消息提醒</h3>
<label class="toggle-field">
<span>允许显示提醒</span>
<input type="checkbox" v-model="activeRow.allowNotifications" />
</label> </label>
</template> <button
<label v-if="selected.kind === 'countdown'"> class="wide-button"
倒计时目标 :disabled="activeRow.main"
<select v-model="selected.settings.target" @change="save"> @click="
<option value="next">下一节课</option> scheme.rows.forEach((r) => (r.main = r.id === activeRow.id));
<option value="school">放学</option> save();
</select> "
</label>
<template v-if="selected.kind === 'text'">
<textarea v-model="selected.settings.text" @change="save" />
<label>
<input type="checkbox" v-model="selected.settings.template" @change="save" />
模板文本({date} {time} {course})
</label>
</template>
<template v-if="selected.kind === 'schedule'">
<select v-model="selected.settings.display" @change="save">
<option value="current">当前课程</option>
<option value="next">下一节</option>
<option value="today">今日课表</option>
</select>
<label>
显示节数
<input type="number" v-model.number="selected.settings.count" @change="save" />
</label>
<label>
<input type="checkbox" v-model="selected.settings.showPeriod" @change="save" />
显示节次
</label>
</template>
<template v-if="['group', 'stack', 'slide', 'rolling'].includes(selected.kind)">
<select v-model="selected.settings.direction" @change="save">
<option value="horizontal">横向</option>
<option value="vertical">纵向</option>
</select>
<label>
间距
<input type="number" v-model.number="selected.settings.gap" @change="save" />
</label>
<label>
轮播间隔(秒)
<input type="number" v-model.number="selected.settings.interval" @change="save" />
</label>
<label>
滚动速度
<input type="number" v-model.number="selected.settings.speed" @change="save" />
</label>
</template>
<h4>通用外观</h4>
<label>
<input type="checkbox" v-model="selected.appearance.resourceOverridingEnabled" @change="save" />
启用资源覆盖
</label>
<label
v-for="font in [
'secondaryFontSize',
'bodyFontSize',
'emphasizedFontSize',
'largeFontSize',
] as const"
:key="font"
> >
{{ {{ activeRow.main ? '已设为主要行' : '设为主要行' }}
{ </button>
secondaryFontSize: '次级字号', <p class="field-hint">提醒优先显示在主要行内;若该行禁用提醒,则显示在下一条允许提醒的行内。</p>
bodyFontSize: '正文字号', </section>
emphasizedFontSize: '强调字号', <section class="setting-section">
largeFontSize: '大号字号', <button class="wide-button" @click="addRow(scheme.rows.indexOf(activeRow))">
}[font] 在此行下方新增行
}} </button>
<input type="number" v-model.number="selected.appearance[font]" @change="save" /> <button
</label> class="wide-button danger-button"
<label> :disabled="scheme.rows.length < 2"
<input type="checkbox" v-model="selected.appearance.customForegroundEnabled" @change="save" /> @click="
自定义前景色 scheme.rows.splice(scheme.rows.indexOf(activeRow), 1);
<input type="color" v-model="selected.appearance.foreground" @change="save" /> selectedRowId = '';
</label> save();
<label> "
<input type="checkbox" v-model="selected.appearance.customBackgroundEnabled" @change="save" /> >
自定义背景色 删除这一行
<input type="color" v-model="selected.appearance.background" @change="save" /> </button>
</label> </section>
<label> </div>
<input
type="checkbox"
v-model="selected.appearance.customBackgroundOpacityEnabled"
@change="save"
/>
背景透明度
<input
type="number"
v-model.number="selected.appearance.backgroundOpacity"
min="0"
max="1"
step="0.05"
@change="save"
/>
</label>
<label>
<input type="checkbox" v-model="selected.appearance.customCornerRadiusEnabled" @change="save" />
自定义圆角
<input type="number" v-model.number="selected.appearance.cornerRadius" @change="save" />
</label>
<label>
元素透明度
<input
type="number"
v-model.number="selected.appearance.opacity"
min="0"
max="1"
step="0.05"
@change="save"
/>
</label>
<h4>高级设置</h4>
<label v-for="rule in ['in-class', 'no-message', 'no-class'] as const" :key="rule">
<input type="checkbox" v-model="selected.advanced.hideRules" :value="rule" @change="save" />
{{
{ 'in-class': '上课时隐藏', 'no-message': '无消息时隐藏', 'no-class': '无当前课程时隐藏' }[rule]
}}
</label>
<label>
对齐(有宽度时生效)
<select v-model="selected.advanced.align" @change="save">
<option value="stretch">拉伸</option>
<option value="start">起始</option>
<option value="center">居中</option>
<option value="end">末端</option>
</select>
</label>
<label>
固定宽度(0 自适应)
<input type="number" v-model.number="selected.advanced.width" @change="save" />
</label>
<label>
宽度上限(超出右侧截断)
<input type="number" v-model.number="selected.advanced.maxWidth" @change="save" />
</label>
</template> </template>
<p v-else>点选组件后显示它独立的设置。</p> <template v-else>
<p role="alert">{{ error }}</p> <div class="inspector-heading">
<span class="eyebrow">方案设置</span>
<h2>整体外观</h2>
<p>所有组件共用一个灵动岛</p>
</div>
<div class="inspector-body" @change="save">
<section class="setting-section">
<h3>当前方案</h3>
<label class="field">
方案名称
<input v-model="scheme.name" />
</label>
<label class="field inline-field">
内容缩放
<input type="number" v-model.number="scheme.scale" min="0.5" max="3" step="0.1" />
</label>
</section>
<section class="setting-section">
<h3>岛体</h3>
<label class="field inline-field">
背景颜色
<input type="color" v-model="scheme.color" />
</label>
<label class="field inline-field">
文字颜色
<input type="color" v-model="scheme.foreground" />
</label>
<label class="field">
背景透明度
<div class="range-field">
<input type="range" v-model.number="scheme.opacity" min="0.1" max="1" step="0.05" />
<output>{{ Math.round(scheme.opacity * 100) }}%</output>
</div>
</label>
<label class="field inline-field">
圆角
<input type="number" v-model.number="scheme.radius" min="0" max="100" />
</label>
</section>
<section class="setting-section">
<h3>多行布局</h3>
<label class="field inline-field">
行间距
<input type="number" v-model.number="scheme.spacing" min="0" max="80" />
</label>
<label class="toggle-field">
<span>各行分离显示</span>
<input type="checkbox" v-model="scheme.separated" />
</label>
<p class="field-hint">默认将所有行放在同一个岛体内;开启后各行拥有独立背景。</p>
</section>
</div>
</template>
<p v-if="error" class="editor-error" role="alert">{{ error }}</p>
</aside> </aside>
</div> </div>
</template> </template>
<style scoped> <style src="./custom-island.css"></style>
.custom-window {
padding: 10px;
width: 100vw;
height: 100vh;
box-sizing: border-box;
}
.custom-card {
box-sizing: border-box;
padding: 12px;
overflow: auto;
background: var(--custom-color);
border-radius: var(--custom-radius);
filter: drop-shadow(0 2px 3px #0003);
}
.custom-inner {
width: max-content;
min-width: 100%;
display: flex;
flex-direction: column;
gap: inherit;
}
.layout-row {
display: flex;
flex-direction: column;
}
.row-content {
display: flex;
align-items: center;
min-height: 24px;
}
.separated {
background: transparent;
}
.separated .layout-row {
background: var(--custom-color);
border-radius: var(--custom-radius);
padding: 8px;
}
.layout-editor {
position: fixed;
inset: 10px;
display: grid;
grid-template-columns: 200px minmax(0, 1fr) 280px;
grid-template-rows: auto 1fr;
color: var(--ch-text, #17202c);
background: var(--ch-surface, #f4f5f8);
border-radius: var(--ch-radius-lg, 16px);
overflow: hidden;
font:
14px 'Microsoft YaHei',
sans-serif;
}
.layout-editor header {
grid-column: 1/-1;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
}
.library,
.settings {
overflow: auto;
padding: 12px;
}
.settings label,
.library label {
display: block;
margin-block: 8px;
}
.canvas {
overflow: hidden;
touch-action: none;
padding: 80px 30px;
background: var(--ch-bg, #e3e5eb);
}
.canvas .custom-card {
transform-origin: top left;
}
.row-tools {
display: flex;
gap: 6px;
flex-wrap: wrap;
padding-bottom: 8px;
}
.layout-editor button,
.layout-editor select,
.layout-editor input:not([type='checkbox']),
.layout-editor textarea {
max-width: 100%;
min-height: 36px;
font: inherit;
border: 1px solid var(--ch-border, #9ca6b4);
border-radius: var(--ch-radius-sm, 6px);
padding: 4px;
}
.layout-editor button {
touch-action: none;
cursor: pointer;
}
.library button {
margin: 3px;
}
.layout-editor input[type='number'] {
width: 70px;
}
.layout-editor input[type='file'] {
width: 110px;
}
</style>
@@ -0,0 +1,614 @@
.custom-window {
padding: 10px;
width: 100vw;
height: 100vh;
}
.custom-card {
box-sizing: border-box;
padding: 12px;
overflow: auto;
background: var(--custom-color);
border-radius: var(--custom-radius);
box-shadow:
inset 0 0 0 1px #ffffff1e,
0 2px 3px #0000001c;
}
.custom-inner {
width: max-content;
display: flex;
flex-direction: column;
gap: var(--custom-row-gap);
}
.custom-card .layout-row {
display: flex;
flex-direction: column;
min-width: 0;
}
.custom-card .row-content {
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--row-gap);
min-height: 24px;
}
.custom-card .row-components {
display: flex;
align-items: center;
gap: var(--row-gap);
}
.custom-card .row-components:empty {
display: none;
}
.custom-card #custom-alert-host {
width: max-content;
max-width: 100%;
}
.custom-card.has-alert {
box-shadow:
inset 0 0 0 1px #ffffff2b,
0 2px 3px #0000001c;
}
.custom-card.separated {
background: transparent;
box-shadow: none;
padding: 0;
}
.custom-card.separated .layout-row {
background: var(--custom-color);
border-radius: var(--custom-radius);
padding: 12px;
box-shadow: inset 0 0 0 1px #ffffff1e;
}
/* 自定义模式只保留一个岛壳。原提醒渲染器继续负责内容、动画和按钮。 */
.custom-card .island-root.embedded .shape,
.custom-card .island-root.embedded .tint {
display: none;
}
.custom-card .island-root.embedded .island-card {
color: inherit;
}
.custom-card .island-root.embedded .content {
clip-path: none;
}
.custom-card .island-root.embedded .expanded-layer:not(.list-mode) {
padding: 16px 10px 10px;
justify-content: flex-start;
}
.custom-card .island-root.embedded {
border-top: 1px solid #ffffff1a;
}
.custom-window .preview-stage {
width: max-content;
}
.layout-editor {
--editor-bg: #f6f7f9;
--editor-border: #e6e9ef;
--editor-text: #202735;
--editor-muted: #7b8494;
--editor-accent: #3c69de;
position: fixed;
inset: 10px;
display: grid;
grid-template-columns: 232px minmax(0, 1fr) 300px;
grid-template-rows: 76px minmax(0, 1fr);
color: var(--editor-text);
background: #fff;
border: 1px solid #dce1e9;
border-radius: 18px;
overflow: hidden;
font:
13px 'Segoe UI Variable Display',
'Microsoft YaHei',
sans-serif;
box-shadow: 0 12px 40px #15203c22;
}
.layout-editor h1,
.layout-editor h2,
.layout-editor h3,
.layout-editor p {
margin: 0;
}
.layout-editor button,
.layout-editor select,
.layout-editor input,
.layout-editor textarea {
font: inherit;
}
.layout-editor button,
.layout-editor select,
.layout-editor input:not([type='checkbox']):not([type='range']),
.layout-editor textarea {
min-height: 36px;
border: 1px solid var(--editor-border);
border-radius: 8px;
padding: 7px 10px;
color: var(--editor-text);
background: #fff;
max-width: 100%;
}
.layout-editor button {
cursor: pointer;
touch-action: none;
transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.layout-editor button:hover {
background: #f0f3f9;
}
.layout-editor button:disabled {
cursor: default;
opacity: 0.45;
}
.layout-editor button:focus-visible,
.layout-editor input:focus-visible,
.layout-editor select:focus-visible,
.layout-editor textarea:focus-visible,
.layout-editor summary:focus-visible {
outline: 2px solid var(--editor-accent);
outline-offset: 2px;
}
.layout-editor input[type='checkbox'],
.layout-editor input[type='range'] {
accent-color: var(--editor-accent);
}
.layout-editor input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
flex-shrink: 0;
}
.layout-editor input[type='color'] {
width: 48px;
padding: 4px !important;
cursor: pointer;
}
.layout-editor .editor-header {
grid-column: 1/-1;
display: flex;
align-items: center;
gap: 24px;
padding: 0 24px;
border-bottom: 1px solid var(--editor-border);
z-index: 3;
}
.layout-editor .editor-title {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.layout-editor .editor-mark {
font-size: 30px;
color: #222b3b;
}
.layout-editor h1 {
font-size: 17px;
font-weight: 650;
letter-spacing: 0.5px;
}
.layout-editor .editor-title p {
font-size: 11px;
color: var(--editor-muted);
margin-top: 3px;
}
.layout-editor .scheme-picker {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.layout-editor .scheme-picker > span {
color: var(--editor-muted);
font-size: 12px;
}
.layout-editor .scheme-picker select {
width: 170px;
background: var(--editor-bg);
}
.layout-editor .scheme-menu {
position: relative;
}
.layout-editor .scheme-menu summary {
list-style: none;
cursor: pointer;
padding: 10px;
border-radius: 8px;
}
.layout-editor .scheme-menu-body {
position: absolute;
right: 0;
top: 40px;
width: 170px;
padding: 6px;
background: white;
border: 1px solid var(--editor-border);
border-radius: 12px;
box-shadow: 0 8px 28px #1c2d4c1a;
}
.layout-editor .scheme-menu-body button,
.layout-editor .import-button {
width: 100%;
display: block;
border: 0;
text-align: left;
padding: 10px;
cursor: pointer;
border-radius: 6px;
}
.layout-editor .import-button:hover {
background: var(--editor-bg);
}
.layout-editor .import-button input {
display: none;
}
.layout-editor .save-state {
color: var(--editor-muted);
font-size: 11px;
}
.layout-editor .primary-button {
color: #fff;
background: var(--editor-accent);
border-color: var(--editor-accent);
}
.layout-editor .primary-button:hover {
background: #305ac8;
}
.layout-editor .done-button {
min-width: 80px;
}
.layout-editor .library,
.layout-editor .settings {
overflow: auto;
background: #fff;
}
.layout-editor .library {
border-right: 1px solid var(--editor-border);
padding: 22px 16px 16px;
}
.layout-editor .panel-heading {
padding: 0 4px 20px;
}
.layout-editor h2 {
font-size: 17px;
font-weight: 650;
}
.layout-editor .panel-heading p,
.layout-editor .inspector-heading p {
font-size: 12px;
color: var(--editor-muted);
margin-top: 7px;
line-height: 1.6;
}
.layout-editor .library-group {
margin-bottom: 22px;
}
.layout-editor .library-group h3 {
color: var(--editor-muted);
font-size: 11px;
font-weight: 500;
margin: 0 4px 10px;
}
.layout-editor .component-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.layout-editor .library-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 4px;
font-size: 12px;
border-color: #eff1f5;
}
.layout-editor .component-symbol {
font-size: 22px;
height: 24px;
display: flex;
align-items: center;
color: #65728a;
}
.layout-editor .library-item.active {
background: #f0f4ff;
border-color: #97b2f4;
color: var(--editor-accent);
}
.layout-editor .library-item.active .component-symbol {
color: inherit;
}
.layout-editor .library-add {
position: sticky;
bottom: -16px;
padding: 16px 0;
background: white;
border-top: 1px solid var(--editor-border);
}
.layout-editor .canvas {
position: relative;
overflow: hidden;
touch-action: none;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 160px 36px 100px;
background:
radial-gradient(#d9dfe9 0.8px, transparent 0.8px) 0 0 / 20px 20px,
#f2f4f8;
}
.layout-editor .canvas-toolbar {
position: absolute;
top: 22px;
left: 24px;
right: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.layout-editor .canvas-toolbar > span {
color: var(--editor-muted);
font-size: 12px;
}
.layout-editor .canvas-toolbar > div {
display: flex;
gap: 6px;
pointer-events: auto;
}
.layout-editor .canvas-toolbar button {
font-size: 12px;
box-shadow: 0 2px 4px #18305a05;
}
.layout-editor .canvas-toolbar button.active {
color: var(--editor-accent);
border-color: #b6c8f0;
}
.layout-editor .preview-stage {
transform-origin: center top;
flex-shrink: 0;
max-width: none;
}
.layout-editor .custom-card {
min-width: 280px;
overflow: visible;
}
.layout-editor .row-editing {
position: relative;
padding: 34px 4px 8px;
border: 1px dashed #ffffff36;
border-radius: 12px;
}
.layout-editor .row-editing.row-selected {
border-color: #8baeff;
}
.layout-editor .row-tools {
position: absolute;
top: 0;
left: 2px;
right: 2px;
display: flex;
align-items: center;
gap: 3px;
height: 28px;
}
.layout-editor .row-tools button {
border: 0;
min-height: 24px;
padding: 3px 6px;
background: transparent;
color: #ffffffab;
font-size: 11px;
}
.layout-editor .row-tools button:hover {
background: #ffffff12;
color: white;
}
.layout-editor .row-name {
display: flex;
align-items: center;
gap: 6px;
}
.layout-editor .row-name span {
font-size: 9px;
padding: 1px 5px;
color: #a9c3ff;
border-radius: 4px;
background: #83a8ff22;
}
.layout-editor .row-tools .row-settings {
margin-left: auto;
}
.layout-editor .row-content {
min-width: 260px;
min-height: 52px;
}
.layout-editor .empty-row {
color: #ffffff60;
padding: 10px 18px;
font-size: 12px;
}
.layout-editor .add-row-button {
display: block;
width: 100%;
margin-top: 16px;
color: #7d899d;
background: #ffffff70;
border: 1px dashed #c7d0e0;
font-size: 12px;
}
.layout-editor .canvas-footer {
position: absolute;
bottom: 18px;
left: 24px;
right: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--editor-muted);
font-size: 11px;
pointer-events: none;
}
.layout-editor .canvas-footer label {
display: flex;
align-items: center;
gap: 8px;
pointer-events: auto;
}
.layout-editor .canvas-footer input {
width: 90px;
}
.layout-editor .canvas-footer output {
width: 34px;
}
.layout-editor .settings {
border-left: 1px solid var(--editor-border);
}
.layout-editor .inspector-heading {
padding: 24px 24px 20px;
}
.layout-editor .eyebrow {
display: block;
color: var(--editor-muted);
font-size: 10px;
margin-bottom: 8px;
}
.layout-editor .panel-tabs {
display: flex;
gap: 6px;
padding: 0 24px 16px;
border-bottom: 1px solid var(--editor-border);
}
.layout-editor .panel-tabs button {
flex: 1;
border: 0;
min-height: 32px;
font-size: 12px;
background: var(--editor-bg);
color: #788295;
}
.layout-editor .panel-tabs button.active {
color: var(--editor-accent);
background: #edf2ff;
}
.layout-editor .inspector-body {
padding: 0 24px 24px;
}
.layout-editor .setting-section {
padding: 20px 0;
border-bottom: 1px solid var(--editor-border);
}
.layout-editor .setting-section:last-child {
border-bottom: 0;
}
.layout-editor .setting-section h3 {
font-size: 12px;
font-weight: 650;
margin-bottom: 16px;
}
.layout-editor .field {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
margin-bottom: 16px;
}
.layout-editor .field:last-child {
margin-bottom: 0;
}
.layout-editor .field > input:not([type='color']),
.layout-editor .field > textarea,
.layout-editor .field > select {
width: 100%;
}
.layout-editor .inline-field {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.layout-editor .inline-field input[type='number'] {
width: 80px;
}
.layout-editor .toggle-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 38px;
font-size: 12px;
}
.layout-editor .field-hint {
font-size: 11px;
color: var(--editor-muted);
line-height: 1.8;
margin: 8px 0 12px;
}
.layout-editor .range-field {
display: flex;
align-items: center;
gap: 12px;
}
.layout-editor .range-field input {
flex: 1;
min-width: 0;
}
.layout-editor .range-field output {
width: 36px;
color: var(--editor-muted);
}
.layout-editor .wide-button {
width: 100%;
margin-bottom: 12px;
}
.layout-editor .wide-button:last-child {
margin-bottom: 0;
}
.layout-editor .danger-button {
color: #cc4a52;
background: #fff;
}
.layout-editor .danger-button:hover {
background: #fff3f4;
}
.layout-editor fieldset {
padding: 0;
margin: 0;
border: 0;
}
.layout-editor fieldset:disabled {
opacity: 0.45;
}
.layout-editor .editor-error {
margin: 12px 24px;
padding: 10px;
background: #fff1f1;
color: #bc3f4b;
border-radius: 8px;
line-height: 1.7;
}
@media (max-width: 1100px) {
.layout-editor {
grid-template-columns: 188px minmax(0, 1fr) 260px;
}
.layout-editor .editor-header {
gap: 12px;
padding: 0 18px;
}
.layout-editor .scheme-picker > span,
.layout-editor .save-state,
.layout-editor .canvas-footer > span {
display: none;
}
.layout-editor .inspector-heading,
.layout-editor .inspector-body {
padding-left: 18px;
padding-right: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.layout-editor button {
transition: none;
}
}
@@ -193,6 +193,8 @@ class IslandController {
*/ */
private touchMode = false; private touchMode = false;
private editingLayout = false; private editingLayout = false;
private settlingLayout = false;
private layoutSettleTimer: ReturnType<typeof setTimeout> | null = null;
private customSize = { width: 424, height: 80 }; private customSize = { width: 424, height: 80 };
private customDesiredSize = { width: 424, height: 80 }; private customDesiredSize = { width: 424, height: 80 };
private customTimer: ReturnType<typeof setTimeout> | null = null; private customTimer: ReturnType<typeof setTimeout> | null = null;
@@ -207,6 +209,9 @@ class IslandController {
this.syncWindow(); this.syncWindow();
} }
editLayout(enabled = true): void { editLayout(enabled = true): void {
if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer);
this.layoutSettleTimer = null;
this.settlingLayout = false;
this.editingLayout = enabled; this.editingLayout = enabled;
if (enabled) saveConfig({ islandLayout: { ...getConfig().islandLayout, layout: 'custom' } }); if (enabled) saveConfig({ islandLayout: { ...getConfig().islandLayout, layout: 'custom' } });
this.win?.webContents.send('island:layout', getConfig().islandLayout); this.win?.webContents.send('island:layout', getConfig().islandLayout);
@@ -230,7 +235,16 @@ class IslandController {
}; };
this.win.webContents.send('island:viewport', this.customSize); this.win.webContents.send('island:viewport', this.customSize);
this.applyWindowLayout(); this.applyWindowLayout();
if (this.customLayout) {
// 退出编辑先保持隐藏,等行工具栏移除、渲染器防抖测量和主进程防抖完成。
// 避免把编辑器的占位高度带回常态岛;收缩仍全部发生在不可见期间。
this.settlingLayout = true;
this.layoutSettleTimer = setTimeout(() => {
this.layoutSettleTimer = null;
this.settlingLayout = false;
this.syncWindow(); this.syncWindow();
}, 320);
} else this.syncWindow();
} }
} }
} }
@@ -1176,6 +1190,7 @@ class IslandController {
return; return;
} }
if (this.customLayout) { if (this.customLayout) {
if (this.settlingLayout) return;
this.cancelFade(); this.cancelFade();
this.applyWindowLayout(); this.applyWindowLayout();
this.win.setOpacity(this.appearance.opacity); this.win.setOpacity(this.appearance.opacity);
@@ -47,7 +47,7 @@ export async function verifyLayoutSmoke(
}); });
await check('组件可以包裹进容器且设置独立保存', async () => { await check('组件可以包裹进容器且设置独立保存', async () => {
await win.webContents.executeJavaScript( await win.webContents.executeJavaScript(
`(() => { const select=document.querySelector('.settings select');if(!select)throw Error('未选中组件');select.value='group';select.dispatchEvent(new Event('change',{bubbles:true})); })()`, `(() => { const select=document.querySelector('[data-action=wrap]');if(!select)throw Error('未选中组件');select.value='group';select.dispatchEvent(new Event('change',{bubbles:true})); })()`,
); );
await pause(); await pause();
return ( return (
@@ -56,6 +56,27 @@ export async function verifyLayoutSmoke(
) === true ) === true
); );
}); });
await check('组件设置按内容外观高级切换,整体外观单独显示', async () => {
const initial = await win.webContents.executeJavaScript(`(() => {
const panel=document.querySelector('.settings');
return panel?.textContent.includes('显示内容') && !panel.textContent.includes('字体大小') && !panel.textContent.includes('背景颜色');
})()`);
await win.webContents.executeJavaScript(`document.querySelectorAll('.panel-tabs button')[1].click()`);
await pause();
const appearance = await win.webContents.executeJavaScript(`(() => {
const panel=document.querySelector('.settings');
return panel?.textContent.includes('字体大小') && !panel.textContent.includes('组件操作');
})()`);
await win.webContents.executeJavaScript(`document.querySelectorAll('.panel-tabs button')[2].click()`);
await pause();
const advanced = await win.webContents.executeJavaScript(`(() => {
const panel=document.querySelector('.settings');
return panel?.textContent.includes('隐藏条件') && !panel.textContent.includes('字体大小');
})()`);
await win.webContents.executeJavaScript(`document.querySelectorAll('.panel-tabs button')[0].click()`);
await pause();
return initial && appearance && advanced;
});
if (process.env.ISLAND_SHOTS_DIR) { if (process.env.ISLAND_SHOTS_DIR) {
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true }); fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
fs.writeFileSync( fs.writeFileSync(
@@ -71,12 +92,14 @@ export async function verifyLayoutSmoke(
await check('自定义窗口等于卡片加阴影留白且夹紧工作区', async () => { await check('自定义窗口等于卡片加阴影留白且夹紧工作区', async () => {
await pause(); await pause();
const rect = await win.webContents.executeJavaScript( const rect = await win.webContents.executeJavaScript(
`(() => {const r=document.querySelector('.custom-card').getBoundingClientRect();return {width:r.width,height:r.height};})()`, `(() => {const r=document.querySelector('.custom-card').getBoundingClientRect(), inner=document.querySelector('.custom-inner').getBoundingClientRect();return {width:r.width,height:r.height,contentWidth:inner.width+24,contentHeight:inner.height+24};})()`,
); );
const box = win.getBounds(); const box = win.getBounds();
return ( return (
Math.abs(box.width - rect.width - 2 * ISLAND_SHADOW_PAD) <= 2 && Math.abs(box.width - rect.width - 2 * ISLAND_SHADOW_PAD) <= 2 &&
Math.abs(box.height - rect.height - 2 * ISLAND_SHADOW_PAD) <= 2 Math.abs(box.height - rect.height - 2 * ISLAND_SHADOW_PAD) <= 2 &&
Math.abs(rect.width - rect.contentWidth) <= 2 &&
Math.abs(rect.height - rect.contentHeight) <= 2
); );
}); });
await check('自定义提醒复用原渲染器且进入主要行', async () => { await check('自定义提醒复用原渲染器且进入主要行', async () => {
@@ -96,6 +119,35 @@ export async function verifyLayoutSmoke(
`(() => {const host=document.querySelector('#custom-alert-host'),node=host?.querySelector('.island-root.embedded');return Boolean(node&&node.textContent.includes('组件化提醒验收')&&host.closest('[data-row]')===document.querySelector('[data-row]'));})()`, `(() => {const host=document.querySelector('#custom-alert-host'),node=host?.querySelector('.island-root.embedded');return Boolean(node&&node.textContent.includes('组件化提醒验收')&&host.closest('[data-row]')===document.querySelector('[data-row]'));})()`,
); );
}); });
await check('提醒展开内容可见,岛宽取组件与提醒的较宽者', async () => {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
const fits = await win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.custom-card'), host=card?.querySelector('#custom-alert-host');
const layer=host?.querySelector('.expanded-layer');
if(!card||!host||!layer||Number(getComputedStyle(layer).opacity)<0.95)return false;
const rows=[...card.querySelectorAll('.row-components')].map(row=>row.getBoundingClientRect().width);
const expected=Math.max(...rows,host.getBoundingClientRect().width)+24;
const outer=card.getBoundingClientRect(), inner=host.getBoundingClientRect();
return Math.abs(outer.width-expected)<=2 && inner.bottom<=outer.bottom+1;
})()`);
if (fits) return true;
await pause();
}
return false;
});
// 隐藏窗口的 rAF 可能限流;先等原渲染器的形变兜底与尺寸防抖落地,再验最终几何。
await check('组件与提醒同处岛体内部,提醒没有第二层岛壳', async () => {
return win.webContents.executeJavaScript(`(() => {
const card=document.querySelector('.custom-card'), host=card?.querySelector('#custom-alert-host');
const shape=host?.querySelector('.shape'), content=host?.querySelector('.content');
if(!card||!host||!shape||!content)return false;
const outer=card.getBoundingClientRect(), inner=host.getBoundingClientRect();
return document.querySelectorAll('.custom-card').length===1 && getComputedStyle(shape).display==='none'
&& getComputedStyle(content).clipPath==='none' && inner.left>=outer.left && inner.right<=outer.right+1
&& inner.top>=outer.top && inner.bottom<=outer.bottom+1 && Boolean(card.querySelector('.clock'));
})()`);
});
await check('主要行禁用提醒后按允许提醒的行回落', async () => { await check('主要行禁用提醒后按允许提醒的行回落', async () => {
const changed = getConfig().islandLayout; const changed = getConfig().islandLayout;
const rows = changed.schemes[0]!.rows; const rows = changed.schemes[0]!.rows;
@@ -113,7 +165,9 @@ export async function verifyLayoutSmoke(
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-alert.png'), path.join(process.env.ISLAND_SHOTS_DIR, 'custom-alert.png'),
(await win.webContents.capturePage()).toPNG(), (await win.webContents.capturePage()).toPNG(),
); );
island.handleAction({ action: 'dismiss-all' }); await win.webContents.executeJavaScript(
`document.querySelector('#custom-alert-host .solid-btn')?.click()`,
);
await pause(); await pause();
await pause(); await pause();
await pause(); await pause();
@@ -124,6 +178,11 @@ export async function verifyLayoutSmoke(
const visible = process.env.ELECTRON_SMOKE_MODE === 'visual' ? win.isVisible() : !win.isVisible(); const visible = process.env.ELECTRON_SMOKE_MODE === 'visual' ? win.isVisible() : !win.isVisible();
return content && visible && island.getState().mode === 'hidden'; return content && visible && island.getState().mode === 'hidden';
}); });
await check('没有提醒时消息组件不占位', async () => {
return win.webContents.executeJavaScript(
`document.querySelectorAll('.custom-card .component-node.notification, .custom-card .component-node.homework, .custom-card .component-node.call').length === 0`,
);
});
} finally { } finally {
saveConfig({ islandLayout: original }); saveConfig({ islandLayout: original });
island.syncCustomLayout(); island.syncCustomLayout();
@@ -24,6 +24,7 @@ import {
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE }); const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const savingIsland = ref(false); const savingIsland = ref(false);
function editLayout() { function editLayout() {
layoutMode.value = 'custom';
void window.desktop?.islandEditLayout(); void window.desktop?.islandEditLayout();
} }
const layoutMode = ref<'fixed' | 'custom'>('fixed'); const layoutMode = ref<'fixed' | 'custom'>('fixed');
@@ -32,6 +33,10 @@ async function changeLayout() {
if (config) if (config)
await window.desktop?.saveConfig({ islandLayout: { ...config.islandLayout, layout: layoutMode.value } }); await window.desktop?.saveConfig({ islandLayout: { ...config.islandLayout, layout: layoutMode.value } });
} }
async function chooseLayout(mode: 'fixed' | 'custom') {
layoutMode.value = mode;
await changeLayout();
}
onMounted(async () => { onMounted(async () => {
layoutMode.value = (await window.desktop?.getConfig())?.islandLayout.layout ?? 'fixed'; layoutMode.value = (await window.desktop?.getConfig())?.islandLayout.layout ?? 'fixed';
}); });
@@ -106,14 +111,6 @@ onMounted(async () => {
<template> <template>
<div class="page"> <div class="page">
<el-card>
<h3>组件布局</h3>
<el-select v-model="layoutMode" @change="changeLayout">
<el-option label="固定灵动岛(默认)" value="fixed" />
<el-option label="自定义组件栏" value="custom" />
</el-select>
<el-button @click="editLayout">编辑灵动岛布局</el-button>
</el-card>
<div class="page-header"> <div class="page-header">
<div> <div>
<h2 class="page-title">灵动岛</h2> <h2 class="page-title">灵动岛</h2>
@@ -121,9 +118,43 @@ onMounted(async () => {
</div> </div>
</div> </div>
<el-card shadow="never" class="layout-mode-card">
<template #header><span>岛内布局</span></template>
<div class="layout-choices">
<button
class="layout-choice"
:class="{ active: layoutMode === 'fixed' }"
:aria-pressed="layoutMode === 'fixed'"
@click="chooseLayout('fixed')"
>
<strong>经典灵动岛</strong>
<span>有消息时展开,保持熟悉的提醒样式</span>
</button>
<button
class="layout-choice"
:class="{ active: layoutMode === 'custom' }"
:aria-pressed="layoutMode === 'custom'"
@click="chooseLayout('custom')"
>
<strong>自定义灵动岛</strong>
<span>在岛内排列时钟、课程和消息等组件</span>
</button>
</div>
<div v-if="layoutMode === 'custom'" class="layout-edit-entry">
<div>
<strong>按你的习惯布置岛内内容</strong>
<p>在预览中添加和排列组件,点选组件即可设置;更改自动保存到本机。</p>
</div>
<el-button type="primary" @click="editLayout">编辑岛内组件</el-button>
</div>
</el-card>
<el-card shadow="never"> <el-card shadow="never">
<template #header><span>灵动岛 · 个性化</span></template> <template #header>
<span>{{ layoutMode === 'fixed' ? '外观与位置' : '位置与提醒' }}</span>
</template>
<el-form label-width="96px" label-position="left"> <el-form label-width="96px" label-position="left">
<template v-if="layoutMode === 'fixed'">
<el-form-item :label="`高度 ${island.height}px`"> <el-form-item :label="`高度 ${island.height}px`">
<el-slider <el-slider
:model-value="island.height" :model-value="island.height"
@@ -200,6 +231,7 @@ onMounted(async () => {
</el-select> </el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span> <span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item> </el-form-item>
</template>
<el-form-item label="显示位置"> <el-form-item label="显示位置">
<el-select <el-select
:model-value="island.position" :model-value="island.position"
@@ -265,7 +297,7 @@ onMounted(async () => {
" "
/> />
</el-form-item> </el-form-item>
<el-form-item label="空闲细缝"> <el-form-item v-if="layoutMode === 'fixed'" label="空闲细缝">
<el-switch <el-switch
:model-value="island.idleSliver" :model-value="island.idleSliver"
@change=" @change="
@@ -290,6 +322,73 @@ onMounted(async () => {
</div> </div>
</template> </template>
<style scoped>
.layout-mode-card {
margin-bottom: 20px;
}
.layout-choices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.layout-choice {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: 12px;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
font: inherit;
}
.layout-choice strong {
font-size: 15px;
}
.layout-choice span {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.7;
}
.layout-choice.active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.layout-choice:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 3px;
}
.layout-edit-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color-lighter);
}
.layout-edit-entry strong {
font-size: 14px;
}
.layout-edit-entry p {
margin: 8px 0 0;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.8;
}
@media (max-width: 680px) {
.layout-choices {
grid-template-columns: 1fr;
}
.layout-edit-entry {
align-items: flex-start;
flex-direction: column;
}
}
</style>
<style scoped> <style scoped>
.ml-8 { .ml-8 {
margin-left: 8px; margin-left: 8px;