fix(island): 将组件与提醒合入岛体并重写布局设置
This commit is contained in:
1 parent
ead66f0b93
commit
c83b03d8e6
10 files changed
+1591
-551
No files matched your search
@@ -52,6 +52,18 @@
|
||||
|
||||
本机方案存 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` 课程表组件。
|
||||
|
||||
+17
-14
@@ -1566,22 +1566,25 @@ makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列
|
||||
[LessonsService 的启用解析](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Services/LessonsService.cs)、
|
||||
[组件文档](https://docs.classisland.tech/app/component/)。复刻能力与语义,不引入 .NET 或已退役插件。
|
||||
|
||||
| 需求 | 实现入口 | 验收入口 |
|
||||
| -------------------------------------------- | --------------------------------------------- | --------------------------------------- |
|
||||
| 日课表、1–9 周轮换、四种时间点、显式位置外键 | 时间表与课表编辑页、class-plans 模块 | `packages/server/scripts/verify-v14.ts` |
|
||||
| 换课/临时层/预定/群组,原表不变 | schedules 与 class-plan-groups | 同上 |
|
||||
| 本机多行、多方案、组件独立设置与触摸拖动 | 设置→灵动岛→编辑布局;托盘同名入口 | `verify:desktop` 的组件布局用例 |
|
||||
| 容器、规则隐藏、四档字体与外观覆盖 | 编辑器右侧设置 | 同上 + 显式视觉回归 |
|
||||
| 通知/作业/叫人组件 | 主要且允许通知的行,复用 IslandApp 卡片与操作 | 原有固定模式回归 + 组件布局回归 |
|
||||
| 换服务端重置退避与令牌 | config-events / client-agent | 本地假服务端心跳用例 |
|
||||
| 即时远控与回执 | clients / client-agent | 冒烟假执行器;不调用系统命令 |
|
||||
| 下载与安装阶段进度 | 客户端管理页/桌面关于页 | 进度 GET、ADMIN 房间事件、IPC |
|
||||
| 集体成绩与转出过滤 | grades / lib/students | 临时数据库隐私与范围负断言 |
|
||||
| 自动学年升级与可配周数 | 学期周次页 | dry-run、快照与事务幂等 |
|
||||
| 自启动按钮与日期归档 | 系统设置页/通知日期选择器 | 注入系统适配器、Web 回归 |
|
||||
| 四种内置岗位组与只读名单 | 权限组页/学生名单页 | 跨年级、跨部 403 |
|
||||
| 需求 | 实现入口 | 验收入口 |
|
||||
| -------------------------------------------- | ---------------------------------------------- | --------------------------------------- |
|
||||
| 日课表、1–9 周轮换、四种时间点、显式位置外键 | 时间表与课表编辑页、class-plans 模块 | `packages/server/scripts/verify-v14.ts` |
|
||||
| 换课/临时层/预定/群组,原表不变 | schedules 与 class-plan-groups | 同上 |
|
||||
| 本机多行、多方案、组件独立设置与触摸拖动 | 设置→灵动岛→编辑岛内组件;托盘同名入口 | `verify:desktop` 的组件布局用例 |
|
||||
| 容器、规则隐藏、四档字体与外观覆盖 | 右侧按内容/外观/高级分栏;行设置和整体外观独立 | 同上 + 显式视觉回归 |
|
||||
| 通知/作业/叫人组件 | 主要且允许通知的行内,共用岛体和原提醒操作 | 原有固定模式回归 + 组件布局回归 |
|
||||
| 换服务端重置退避与令牌 | config-events / client-agent | 本地假服务端心跳用例 |
|
||||
| 即时远控与回执 | clients / client-agent | 冒烟假执行器;不调用系统命令 |
|
||||
| 下载与安装阶段进度 | 客户端管理页/桌面关于页 | 进度 GET、ADMIN 房间事件、IPC |
|
||||
| 集体成绩与转出过滤 | grades / lib/students | 临时数据库隐私与范围负断言 |
|
||||
| 自动学年升级与可配周数 | 学期周次页 | dry-run、快照与事务幂等 |
|
||||
| 自启动按钮与日期归档 | 系统设置页/通知日期选择器 | 注入系统适配器、Web 回归 |
|
||||
| 四种内置岗位组与只读名单 | 权限组页/学生名单页 | 跨年级、跨部 403 |
|
||||
|
||||
天气组件本轮不做:没有可离线使用的数据源,验收不依赖外网。
|
||||
自定义组件栏位于灵动岛内部,提醒内容也在同一个岛体内展开,不再嵌套另一张带底色的岛卡片。
|
||||
编辑器左侧按用途添加组件、中央预览岛内排列、右侧只显示当前对象的设置;方案操作收在顶部菜单。
|
||||
主设置页选择经典或自定义灵动岛,自定义模式的组件尺寸、配色在编辑器中调整,主设置页保留位置与提醒选项。
|
||||
迁移保留旧 Schedule 作为审计来源;新门面只写新模型。重复占用同一时间点的旧行无法同时成为一节课,
|
||||
迁移选择同一时段 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(
|
||||
() =>
|
||||
!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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user