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

@@ -63,7 +63,9 @@ const fonts = {
<label v-else-if="node.kind === 'countdown'" class="field">
倒计时目标
<select v-model="node.settings.target">
<option value="auto">自动:课中距下课,课间距上课</option>
<option value="next">下一节课</option>
<option value="end">本节下课</option>
<option value="school">放学</option>
</select>
</label>
@@ -95,6 +97,10 @@ const fonts = {
<span>显示课程时间</span>
<input type="checkbox" v-model="node.settings.showPeriod" />
</label>
<label class="toggle-field">
<span>显示上课进度条</span>
<input type="checkbox" v-model="node.settings.showProgress" />
</label>
</template>
<template v-else-if="['group', 'stack', 'slide', 'rolling'].includes(node.kind)">
<label class="field">
@@ -2,6 +2,7 @@
import { computed, type CSSProperties } from 'vue';
import {
ISLAND_COMPONENT_LABELS,
resolveClassStatus,
type IslandComponentNode,
type IslandState,
type ScheduleDto,
@@ -23,16 +24,35 @@ function select(event: MouseEvent) {
emit('select', props.node.id);
}
const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind));
const current = computed(() => {
const d = new Date(props.now),
t = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
return props.schedules.find((s) => s.startTime <= t && s.endTime > t);
});
const next = computed(() => {
const d = new Date(props.now),
t = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
return props.schedules.find((s) => s.startTime > t);
});
const today = computed(() =>
props.schedules
.filter((s) => s.dayOfWeek === (new Date(props.now).getDay() || 7))
.sort((a, b) => a.startTime.localeCompare(b.startTime)),
);
const status = computed(() => resolveClassStatus(today.value, new Date(props.now)));
const current = computed(() => today.value.find((s) => s.id === status.value.current?.scheduleId));
const next = computed(() => today.value.find((s) => s.id === status.value.next?.scheduleId));
function timeAt(time: string) {
const d = new Date(props.now);
const [h, m] = time.split(':').map(Number);
d.setHours(h!, m!, 0, 0);
return d.getTime();
}
const elapsed = computed(() =>
current.value
? Math.max(
0,
Math.min(
1,
(props.now - timeAt(current.value.startTime)) /
Math.max(1, timeAt(current.value.endTime) - timeAt(current.value.startTime)),
),
)
: 0,
);
const remaining = computed(() =>
current.value ? Math.max(0, Math.ceil((timeAt(current.value.endTime) - props.now) / 1000)) : 0,
);
const hidden = computed(
() =>
!props.editing &&
@@ -75,21 +95,27 @@ const content = computed(() => {
.replaceAll('{course}', current.value?.course?.name || '暂无课程')
: n.settings.text;
if (n.kind === 'countdown') {
const time = n.settings.target === 'school' ? props.schedules.at(-1)?.endTime : next.value?.startTime;
const ending = n.settings.target === 'end' || (n.settings.target === 'auto' && !!current.value);
const time =
n.settings.target === 'school'
? today.value.at(-1)?.endTime
: ending
? current.value?.endTime
: next.value?.startTime;
if (!time) return '今日课程已结束';
const [h, m] = time.split(':').map(Number);
const end = new Date(d);
end.setHours(h!, m!, 0, 0);
const left = Math.max(0, Math.ceil((end.getTime() - props.now) / 1000));
return `${n.settings.target === 'school' ? '距放学' : '距上课'} ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
return `${n.settings.target === 'school' ? '距放学' : ending ? '距下课' : '距上课'} ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
}
return '';
});
const lessons = computed(() =>
props.node.settings.display === 'today'
? props.schedules.slice(0, props.node.settings.count)
: [props.node.settings.display === 'next' ? next.value : current.value].filter((s): s is ScheduleDto =>
Boolean(s),
? today.value.slice(0, props.node.settings.count)
: [props.node.settings.display === 'next' ? next.value : current.value || next.value].filter(
(s): s is ScheduleDto => Boolean(s),
),
);
const style = computed<CSSProperties>(() => {
@@ -166,8 +192,22 @@ const style = computed<CSSProperties>(() => {
<div v-for="s in lessons" :key="s.id" class="lesson-line">
<strong>{{ s.course?.name }}</strong>
<small v-if="node.settings.showPeriod" class="lesson-time">{{ s.startTime }}–{{ s.endTime }}</small>
<small v-if="node.settings.showProgress && current?.id === s.id" class="lesson-remaining">
距下课 {{ Math.floor(remaining / 60) }}:{{ String(remaining % 60).padStart(2, '0') }}
</small>
</div>
<span v-if="!lessons.length">暂无课程</span>
<div
v-if="current && node.settings.showProgress && node.settings.display !== 'next'"
class="lesson-progress"
role="progressbar"
:aria-valuenow="Math.round(elapsed * 100)"
:aria-valuemin="0"
:aria-valuemax="100"
:aria-label="`${current.course?.name}课程进度,距下课${remaining}秒`"
>
<i :style="{ transform: `scaleX(${elapsed})` }" />
</div>
</template>
<template v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
<slot v-if="node.id === alertNodeId" name="alerts" />
@@ -197,7 +237,7 @@ const style = computed<CSSProperties>(() => {
.countdown {
white-space: nowrap;
}
.schedule > div {
.schedule > .lesson-line {
display: flex;
flex-direction: row;
align-items: center;
@@ -215,9 +255,29 @@ const style = computed<CSSProperties>(() => {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.schedule > div + div {
.lesson-progress {
position: absolute;
inset: auto 4px 0;
height: 2px;
background: #ffffff24;
overflow: hidden;
}
.lesson-progress i {
display: block;
height: 100%;
background: var(--island-accent, #60a5fa);
transform-origin: left;
transition: transform 1s linear;
}
.schedule > .lesson-line + .lesson-line {
margin-top: 8px;
}
.lesson-remaining {
font-size: var(--secondary, 14px);
font-variant-numeric: tabular-nums;
opacity: 0.8;
white-space: nowrap;
}
.separator > span {
display: block;
width: 1px;
@@ -22,6 +22,7 @@ const props = defineProps<{
editing: boolean;
appearance: IslandAppearance;
schedules: ScheduleDto[];
embedded?: boolean;
}>();
const emit = defineEmits<{
save: [settings: IslandLayoutSettings];
@@ -115,6 +116,10 @@ function switchScheme() {
const now = ref(Date.now()),
canvasScale = ref(1),
pan = ref({ x: 0, y: 0 });
const canvas = ref<HTMLElement>();
const previewStage = ref<HTMLElement>();
const autoScale = ref(1);
const previewScale = computed(() => canvasScale.value * autoScale.value);
const shell = ref<HTMLElement>(),
inner = ref<HTMLElement>();
const viewport = ref({ width: 0, height: 0 });
@@ -358,6 +363,21 @@ let observer: ResizeObserver | undefined,
function measure() {
clearTimeout(measureTimer);
measureTimer = setTimeout(() => {
if (props.embedded && shell.value && canvas.value) {
const padding = getComputedStyle(canvas.value);
const availableWidth =
canvas.value.clientWidth - parseFloat(padding.paddingLeft) - parseFloat(padding.paddingRight);
const availableHeight =
canvas.value.clientHeight - parseFloat(padding.paddingTop) - parseFloat(padding.paddingBottom);
autoScale.value = Math.min(
1,
Math.max(80, availableWidth) /
Math.max(1, previewStage.value?.offsetWidth ?? shell.value.offsetWidth),
Math.max(100, availableHeight) /
Math.max(1, previewStage.value?.offsetHeight ?? shell.value.offsetHeight),
);
return;
}
if (inner.value) {
// 内部组件的自然尺寸决定整个岛体;提醒不再拥有另一层岛壳。
const rect = inner.value.getBoundingClientRect(),
@@ -387,6 +407,8 @@ onMounted(() => {
clock = setInterval(() => (now.value = Date.now()), 1000);
observer = new ResizeObserver(measure);
if (inner.value) observer.observe(inner.value);
if (canvas.value) observer.observe(canvas.value);
if (previewStage.value) observer.observe(previewStage.value);
stopViewport = window.island?.onViewport((v) => (viewport.value = v));
measure();
});
@@ -400,6 +422,7 @@ onUnmounted(() => {
<template>
<div
:class="editing ? 'layout-editor' : 'custom-window'"
:data-embedded="embedded || undefined"
@pointerdown.capture="onPointerDown"
@pointermove="panMove"
@pointerup="
@@ -482,7 +505,7 @@ onUnmounted(() => {
</button>
</div>
</aside>
<main :class="{ canvas: editing }" @pointerdown="panStart">
<main ref="canvas" :class="{ canvas: editing }" @pointerdown="panStart">
<div v-if="editing" class="canvas-toolbar">
<nav class="surface-tabs" aria-label="编辑自定义灵动岛内容">
<button :class="{ active: editingSurface === 'compact' }" @click="setSurface('compact')">
@@ -513,8 +536,9 @@ onUnmounted(() => {
</div>
</div>
<div
ref="previewStage"
class="preview-stage"
:style="editing ? { transform: `translate(${pan.x}px,${pan.y}px) scale(${canvasScale})` } : {}"
:style="editing ? { transform: `translate(${pan.x}px,${pan.y}px) scale(${previewScale})` } : {}"
>
<div
ref="shell"
@@ -254,8 +254,8 @@ function measure() {
}
function resizeCanvas(width = innerWidth, height = innerHeight) {
available.value = {
width: Math.max(40, width - 2 * ISLAND_SHADOW_PAD - 4),
height: Math.max(40, height - 2 * ISLAND_SHADOW_PAD - 4),
width: Math.max(40, Math.min(width, innerWidth) - 2 * ISLAND_SHADOW_PAD - 4),
height: Math.max(40, Math.min(height, innerHeight) - 2 * ISLAND_SHADOW_PAD - 4),
};
measure();
}
@@ -263,6 +263,7 @@ const resize = () => resizeCanvas();
let stopViewport: (() => void) | undefined;
function visibility() {
visible.value = !document.hidden;
resize();
void nextTick(reportHit);
}
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
@@ -285,7 +286,11 @@ watch(
onMounted(() => {
resize();
stopViewport = window.island?.onViewport((v) => resizeCanvas(v.width, v.height));
observer = new ResizeObserver(measure);
observer = new ResizeObserver((entries) => {
if (entries.some((entry) => entry.target === document.documentElement)) resize();
else measure();
});
observer.observe(document.documentElement);
if (compact.value) observer.observe(compact.value);
if (details.value) observer.observe(details.value);
if (lessonSource.value) observer.observe(lessonSource.value);
@@ -327,6 +332,7 @@ onUnmounted(() => {
call: state.active?.kind === 'call',
'resident-separated': scheme.separated && !expanded && !lesson,
'resident-lesson': !!lesson,
'message-surface': expanded && messages.length > 0 && !lesson,
}"
:style="style"
:role="expanded ? 'region' : 'button'"
@@ -389,6 +389,7 @@ const islandClass = computed(() => ({
'hidden-placeholder':
mode.value === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder,
'ci-lesson-card': !!state.value.lessonReminder,
'message-surface': !!state.value.active && mode.value !== 'hidden' && !state.value.lessonReminder,
sliver: mode.value === 'hidden' && appearance.value.idleSliver,
urgent: isUrgent.value,
call: isCall.value,
@@ -1,24 +1,21 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import {
defaultIslandLayouts,
DEFAULT_ISLAND_APPEARANCE,
type IslandAppearance,
type IslandLayoutSettings,
type IslandState,
type ScheduleDto,
} from '@classhelper/shared';
import FixedIsland from './IslandApp.vue';
import CustomIsland from './CustomIsland.vue';
import './custom-island.css';
import CustomIslandRuntime from './CustomIslandRuntime.vue';
const settings = ref(defaultIslandLayouts()),
editing = ref(false),
ready = ref(false);
const schedules = ref<ScheduleDto[]>([]);
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const nativeVisible = ref(false);
const alertHost = shallowRef<HTMLElement | null>(null);
const resident = () => settings.value.layout === 'custom' && !editing.value;
const resident = () => settings.value.layout === 'custom';
const state = ref<IslandState>({
mode: 'hidden',
active: null,
@@ -30,13 +27,6 @@ const state = ref<IslandState>({
maxCardHeight: 1024,
updatedAt: 0,
});
async function save(value: IslandLayoutSettings) {
settings.value = await window.island!.saveLayout(JSON.parse(JSON.stringify(value)));
}
async function done() {
editing.value = false;
await window.island?.setEditing(false);
}
let heartbeat: ReturnType<typeof setInterval>;
onUnmounted(() => clearInterval(heartbeat));
onMounted(async () => {
@@ -44,7 +34,6 @@ onMounted(async () => {
const bridge = window.island;
if (bridge) {
bridge.onLayout((value) => (settings.value = value));
bridge.onEditing((value) => (editing.value = value));
bridge.onSchedules((value) => (schedules.value = value));
bridge.onState((value) => (state.value = value));
bridge.onAppearance((value) => (appearance.value = value));
@@ -54,7 +43,6 @@ onMounted(async () => {
settings.value = await bridge.getLayout();
schedules.value = await bridge.getSchedules();
state.value = await bridge.getState();
editing.value = await bridge.getEditing();
}
await nextTick();
ready.value = true;
@@ -69,26 +57,5 @@ onMounted(async () => {
:appearance="appearance"
:native-visible="nativeVisible"
/>
<CustomIsland
v-else-if="settings.layout === 'custom'"
:settings="settings"
:state="state"
:editing="editing"
:appearance="appearance"
:schedules="schedules"
@save="save"
@done="done"
@host="alertHost = $event"
/>
<Teleport
v-if="ready"
:disabled="resident() || settings.layout === 'fixed' || !alertHost"
:to="alertHost || 'body'"
>
<FixedIsland
v-show="!resident() && (settings.layout === 'fixed' || !!alertHost)"
:embedded="settings.layout === 'custom'"
:suspended="resident()"
/>
</Teleport>
<FixedIsland v-if="ready" v-show="!resident()" :suspended="resident()" />
</template>
@@ -12,17 +12,39 @@ const root = ref<HTMLElement>();
const width = ref(480);
const mask = computed(() => props.now < props.reminder.maskUntil);
const title = computed(
() => ({ prepare: '即将上课', 'on-class': '上课', break: '课间休息' })[props.reminder.kind],
() =>
({
prepare: '即将上课',
'on-class': '上课',
break: '课间休息',
ending: '即将下课',
'in-class': '正在上课',
})[props.reminder.kind],
);
const leftSeconds = computed(() =>
Math.max(0, Math.ceil((props.reminder.lessonStartsAt - props.now) / 1000)),
Math.max(
0,
Math.ceil(
((props.reminder.kind === 'ending' || props.reminder.kind === 'in-class'
? props.reminder.lessonEndsAt
: props.reminder.lessonStartsAt)! -
props.now) /
1000,
),
),
);
const progress = computed(() =>
Math.max(
0,
Math.min(
1,
(props.reminder.endsAt - props.now) / Math.max(1, props.reminder.endsAt - props.reminder.startedAt),
props.reminder.kind === 'in-class' || props.reminder.kind === 'ending'
? (props.now - props.reminder.lessonStartsAt) /
Math.max(
1,
(props.reminder.lessonEndsAt ?? props.reminder.endsAt) - props.reminder.lessonStartsAt,
)
: (props.reminder.endsAt - props.now) / Math.max(1, props.reminder.endsAt - props.reminder.startedAt),
),
),
);
@@ -107,19 +129,36 @@ onUnmounted(() => {
aria-live="polite"
>
<div class="ci-lesson-info">
<span v-if="reminder.kind === 'prepare'" class="ci-countdown">
距上课还剩
<span
v-if="reminder.kind === 'prepare' || reminder.kind === 'ending' || reminder.kind === 'in-class'"
class="ci-countdown"
>
{{ reminder.kind === 'prepare' ? '距上课还剩' : '距下课还剩' }}
<strong>{{ leftSeconds }}</strong>
秒
</span>
<span v-else class="ci-countdown">课间休息</span>
<span class="ci-lesson-course">
<span>下节课是:</span>
<span>
{{
reminder.kind === 'ending' || reminder.kind === 'on-class' || reminder.kind === 'in-class'
? '本节课:'
: '下节课是:'
}}
</span>
<strong>{{ course }}</strong>
<small>{{ time }}</small>
</span>
</div>
<div class="ci-lesson-progress" :style="{ transform: `scaleX(${progress})` }" />
<div
class="ci-lesson-progress"
role="progressbar"
:aria-valuenow="Math.round(progress * 100)"
aria-valuemin="0"
aria-valuemax="100"
aria-label="课程与提醒进度"
:style="{ transform: `scaleX(${progress})` }"
/>
<svg class="ci-mask" :viewBox="`0 0 ${width} 40`" preserveAspectRatio="none" aria-hidden="true">
<polygon v-for="(points, i) in polygons" :key="i" :points="points" />
</svg>
@@ -80,6 +80,10 @@ body {
--wn-bg: #0b0b0f;
--wn-border: rgba(255, 255, 255, 0.14);
}
/* 阅读老师消息时使用实色底,玻璃风格也不透出桌面。 */
.island-card.message-surface {
--wn-bg: #202020;
}
.island-card.hidden-placeholder {
visibility: hidden;
@@ -250,3 +250,6 @@
fill: transparent;
stroke: none;
}
.resident-window .resident-card.message-surface {
--wn-bg: #202020;
}