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