feat(ui): 补齐课程提醒进度并内嵌编辑器与重构后台导航
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user