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
+1591 -551

No files matched your search

@@ -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(
() =>
!props.editing &&
props.node.advanced.hideRules.some(
(r) =>
(r === 'in-class' && props.state.inClass) ||
(r === 'no-message' && !props.state.active) ||
(r === 'no-class' && !current.value),
),
((['notification', 'homework', 'call'].includes(props.node.kind) &&
(!props.state.active || props.state.mode === 'hidden' || props.node.id !== props.alertNodeId)) ||
props.node.advanced.hideRules.some(
(r) =>
(r === 'in-class' && props.state.inClass) ||
(r === 'no-message' && !props.state.active) ||
(r === 'no-class' && !current.value),
)),
);
const visibleChildren = computed(() =>
props.node.kind === 'slide'
@@ -122,7 +124,7 @@ const style = computed<CSSProperties>(() => {
<div
v-if="!hidden"
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"
:data-node="node.id"
@click.stop="editing && emit('select', node.id)"
@@ -192,14 +194,46 @@ const style = computed<CSSProperties>(() => {
animation: rolling var(--rolling-duration) linear infinite;
}
.selected {
outline: 2px solid var(--ch-accent, #5b9cff);
outline: 2px solid #85aaff;
border-radius: 8px;
background: #759cff14;
}
.node-handle {
touch-action: none;
cursor: grab;
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;
flex-basis: auto;
}
.node-editing {
cursor: pointer;
}
.node-editing:hover {
box-shadow: inset 0 0 0 1px #ffffff28;
border-radius: 8px;
}
small {
font-size: var(--secondary, 12px);
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from 'vue';
import {
ISLAND_COMPONENTS,
ISLAND_COMPONENT_LABELS,
ISLAND_SHADOW_PAD,
islandNode,
@@ -13,6 +12,8 @@ import {
type ScheduleDto,
} from '@classhelper/shared';
import ComponentNode from './ComponentNode.vue';
import ComponentInspector from './ComponentInspector.vue';
import ComponentIcon from './ComponentIcon.vue';
const props = defineProps<{
settings: IslandLayoutSettings;
state: IslandState;
@@ -38,6 +39,32 @@ watch(
const scheme = computed(() => draft.value.schemes.find((s) => s.id === draft.value.activeSchemeId)!);
const selectedId = ref(''),
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()),
canvasScale = ref(1),
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 shellStyle = computed<CSSProperties>(() => ({
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)`,
maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`,
color: scheme.value.foreground,
@@ -77,6 +104,7 @@ const shellStyle = computed<CSSProperties>(() => ({
.toString(16)
.padStart(2, '0'),
'--custom-radius': `${scheme.value.radius}px`,
'--custom-row-gap': `${scheme.value.spacing}px`,
gap: `${scheme.value.spacing}px`,
}));
function id() {
@@ -86,6 +114,7 @@ function save() {
const value = normalizeIslandLayouts(JSON.parse(JSON.stringify(draft.value)));
draft.value = value;
emit('save', value);
savedAt.value = '已自动保存';
}
function addRow(after: number) {
scheme.value.rows.splice(after + 1, 0, {
@@ -96,13 +125,14 @@ function addRow(after: number) {
gap: 8,
nodes: [],
});
selectRow(scheme.value.rows[after + 1]!.id);
save();
}
function add(kind = selectedKind.value, row = scheme.value.rows[0]) {
function add(kind = selectedKind.value, row = activeRow.value) {
if (!row) return;
const n = islandNode(kind, id());
row.nodes.push(n);
selectedId.value = n.id;
selectNode(n.id);
save();
}
function findList(nodeId: string, nodes?: IslandComponentNode[]): IslandComponentNode[] | null {
@@ -123,6 +153,7 @@ function remove() {
1,
);
selectedId.value = '';
panel.value = 'row';
save();
}
function duplicate() {
@@ -132,7 +163,7 @@ function duplicate() {
const copy = JSON.parse(JSON.stringify(n)) as IslandComponentNode;
for (const child of allNodes([copy])) child.id = id();
list.splice(list.indexOf(n) + 1, 0, copy);
selectedId.value = copy.id;
selectNode(copy.id);
save();
}
function wrap(kind: IslandComponentKind) {
@@ -143,7 +174,7 @@ function wrap(kind: IslandComponentKind) {
const container = islandNode(kind, id());
container.children = [n];
list.splice(list.indexOf(n), 1, container);
selectedId.value = container.id;
selectNode(container.id);
save();
}
function newScheme() {
@@ -157,7 +188,7 @@ function newScheme() {
}
draft.value.schemes.push(copy);
draft.value.activeSchemeId = copy.id;
save();
switchScheme();
}
function exportScheme() {
const url = URL.createObjectURL(
@@ -187,7 +218,8 @@ async function importScheme(event: Event) {
s.id = id();
draft.value.schemes.push(s);
draft.value.activeSchemeId = s.id;
save();
switchScheme();
error.value = '';
} catch (e) {
error.value = String(e);
}
@@ -247,7 +279,7 @@ function drop() {
const targetList = findList(target.id)!;
targetList.splice(targetList.indexOf(target), 0, node);
} else row.nodes.push(node);
selectedId.value = node.id;
selectNode(node.id);
save();
}
let panning: { x: number; y: number; originX: number; originY: number } | null = null;
@@ -268,11 +300,21 @@ function measure() {
clearTimeout(measureTimer);
measureTimer = setTimeout(() => {
if (inner.value) {
// 内部组件的自然尺寸决定整个岛体;提醒不再拥有另一层岛壳。
const rect = inner.value.getBoundingClientRect(),
padding = shell.value ? getComputedStyle(shell.value) : null,
scale = props.editing ? canvasScale.value : 1;
window.island?.reportSize({
width: Math.ceil(rect.width / scale + 24),
height: Math.ceil(rect.height / scale + 24),
width: Math.ceil(
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);
@@ -304,36 +346,34 @@ onUnmounted(() => {
panning = null;
"
>
<header v-if="editing">
<strong>编辑灵动岛</strong>
<select
v-model="draft.activeSchemeId"
@change="
selectedId = '';
save();
"
>
<option v-for="s in draft.schemes" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
<button @click="newScheme">新方案</button>
<button @click="exportScheme">导出</button>
<label>
导入
<input type="file" accept="application/json" @change="importScheme" />
</label>
<button
@click="
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>
<header v-if="editing" class="editor-header">
<div class="editor-title">
<span class="editor-mark" aria-hidden="true">◒</span>
<div>
<h1>自定义灵动岛</h1>
<p>把需要的信息,放进岛里</p>
</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>
</select>
<details class="scheme-menu">
<summary aria-label="方案操作">•••</summary>
<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" />
</label>
</div>
</details>
</div>
<span class="save-state" role="status">{{ savedAt || '更改会自动保存' }}</span>
<button
class="primary-button done-button"
@click="
save();
emit('done');
@@ -343,413 +383,289 @@ onUnmounted(() => {
</button>
</header>
<aside v-if="editing" class="library">
<h3>组件库</h3>
<button
v-for="kind in ISLAND_COMPONENTS"
:key="kind"
@pointerdown="startDrag($event, 'library', '', kind)"
@click="selectedKind = kind"
>
{{ ISLAND_COMPONENT_LABELS[kind as IslandComponentKind] }}
</button>
<select v-model="selectedKind">
<option v-for="kind in ISLAND_COMPONENTS" :key="kind" :value="kind">
{{ ISLAND_COMPONENT_LABELS[kind as IslandComponentKind] }}
</option>
</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>
<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
v-for="kind in group.kinds"
:key="kind"
class="library-item"
:class="{ active: selectedKind === kind }"
:aria-pressed="selectedKind === kind"
@pointerdown="startDrag($event, 'library', '', kind)"
@click="selectedKind = kind"
@dblclick="add(kind)"
>
<span class="component-symbol"><ComponentIcon :kind="kind" /></span>
<span>{{ ISLAND_COMPONENT_LABELS[kind] }}</span>
</button>
</div>
</section>
<div class="library-add">
<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>
</label>
<button class="primary-button wide-button" @click="add()">
添加{{ ISLAND_COMPONENT_LABELS[selectedKind] }}
</button>
</div>
</aside>
<main :class="{ canvas: editing }" @pointerdown="panStart">
<div
ref="shell"
class="custom-card"
:class="{ separated: scheme.separated }"
:style="[
shellStyle,
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 }">
<section
v-for="(row, i) in scheme.rows"
:key="row.id"
:data-row="row.id"
class="layout-row"
:style="{ gap: `${row.gap}px` }"
<div v-if="editing" class="canvas-toolbar">
<span>岛内预览</span>
<div>
<button
:class="{ active: panel === 'appearance' }"
@click="
panel = 'appearance';
selectedId = '';
"
>
<div v-if="editing" class="row-tools">
<button @pointerdown="startDrag($event, 'row', row.id)">⠿ 拖动行</button>
<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>
<label>
<input type="checkbox" v-model="row.allowNotifications" @change="save" />
允许通知
</label>
<button
:disabled="scheme.rows.length < 2"
@click="
scheme.rows.splice(i, 1);
save();
"
>
删除行
</button>
</div>
<div class="row-content">
<ComponentNode
v-for="n in row.nodes"
:key="n.id"
:node="n"
:state="state"
:now="now"
:schedules="schedules"
:editing="editing"
:selected-id="selectedId"
:alert-node-id="alertNodeId"
@select="selectedId = $event"
@drag="(e, id) => startDrag(e, 'node', id)"
>
<template #alerts><div id="custom-alert-host" :ref="hostRef" v-show="hasMessage" /></template>
</ComponentNode>
<div
v-if="row.id === alertRow?.id && !alertNodeId"
id="custom-alert-host"
:ref="hostRef"
v-show="hasMessage"
/>
</div>
<button v-if="editing" @click="addRow(i)">在下方新建主界面行</button>
</section>
整体外观
</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
ref="shell"
class="custom-card"
:class="{ separated: scheme.separated, 'has-alert': hasMessage }"
:data-priority="hasMessage ? state.active?.priority : undefined"
:style="[shellStyle, editing ? { width: 'max-content', maxWidth: 'none', maxHeight: 'none' } : {}]"
>
<div ref="inner" class="custom-inner" :style="{ zoom: scheme.scale }">
<section
v-for="row in scheme.rows"
:key="row.id"
:data-row="row.id"
class="layout-row"
:class="{ 'row-selected': editing && selectedRowId === row.id, 'row-editing': editing }"
:style="{ '--row-gap': `${row.gap}px` }"
>
<div v-if="editing" class="row-tools">
<button class="row-drag" aria-label="拖动行" @pointerdown="startDrag($event, 'row', row.id)">
⠿
</button>
<button class="row-name" @click="selectRow(row.id)">
{{ row.name }}
<span v-if="row.main">主要行</span>
</button>
<button class="row-settings" :aria-label="`${row.name}设置`" @click="selectRow(row.id)">
行设置
</button>
</div>
<div class="row-content" @click.self="editing && selectRow(row.id)">
<div class="row-components">
<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>
</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>
<div
v-if="row.id === alertRow?.id && !alertNodeId"
id="custom-alert-host"
:ref="hostRef"
v-show="hasMessage"
/>
<span v-if="editing && !row.nodes.length" class="empty-row">拖入组件,或从左侧添加</span>
</div>
</section>
</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>
<div v-if="!alertRow" id="custom-alert-host" :ref="hostRef" style="display: none" />
<aside v-if="editing" class="settings">
<template v-if="selected">
<h3>{{ ISLAND_COMPONENT_LABELS[selected.kind] }} 组件设置</h3>
<button @click="remove">删除组件</button>
<button @click="duplicate">创建副本</button>
<label>
包裹到新容器
<select @change="wrap(($event.target as HTMLSelectElement).value as IslandComponentKind)">
<option value="">选择容器</option>
<option v-for="kind in ['slide', 'rolling', 'group', 'stack']" :key="kind" :value="kind">
{{ ISLAND_COMPONENT_LABELS[kind as IslandComponentKind] }}
</option>
</select>
</label>
<template v-if="selected.kind === 'clock'">
<label>
<input type="checkbox" v-model="selected.settings.hour12" @change="save" />
12 小时制
</label>
<label>
<input type="checkbox" v-model="selected.settings.seconds" @change="save" />
显示秒
</label>
</template>
<label v-if="selected.kind === 'countdown'">
倒计时目标
<select v-model="selected.settings.target" @change="save">
<option value="next">下一节课</option>
<option value="school">放学</option>
</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"
>
{{
{
secondaryFontSize: '次级字号',
bodyFontSize: '正文字号',
emphasizedFontSize: '强调字号',
largeFontSize: '大号字号',
}[font]
}}
<input type="number" v-model.number="selected.appearance[font]" @change="save" />
</label>
<label>
<input type="checkbox" v-model="selected.appearance.customForegroundEnabled" @change="save" />
自定义前景色
<input type="color" v-model="selected.appearance.foreground" @change="save" />
</label>
<label>
<input type="checkbox" v-model="selected.appearance.customBackgroundEnabled" @change="save" />
自定义背景色
<input type="color" v-model="selected.appearance.background" @change="save" />
</label>
<label>
<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>
<ComponentInspector
v-if="panel === 'component' && selected"
:node="selected"
@change="save"
@remove="remove"
@duplicate="duplicate"
@wrap="wrap"
/>
<template v-else-if="panel === 'row'">
<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="activeRow.name" />
</label>
<label class="field inline-field">
组件间距
<input type="number" min="0" max="80" v-model.number="activeRow.gap" />
</label>
</section>
<section class="setting-section">
<h3>消息提醒</h3>
<label class="toggle-field">
<span>允许显示提醒</span>
<input type="checkbox" v-model="activeRow.allowNotifications" />
</label>
<button
class="wide-button"
:disabled="activeRow.main"
@click="
scheme.rows.forEach((r) => (r.main = r.id === activeRow.id));
save();
"
>
{{ activeRow.main ? '已设为主要行' : '设为主要行' }}
</button>
<p class="field-hint">提醒优先显示在主要行内;若该行禁用提醒,则显示在下一条允许提醒的行内。</p>
</section>
<section class="setting-section">
<button class="wide-button" @click="addRow(scheme.rows.indexOf(activeRow))">
在此行下方新增行
</button>
<button
class="wide-button danger-button"
:disabled="scheme.rows.length < 2"
@click="
scheme.rows.splice(scheme.rows.indexOf(activeRow), 1);
selectedRowId = '';
save();
"
>
删除这一行
</button>
</section>
</div>
</template>
<p v-else>点选组件后显示它独立的设置。</p>
<p role="alert">{{ error }}</p>
<template v-else>
<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>
</div>
</template>
<style scoped>
.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>
<style src="./custom-island.css"></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;
}
}
+16 -1
View File
@@ -193,6 +193,8 @@ class IslandController {
*/
private touchMode = false;
private editingLayout = false;
private settlingLayout = false;
private layoutSettleTimer: ReturnType<typeof setTimeout> | null = null;
private customSize = { width: 424, height: 80 };
private customDesiredSize = { width: 424, height: 80 };
private customTimer: ReturnType<typeof setTimeout> | null = null;
@@ -207,6 +209,9 @@ class IslandController {
this.syncWindow();
}
editLayout(enabled = true): void {
if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer);
this.layoutSettleTimer = null;
this.settlingLayout = false;
this.editingLayout = enabled;
if (enabled) saveConfig({ islandLayout: { ...getConfig().islandLayout, layout: 'custom' } });
this.win?.webContents.send('island:layout', getConfig().islandLayout);
@@ -230,7 +235,16 @@ class IslandController {
};
this.win.webContents.send('island:viewport', this.customSize);
this.applyWindowLayout();
this.syncWindow();
if (this.customLayout) {
// 退出编辑先保持隐藏,等行工具栏移除、渲染器防抖测量和主进程防抖完成。
// 避免把编辑器的占位高度带回常态岛;收缩仍全部发生在不可见期间。
this.settlingLayout = true;
this.layoutSettleTimer = setTimeout(() => {
this.layoutSettleTimer = null;
this.settlingLayout = false;
this.syncWindow();
}, 320);
} else this.syncWindow();
}
}
}
@@ -1176,6 +1190,7 @@ class IslandController {
return;
}
if (this.customLayout) {
if (this.settlingLayout) return;
this.cancelFade();
this.applyWindowLayout();
this.win.setOpacity(this.appearance.opacity);
@@ -47,7 +47,7 @@ export async function verifyLayoutSmoke(
});
await check('组件可以包裹进容器且设置独立保存', async () => {
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();
return (
@@ -56,6 +56,27 @@ export async function verifyLayoutSmoke(
) === 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) {
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
fs.writeFileSync(
@@ -71,12 +92,14 @@ export async function verifyLayoutSmoke(
await check('自定义窗口等于卡片加阴影留白且夹紧工作区', async () => {
await pause();
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();
return (
Math.abs(box.width - rect.width - 2 * ISLAND_SHADOW_PAD) <= 2 &&
Math.abs(box.height - rect.height - 2 * ISLAND_SHADOW_PAD) <= 2
Math.abs(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 () => {
@@ -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]'));})()`,
);
});
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 () => {
const changed = getConfig().islandLayout;
const rows = changed.schemes[0]!.rows;
@@ -113,7 +165,9 @@ export async function verifyLayoutSmoke(
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-alert.png'),
(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();
@@ -124,6 +178,11 @@ export async function verifyLayoutSmoke(
const visible = process.env.ELECTRON_SMOKE_MODE === 'visual' ? win.isVisible() : !win.isVisible();
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 {
saveConfig({ islandLayout: original });
island.syncCustomLayout();
@@ -24,6 +24,7 @@ import {
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const savingIsland = ref(false);
function editLayout() {
layoutMode.value = 'custom';
void window.desktop?.islandEditLayout();
}
const layoutMode = ref<'fixed' | 'custom'>('fixed');
@@ -32,6 +33,10 @@ async function changeLayout() {
if (config)
await window.desktop?.saveConfig({ islandLayout: { ...config.islandLayout, layout: layoutMode.value } });
}
async function chooseLayout(mode: 'fixed' | 'custom') {
layoutMode.value = mode;
await changeLayout();
}
onMounted(async () => {
layoutMode.value = (await window.desktop?.getConfig())?.islandLayout.layout ?? 'fixed';
});
@@ -106,14 +111,6 @@ onMounted(async () => {
<template>
<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>
<h2 class="page-title">灵动岛</h2>
@@ -121,85 +118,120 @@ onMounted(async () => {
</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">
<template #header><span>灵动岛 · 个性化</span></template>
<template #header>
<span>{{ layoutMode === 'fixed' ? '外观与位置' : '位置与提醒' }}</span>
</template>
<el-form label-width="96px" label-position="left">
<el-form-item :label="`高度 ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
:max="RANGES.height.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ height: value })"
/>
</el-form-item>
<el-form-item :label="`宽度 ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
:max="RANGES.width.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ width: value })"
/>
</el-form-item>
<el-form-item :label="`圆角 ${island.radius}px`">
<el-slider
:model-value="island.radius"
:min="RANGES.radius.min"
:max="RANGES.radius.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ radius: value })"
/>
</el-form-item>
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
<el-slider
:model-value="island.opacity"
:min="RANGES.opacity.min"
:max="RANGES.opacity.max"
:step="0.05"
@input="(value: number) => applyIslandAppearance({ opacity: value })"
/>
</el-form-item>
<el-form-item :label="`字号 ${island.fontSize}px`">
<el-slider
:model-value="island.fontSize"
:min="RANGES.fontSize.min"
:max="RANGES.fontSize.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
/>
</el-form-item>
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
<el-slider
:model-value="island.speed"
:min="RANGES.speed.min"
:max="RANGES.speed.max"
:step="0.1"
@input="(value: number) => applyIslandAppearance({ speed: value })"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker
:model-value="island.accent"
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
/>
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="视觉风格">
<el-select
:model-value="island.style"
style="width: 180px"
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
>
<el-option
v-for="item in styleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
<template v-if="layoutMode === 'fixed'">
<el-form-item :label="`高度 ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
:max="RANGES.height.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ height: value })"
/>
</el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item>
</el-form-item>
<el-form-item :label="`宽度 ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
:max="RANGES.width.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ width: value })"
/>
</el-form-item>
<el-form-item :label="`圆角 ${island.radius}px`">
<el-slider
:model-value="island.radius"
:min="RANGES.radius.min"
:max="RANGES.radius.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ radius: value })"
/>
</el-form-item>
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
<el-slider
:model-value="island.opacity"
:min="RANGES.opacity.min"
:max="RANGES.opacity.max"
:step="0.05"
@input="(value: number) => applyIslandAppearance({ opacity: value })"
/>
</el-form-item>
<el-form-item :label="`字号 ${island.fontSize}px`">
<el-slider
:model-value="island.fontSize"
:min="RANGES.fontSize.min"
:max="RANGES.fontSize.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
/>
</el-form-item>
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
<el-slider
:model-value="island.speed"
:min="RANGES.speed.min"
:max="RANGES.speed.max"
:step="0.1"
@input="(value: number) => applyIslandAppearance({ speed: value })"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker
:model-value="island.accent"
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
/>
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="视觉风格">
<el-select
:model-value="island.style"
style="width: 180px"
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
>
<el-option
v-for="item in styleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item>
</template>
<el-form-item label="显示位置">
<el-select
:model-value="island.position"
@@ -265,7 +297,7 @@ onMounted(async () => {
"
/>
</el-form-item>
<el-form-item label="空闲细缝">
<el-form-item v-if="layoutMode === 'fixed'" label="空闲细缝">
<el-switch
:model-value="island.idleSliver"
@change="
@@ -290,6 +322,73 @@ onMounted(async () => {
</div>
</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>
.ml-8 {
margin-left: 8px;