Files
ClassHelper/packages/desktop-client/src/renderer/components/IslandComponentSettings.vue
T
LoveChengke 682818dd4a feat: 重做灵动岛编辑器与课表档案时间表编辑
灵动岛
- 重构自定义方案编辑器:组件库常驻、拖拽添加与排序、方案切换/新建/复制/删除、编辑模式预览与缩放,容器在编辑时展开全部子组件。
- 新增天气简报组件:主进程固定请求 Open-Meteo,只接受数值经纬度,失败退避并保留本次运行缓存;冒烟模式不访问外网。
- 倒计时支持目标名称与日期;课程表组件新增「今日时间点」,按时间点类型折叠/展开,课中不再常驻倒计时。
- preload 暴露 getIslandWeather 与时间点订阅,契约同步 desktop.d.ts。

课表档案
- 时间点默认课程:布局返回 defaultCourse,日课表没有条目时回退;新增 apply-default 覆盖所有引用课表;全校时间表不携带默认课程。
- 新增 ClassIsland 式时间表编辑器(列表/时间轴视图、拖动把柄、按类型添加、默认时长与缩放)与「课表信息」表单。
- 课表页默认进入单表编辑,底部保留列表/周/月视图;时间表支持新路由删除。
- 「课间」统一改称「课间休息」。

验证
- pnpm typecheck、pnpm lint 通过。
- verify:e2e 隔离验收 248/248 项通过(含默认课程回退、覆盖写入与时间表删除)。
- ui-smoke 覆盖单表编辑、课表信息与时间轴视图,并更新回归截图。
2026-10-25 16:41:29 +08:00

