feat(ui): 补齐课程提醒进度并内嵌编辑器与重构后台导航

This commit is contained in:
LoveChengke committed 2026-10-17 21:21:28 +08:00
1 parent 3379eb975c
commit 536379036a
34 files changed
+1559 -455

No files matched your search

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
defaultIslandLayouts,
DEFAULT_ISLAND_APPEARANCE,
type IslandState,
type IslandAppearance,
} from '@classhelper/shared';
import CustomIsland from '../../../island/CustomIsland.vue';
import { getIslandSchedules } from '../../island/bridge.js';
const router = useRouter();
const settings = ref(defaultIslandLayouts());
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const schedules = ref(getIslandSchedules());
const state = ref<IslandState>({
mode: 'hidden',
active: null,
queued: [],
inClass: false,
currentPeriodEnd: null,
reason: null,
listExpanded: false,
maxCardHeight: 1024,
updatedAt: 0,
});
const ready = ref(false);
const error = ref('');
let refresh: ReturnType<typeof setInterval>;
onMounted(async () => {
const config = await window.desktop?.getConfig();
if (config) {
settings.value = { ...config.islandLayout, layout: 'custom', residentEnabled: true };
appearance.value = config.island;
await window.desktop?.saveConfig({ islandLayout: JSON.parse(JSON.stringify(settings.value)) });
}
ready.value = true;
refresh = setInterval(() => {
schedules.value = getIslandSchedules();
}, 1000);
});
onUnmounted(() => clearInterval(refresh));
// 保存按顺序发送;快速拖动不会让较早的响应覆盖较新的草稿。
let saving = Promise.resolve();
function save(value: typeof settings.value) {
settings.value = value;
const snapshot = JSON.parse(JSON.stringify(value));
saving = saving
.then(async () => {
await window.desktop?.saveConfig({ islandLayout: snapshot });
error.value = '';
})
.catch(() => {
error.value = '保存失败,请重试';
});
}
async function done() {
await saving;
if (!error.value) await router.push('/settings/island');
}
</script>
<template>
<section class="island-editor-page" aria-label="自定义灵动岛编辑器">
<p v-if="error" role="alert">{{ error }}</p>
<CustomIsland
v-if="ready"
:settings="settings"
:appearance="appearance"
:schedules="schedules"
:state="state"
editing
embedded
@save="save"
@done="done"
/>
</section>
</template>
<style>
.island-editor-page {
height: 100%;
min-height: 430px;
min-width: 0;
display: flex;
flex-direction: column;
}
.island-editor-page .layout-editor {
position: relative;
inset: auto;
flex: 1;
min-height: 430px;
width: 100%;
border-radius: 0;
box-shadow: none;
grid-template-columns: 160px minmax(200px, 1fr) 248px;
grid-template-rows: 64px minmax(0, 1fr);
}
.island-editor-page .layout-editor .canvas {
padding: 110px 16px 80px;
}
.island-editor-page .layout-editor .library,
.island-editor-page .layout-editor .settings {
min-height: 0;
padding: 14px 12px;
}
.island-editor-page .layout-editor .editor-header {
padding: 10px 14px;
gap: 12px;
}
.island-editor-page .layout-editor .editor-title p,
.island-editor-page .layout-editor .save-state {
display: none;
}
.island-editor-page .layout-editor .scheme-picker {
margin-left: auto;
}
@media (max-width: 1100px) {
.island-editor-page .layout-editor {
grid-template-columns: 130px minmax(140px, 1fr) 220px;
}
.island-editor-page .layout-editor .component-grid {
grid-template-columns: 1fr;
}
.island-editor-page .layout-editor .scheme-picker > span {
display: none;
}
}
</style>
@@ -2,6 +2,7 @@
import { confirmAction } from '../../composables/confirm.js';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import {
DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES,
@@ -23,9 +24,10 @@ import {
*/
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const savingIsland = ref(false);
const router = useRouter();
function editLayout() {
layoutMode.value = 'custom';
void window.desktop?.islandEditLayout();
void router.push('/settings/island/editor');
}
const layoutMode = ref<'fixed' | 'custom'>('fixed');
const residentEnabled = ref(false);
@@ -161,7 +163,7 @@ onMounted(async () => {
@click="chooseLayout('fixed')"
>
<strong>经典灵动岛</strong>
<span>有消息时展开,保持熟悉的提醒样式</span>
<span>显示课程进度、上下课提醒与班级消息</span>
</button>
<button
class="layout-choice"
@@ -208,7 +210,7 @@ onMounted(async () => {
</template>
<p class="text-muted">紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。</p>
<p class="text-muted">
上课前 60 秒显示准备提醒和倒计时,上下课时提示课程状态;课程时间显示在名称右侧。
课前提示与倒计时、课中进度、即将下课倒数及上下课提醒,可在「提醒」页分别设置。课程时间显示在名称右侧。
</p>
<el-form label-width="110px" label-position="left">
<el-form-item :label="`${layoutMode === 'custom' ? '通知高度' : '高度'} ${island.height}px`">
@@ -2,7 +2,26 @@
import { onMounted, ref } from 'vue';
import { classGradeQueryApi } from '../../api/index.js';
import { useAuthStore } from '../../stores/auth.js';
import { DEFAULT_LESSON_REMINDERS, type LessonReminderSettings } from '@classhelper/shared';
import { ElMessage } from 'element-plus';
const auth = useAuthStore();
const lesson = ref({ ...DEFAULT_LESSON_REMINDERS });
onMounted(async () => {
const config = await window.desktop?.getConfig();
if (config) lesson.value = { ...config.islandLayout.lessonReminders };
});
async function saveLesson(patch: Partial<LessonReminderSettings>) {
lesson.value = { ...lesson.value, ...patch };
const config = await window.desktop?.getConfig();
if (!config) return;
try {
await window.desktop?.saveConfig({
islandLayout: { ...config.islandLayout, lessonReminders: { ...lesson.value } },
});
} catch {
ElMessage.error('提醒设置保存失败');
}
}
/* ------------------------------------------------------------ 班级端成绩查询状态(只读)
*
* 「是否允许班级端查看成绩」是**服务端口径**:管理员在 Web 端「班级管理」里开关,
@@ -34,7 +53,51 @@ onMounted(loadGradeQuery);
</div>
</div>
<el-card shadow="never">
<el-card shadow="never" class="lesson-channel-settings">
<template #header>上下课提醒</template>
<p class="text-muted">
沿用 ClassIsland 的遮罩、课前倒计时、上下课提示及下节课预告;课中显示课程进度,临近下课显示倒数。
</p>
<div
v-for="channel in [
{ key: 'prepareEnabled', title: '准备上课提醒' },
{ key: 'onClassEnabled', title: '上课提醒' },
{ key: 'endingEnabled', title: '即将下课倒数' },
{ key: 'classProgressEnabled', title: '经典岛课中常显课程进度' },
{ key: 'breakEnabled', title: '下课提醒与下节课预告' },
] as const"
:key="channel.key"
class="lesson-setting-row"
>
<span>{{ channel.title }}</span>
<el-switch
:model-value="lesson[channel.key]"
:aria-label="channel.title"
@change="(v: boolean | string | number) => saveLesson({ [channel.key]: v === true })"
/>
</div>
<div class="lesson-setting-row">
<span>课前提前(秒)</span>
<el-input-number
:model-value="lesson.prepareSeconds"
:min="5"
:max="1800"
:step="5"
@change="(v: number | undefined) => saveLesson({ prepareSeconds: v ?? 60 })"
/>
</div>
<div class="lesson-setting-row">
<span>下课倒数提前(秒)</span>
<el-input-number
:model-value="lesson.endingSeconds"
:min="5"
:max="600"
:step="5"
@change="(v: number | undefined) => saveLesson({ endingSeconds: v ?? 60 })"
/>
</div>
</el-card>
<el-card shadow="never" class="mt-16">
<template #header>通知提醒</template>
<p class="text-muted">
通知与叫人通过 ClassHelper 客户端和灵动岛提醒。普通通知在上课期间暂存,下课后补弹;紧急通知立即提醒。
@@ -62,6 +125,17 @@ onMounted(loadGradeQuery);
</template>
<style scoped>
.lesson-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 12px 0;
border-bottom: 1px solid var(--ch-border);
}
.lesson-setting-row:last-child {
border-bottom: 0;
}
.channel-group {
display: flex;
flex-direction: column;