308 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
ISLAND_COMPONENT_LABELS,
type IslandComponentNode,
type IslandComponentKind,
} from '@classhelper/shared';
const props = defineProps<{ node: IslandComponentNode }>();
const emit = defineEmits<{ change: []; remove: []; duplicate: []; wrap: [kind: IslandComponentKind] }>();
const node = computed(() => props.node),
tab = ref('content');
watch(
() => props.node.id,
() => {
tab.value = 'content';
},
);
const fonts = {
secondaryFontSize: '辅助文字',
bodyFontSize: '正文',
emphasizedFontSize: '强调文字',
largeFontSize: '大号文字',
} as const;
const containers = ['group', 'stack', 'slide', 'rolling'] as const;
</script>
<template>
<div class="component-settings">
<h3>{{ ISLAND_COMPONENT_LABELS[node.kind] }}</h3>
<el-tabs v-model="tab" stretch>
<el-tab-pane label="内容" name="content">
<el-form label-position="top">
<template v-if="node.kind === 'clock'">
<el-form-item label="12 小时制">
<el-switch v-model="node.settings.hour12" @change="emit('change')" />
</el-form-item>
<el-form-item label="显示秒">
<el-switch v-model="node.settings.seconds" @change="emit('change')" />
</el-form-item>
</template>
<template v-else-if="node.kind === 'countdown'">
<el-form-item label="倒计时目标">
<el-select v-model="node.settings.target" @change="emit('change')">
<el-option label="自动:距上课 / 下课" value="auto" />
<el-option label="下一节课" value="next" />
<el-option label="本节下课" value="end" />
<el-option label="放学" value="school" />
<el-option label="指定日期" value="date" />
</el-select>
</el-form-item>
<template v-if="node.settings.target === 'date'">
<el-form-item label="目标名称">
<el-input v-model="node.settings.countdownTitle" :maxlength="64" @change="emit('change')" />
</el-form-item>
<el-form-item label="目标日期">
<el-date-picker
v-model="node.settings.targetDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
@change="emit('change')"
/>
</el-form-item>
</template>
</template>
<template v-else-if="node.kind === 'weather'">
<el-form-item label="地点名称">
<el-input
v-model="node.settings.locationName"
placeholder="例如:学校"
:maxlength="64"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="纬度">
<el-input-number
v-model="node.settings.latitude"
:min="-90"
:max="90"
:precision="4"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="经度">
<el-input-number
v-model="node.settings.longitude"
:min="-180"
:max="180"
:precision="4"
@change="emit('change')"
/>
</el-form-item>
<p class="hint">
填写地点坐标后每 15 分钟更新天气。数据来自
Open-Meteo,断网时显示本次运行的缓存;天气提示不作为官方预警。
</p>
</template>
<template v-else-if="node.kind === 'text'">
<el-form-item label="文字内容">
<el-input v-model="node.settings.text" type="textarea" :rows="3" @change="emit('change')" />
</el-form-item>
<el-form-item label="启用模板变量">
<el-switch v-model="node.settings.template" @change="emit('change')" />
</el-form-item>
<p class="hint">支持 {date} 日期、{time} 时间、{course} 当前课程。</p>
</template>
<template v-else-if="node.kind === 'schedule'">
<el-form-item label="显示课程">
<el-select v-model="node.settings.display" @change="emit('change')">
<el-option label="当前时间点" value="current" />
<el-option label="下一节课" value="next" />
<el-option label="今日时间点" value="today" />
</el-select>
</el-form-item>
<el-form-item v-if="node.settings.display === 'today'" label="显示时间点数">
<el-input-number v-model="node.settings.count" :min="1" :max="30" @change="emit('change')" />
</el-form-item>
<el-form-item label="当前时间点显示起止时间">
<el-switch v-model="node.settings.showPeriod" @change="emit('change')" />
</el-form-item>
<el-form-item label="当前课程显示进度">
<el-switch v-model="node.settings.showProgress" @change="emit('change')" />
</el-form-item>
<p class="hint">
今日时间点只展开当前项并显示时间段;课间休息与默认隐藏的时间点折叠时完全隐藏,轮到时才展开。
下课前一分钟的倒计时由「提醒」设置里的下课提醒负责。
</p>
</template>
<template v-else-if="containers.includes(node.kind as (typeof containers)[number])">
<el-form-item label="排列方向">
<el-select v-model="node.settings.direction" @change="emit('change')">
<el-option label="横向" value="horizontal" />
<el-option label="纵向" value="vertical" />
</el-select>
</el-form-item>
<el-form-item label="组件间距">
<el-input-number v-model="node.settings.gap" :min="0" :max="64" @change="emit('change')" />
</el-form-item>
<el-form-item v-if="node.kind === 'slide'" label="切换间隔(秒)">
<el-input-number
v-model="node.settings.interval"
:min="1"
:max="300"
@change="emit('change')"
/>
</el-form-item>
<el-form-item v-if="node.kind === 'rolling'" label="滚动速度">
<el-input-number v-model="node.settings.speed" :min="1" :max="200" @change="emit('change')" />
</el-form-item>
</template>
<p v-else class="hint">此组件使用实时数据。可在外观和高级设置中调整显示。</p>
<el-divider />
<el-form-item label="包裹到容器">
<el-select
data-action="wrap"
model-value=""
placeholder="选择容器类型"
@change="(kind: IslandComponentKind) => emit('wrap', kind)"
>
<el-option
v-for="kind in containers"
:key="kind"
:label="ISLAND_COMPONENT_LABELS[kind]"
:value="kind"
/>
</el-select>
</el-form-item>
<div class="actions">
<el-button @click="emit('duplicate')">复制组件</el-button>
<el-button type="danger" plain @click="emit('remove')">删除</el-button>
</div>
</el-form>
</el-tab-pane>
<el-tab-pane label="外观" name="appearance">
<el-form label-position="top">
<el-form-item label="独立外观">
<el-switch v-model="node.appearance.resourceOverridingEnabled" @change="emit('change')" />
</el-form-item>
<p class="hint">关闭时沿用灵动岛的整体外观。</p>
<el-form-item v-for="(label, key) in fonts" :key="key" :label="`${label}字号`">
<el-input-number
v-model="node.appearance[key]"
:disabled="!node.appearance.resourceOverridingEnabled"
:min="8"
:max="96"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义文字颜色">
<el-switch
v-model="node.appearance.customForegroundEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-color-picker
v-if="node.appearance.customForegroundEnabled"
v-model="node.appearance.foreground"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义背景颜色">
<el-switch
v-model="node.appearance.customBackgroundEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-color-picker
v-if="node.appearance.customBackgroundEnabled"
v-model="node.appearance.background"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义背景透明度">
<el-switch
v-model="node.appearance.customBackgroundOpacityEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-input-number
v-if="node.appearance.customBackgroundOpacityEnabled"
v-model="node.appearance.backgroundOpacity"
:min="0"
:max="1"
:step="0.05"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="自定义圆角">
<el-switch
v-model="node.appearance.customCornerRadiusEnabled"
:disabled="!node.appearance.resourceOverridingEnabled"
@change="emit('change')"
/>
<el-input-number
v-if="node.appearance.customCornerRadiusEnabled"
v-model="node.appearance.cornerRadius"
:min="0"
:max="100"
@change="emit('change')"
/>
</el-form-item>
<el-form-item label="组件透明度">
<el-slider
v-model="node.appearance.opacity"
:min="0"
:max="1"
:step="0.05"
@change="emit('change')"
/>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="高级" name="advanced">
<el-form label-position="top">
<el-form-item label="对齐方式">
<el-select v-model="node.advanced.align" @change="emit('change')">
<el-option
v-for="(label, key) in { stretch: '拉伸', start: '起始', center: '居中', end: '末端' }"
:key="key"
:label="label"
:value="key"
/>
</el-select>
</el-form-item>
<el-form-item label="固定宽度(px)">
<el-input-number v-model="node.advanced.width" :min="0" :max="2000" @change="emit('change')" />
</el-form-item>
<el-form-item label="最大宽度(px)">
<el-input-number v-model="node.advanced.maxWidth" :min="0" :max="2000" @change="emit('change')" />
</el-form-item>
<p class="hint">宽度为 0 时自适应;对齐在父容器设定宽度时生效。</p>
<el-form-item label="隐藏条件">
<el-checkbox-group v-model="node.advanced.hideRules" @change="emit('change')">
<el-checkbox label="上课时" value="in-class" />
<el-checkbox label="没有消息时" value="no-message" />
<el-checkbox label="没有当前课程时" value="no-class" />
</el-checkbox-group>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
h3 {
margin: 0 0 6px;
}
.hint {
color: var(--ch-text-secondary);
line-height: 1.6;
font-size: 12px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.el-select {
width: 100%;
}
.el-form-item :deep(.el-form-item__content) {
gap: 10px;
}
.el-checkbox-group {
display: flex;
flex-direction: column;
}
</style>