feat(client): 实现可编辑岛组件方案与连接更新状态反馈
This commit is contained in:
1 parent
35b252249d
commit
b990701cde
26 files changed
+1813
-74
No files matched your search
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type CSSProperties } from 'vue';
|
||||
import {
|
||||
ISLAND_COMPONENT_LABELS,
|
||||
type IslandComponentNode,
|
||||
type IslandState,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
const props = defineProps<{
|
||||
node: IslandComponentNode;
|
||||
state: IslandState;
|
||||
now: number;
|
||||
schedules: ScheduleDto[];
|
||||
editing: boolean;
|
||||
selectedId: string;
|
||||
alertNodeId: string;
|
||||
parentHasWidth?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ select: [id: string]; drag: [event: PointerEvent, id: string] }>();
|
||||
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 hidden = computed(
|
||||
() =>
|
||||
!props.editing &&
|
||||
props.node.advanced.hideRules.some(
|
||||
(r) =>
|
||||
(r === 'in-class' && props.state.inClass) ||
|
||||
(r === 'no-message' && !props.state.active) ||
|
||||
(r === 'no-class' && !current.value),
|
||||
),
|
||||
);
|
||||
const visibleChildren = computed(() =>
|
||||
props.node.kind === 'slide'
|
||||
? props.node.children.filter(
|
||||
(_, i) =>
|
||||
i ===
|
||||
Math.floor(props.now / (props.node.settings.interval * 1000)) %
|
||||
Math.max(1, props.node.children.length),
|
||||
)
|
||||
: props.node.children,
|
||||
);
|
||||
const content = computed(() => {
|
||||
const n = props.node,
|
||||
d = new Date(props.now);
|
||||
if (n.kind === 'date')
|
||||
return d.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' });
|
||||
if (n.kind === 'clock')
|
||||
return d.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...(n.settings.seconds ? { second: '2-digit' } : {}),
|
||||
hour12: n.settings.hour12,
|
||||
});
|
||||
if (n.kind === 'text')
|
||||
return n.settings.template
|
||||
? n.settings.text
|
||||
.replaceAll('{date}', d.toLocaleDateString('zh-CN'))
|
||||
.replaceAll('{time}', d.toLocaleTimeString('zh-CN'))
|
||||
.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;
|
||||
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 '';
|
||||
});
|
||||
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),
|
||||
),
|
||||
);
|
||||
const style = computed<CSSProperties>(() => {
|
||||
const n = props.node,
|
||||
a = n.appearance,
|
||||
override = a.resourceOverridingEnabled;
|
||||
const color =
|
||||
a.background +
|
||||
Math.round((a.customBackgroundOpacityEnabled ? a.backgroundOpacity : 1) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return {
|
||||
opacity: a.opacity,
|
||||
width: n.advanced.width ? `${n.advanced.width}px` : undefined,
|
||||
maxWidth: n.advanced.maxWidth ? `${n.advanced.maxWidth}px` : undefined,
|
||||
alignSelf: !props.parentHasWidth
|
||||
? 'stretch'
|
||||
: ({ stretch: 'stretch', start: 'flex-start', center: 'center', end: 'flex-end' } as const)[
|
||||
n.advanced.align
|
||||
],
|
||||
textAlign: n.advanced.align === 'center' ? 'center' : 'start',
|
||||
color: override && a.customForegroundEnabled ? a.foreground : undefined,
|
||||
background: override && a.customBackgroundEnabled ? color : undefined,
|
||||
borderRadius: override && a.customCornerRadiusEnabled ? `${a.cornerRadius}px` : undefined,
|
||||
fontSize: override ? `${a.bodyFontSize}px` : undefined,
|
||||
'--secondary': override ? `${a.secondaryFontSize}px` : undefined,
|
||||
'--emphasized': override ? `${a.emphasizedFontSize}px` : undefined,
|
||||
'--large': override ? `${a.largeFontSize}px` : undefined,
|
||||
'--rolling-duration': `${Math.max(1, 200 / n.settings.speed)}s`,
|
||||
flexDirection: n.settings.direction === 'vertical' ? 'column' : 'row',
|
||||
gap: `${n.settings.gap}px`,
|
||||
display: container.value ? 'flex' : undefined,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="!hidden"
|
||||
class="component-node"
|
||||
:class="[{ selected: editing && selectedId === node.id, container }, node.kind]"
|
||||
:style="style"
|
||||
:data-node="node.id"
|
||||
@click.stop="editing && emit('select', node.id)"
|
||||
>
|
||||
<button
|
||||
v-if="editing"
|
||||
class="node-handle"
|
||||
:data-kind="node.kind"
|
||||
aria-label="拖动组件"
|
||||
@pointerdown.stop="emit('drag', $event, node.id)"
|
||||
>
|
||||
⠿ {{ ISLAND_COMPONENT_LABELS[node.kind] }}
|
||||
</button>
|
||||
<template v-if="container">
|
||||
<ComponentNode
|
||||
v-for="child in visibleChildren"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:state="state"
|
||||
:now="now"
|
||||
:schedules="schedules"
|
||||
:editing="editing"
|
||||
:selected-id="selectedId"
|
||||
:alert-node-id="alertNodeId"
|
||||
:parent-has-width="Boolean(node.advanced.width || node.advanced.maxWidth)"
|
||||
@select="emit('select', $event)"
|
||||
@drag="(e, id) => emit('drag', e, id)"
|
||||
>
|
||||
<template #alerts><slot name="alerts" /></template>
|
||||
</ComponentNode>
|
||||
</template>
|
||||
<template v-else-if="node.kind === 'separator'"><span aria-hidden="true">│</span></template>
|
||||
<template v-else-if="node.kind === 'schedule'">
|
||||
<div v-for="(s, i) in lessons" :key="s.id">
|
||||
<small v-if="node.settings.showPeriod">第 {{ i + 1 }} 节 · {{ s.startTime }}</small>
|
||||
<strong>{{ s.course?.name }}</strong>
|
||||
</div>
|
||||
<span v-if="!lessons.length">暂无课程</span>
|
||||
</template>
|
||||
<template v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
|
||||
<slot v-if="node.id === alertNodeId" name="alerts" />
|
||||
<span v-else-if="editing">{{ ISLAND_COMPONENT_LABELS[node.kind] }}内容组件</span>
|
||||
</template>
|
||||
<span v-else :class="{ large: node.kind === 'clock', emphasized: node.kind === 'countdown' }">
|
||||
{{ content }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.component-node {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 6px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.container {
|
||||
align-items: center;
|
||||
}
|
||||
.stack {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rolling {
|
||||
overflow: hidden;
|
||||
}
|
||||
.rolling > :deep(.component-node) {
|
||||
animation: rolling var(--rolling-duration) linear infinite;
|
||||
}
|
||||
.selected {
|
||||
outline: 2px solid var(--ch-accent, #5b9cff);
|
||||
}
|
||||
.node-handle {
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
display: block;
|
||||
font: inherit;
|
||||
padding: 6px;
|
||||
}
|
||||
small {
|
||||
font-size: var(--secondary, 12px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
strong,
|
||||
.emphasized {
|
||||
font-size: var(--emphasized, 20px);
|
||||
}
|
||||
.large {
|
||||
font-size: var(--large, 28px);
|
||||
}
|
||||
@keyframes rolling {
|
||||
from {
|
||||
transform: translateX(30%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-80%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,755 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from 'vue';
|
||||
import {
|
||||
ISLAND_COMPONENTS,
|
||||
ISLAND_COMPONENT_LABELS,
|
||||
ISLAND_SHADOW_PAD,
|
||||
islandNode,
|
||||
normalizeIslandLayouts,
|
||||
type IslandLayoutSettings,
|
||||
type IslandComponentKind,
|
||||
type IslandComponentNode,
|
||||
type IslandState,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import ComponentNode from './ComponentNode.vue';
|
||||
const props = defineProps<{
|
||||
settings: IslandLayoutSettings;
|
||||
state: IslandState;
|
||||
editing: boolean;
|
||||
schedules: ScheduleDto[];
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
save: [settings: IslandLayoutSettings];
|
||||
done: [];
|
||||
host: [element: HTMLElement | null];
|
||||
}>();
|
||||
function hostRef(element: unknown) {
|
||||
emit('host', element instanceof HTMLElement ? element : null);
|
||||
}
|
||||
const draft = ref(normalizeIslandLayouts(props.settings));
|
||||
watch(
|
||||
() => props.settings,
|
||||
(s) => {
|
||||
draft.value = normalizeIslandLayouts(s);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
const scheme = computed(() => draft.value.schemes.find((s) => s.id === draft.value.activeSchemeId)!);
|
||||
const selectedId = ref(''),
|
||||
selectedKind = ref<IslandComponentKind>('clock');
|
||||
const now = ref(Date.now()),
|
||||
canvasScale = ref(1),
|
||||
pan = ref({ x: 0, y: 0 });
|
||||
const shell = ref<HTMLElement>(),
|
||||
inner = ref<HTMLElement>();
|
||||
const viewport = ref({ width: 0, height: 0 });
|
||||
function allNodes(nodes: IslandComponentNode[]): IslandComponentNode[] {
|
||||
return nodes.flatMap((n) => [n, ...allNodes(n.children)]);
|
||||
}
|
||||
const selected = computed(() =>
|
||||
scheme.value.rows.flatMap((r) => allNodes(r.nodes)).find((n) => n.id === selectedId.value),
|
||||
);
|
||||
const alertRow = computed(
|
||||
() =>
|
||||
scheme.value.rows.find((r) => r.main && r.allowNotifications) ||
|
||||
scheme.value.rows.find((r) => r.allowNotifications),
|
||||
);
|
||||
const alertNodeId = computed(() => {
|
||||
const kind = props.state.active?.kind ?? 'notification';
|
||||
return (
|
||||
(alertRow.value &&
|
||||
allNodes(alertRow.value.nodes).find((n) => n.kind === kind || n.kind === 'notification')?.id) ||
|
||||
''
|
||||
);
|
||||
});
|
||||
const hasMessage = computed(() => props.state.mode !== 'hidden' && Boolean(props.state.active));
|
||||
const shellStyle = computed<CSSProperties>(() => ({
|
||||
width: viewport.value.width ? `${viewport.value.width}px` : 'max-content',
|
||||
minHeight: viewport.value.height ? `${viewport.value.height}px` : undefined,
|
||||
maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`,
|
||||
maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`,
|
||||
color: scheme.value.foreground,
|
||||
fontSize: '16px',
|
||||
'--custom-color':
|
||||
scheme.value.color +
|
||||
Math.round(scheme.value.opacity * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0'),
|
||||
'--custom-radius': `${scheme.value.radius}px`,
|
||||
gap: `${scheme.value.spacing}px`,
|
||||
}));
|
||||
function id() {
|
||||
return `node-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
function save() {
|
||||
const value = normalizeIslandLayouts(JSON.parse(JSON.stringify(draft.value)));
|
||||
draft.value = value;
|
||||
emit('save', value);
|
||||
}
|
||||
function addRow(after: number) {
|
||||
scheme.value.rows.splice(after + 1, 0, {
|
||||
id: id(),
|
||||
name: '新主界面行',
|
||||
main: false,
|
||||
allowNotifications: true,
|
||||
gap: 8,
|
||||
nodes: [],
|
||||
});
|
||||
save();
|
||||
}
|
||||
function add(kind = selectedKind.value, row = scheme.value.rows[0]) {
|
||||
if (!row) return;
|
||||
const n = islandNode(kind, id());
|
||||
row.nodes.push(n);
|
||||
selectedId.value = n.id;
|
||||
save();
|
||||
}
|
||||
function findList(nodeId: string, nodes?: IslandComponentNode[]): IslandComponentNode[] | null {
|
||||
for (const list of nodes ? [nodes] : scheme.value.rows.map((r) => r.nodes)) {
|
||||
if (list.some((n) => n.id === nodeId)) return list;
|
||||
for (const n of list) {
|
||||
const found = findList(nodeId, n.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function remove() {
|
||||
const list = findList(selectedId.value);
|
||||
if (list)
|
||||
list.splice(
|
||||
list.findIndex((n) => n.id === selectedId.value),
|
||||
1,
|
||||
);
|
||||
selectedId.value = '';
|
||||
save();
|
||||
}
|
||||
function duplicate() {
|
||||
const n = selected.value,
|
||||
list = findList(selectedId.value);
|
||||
if (!n || !list) return;
|
||||
const copy = JSON.parse(JSON.stringify(n)) as IslandComponentNode;
|
||||
for (const child of allNodes([copy])) child.id = id();
|
||||
list.splice(list.indexOf(n) + 1, 0, copy);
|
||||
selectedId.value = copy.id;
|
||||
save();
|
||||
}
|
||||
function wrap(kind: IslandComponentKind) {
|
||||
if (!['slide', 'rolling', 'group', 'stack'].includes(kind)) return;
|
||||
const n = selected.value,
|
||||
list = findList(selectedId.value);
|
||||
if (!n || !list) return;
|
||||
const container = islandNode(kind, id());
|
||||
container.children = [n];
|
||||
list.splice(list.indexOf(n), 1, container);
|
||||
selectedId.value = container.id;
|
||||
save();
|
||||
}
|
||||
function newScheme() {
|
||||
const name = `新方案 ${draft.value.schemes.length + 1}`;
|
||||
const copy = JSON.parse(JSON.stringify(scheme.value));
|
||||
copy.id = id();
|
||||
copy.name = name;
|
||||
for (const r of copy.rows) {
|
||||
r.id = id();
|
||||
for (const n of allNodes(r.nodes)) n.id = id();
|
||||
}
|
||||
draft.value.schemes.push(copy);
|
||||
draft.value.activeSchemeId = copy.id;
|
||||
save();
|
||||
}
|
||||
function exportScheme() {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify({ format: 'classhelper-island-layout', scheme: scheme.value }, null, 2)], {
|
||||
type: 'application/json',
|
||||
}),
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${scheme.value.name}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
const error = ref('');
|
||||
async function importScheme(event: Event) {
|
||||
try {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file || file.size > 1_000_000) throw new Error('请选择小于 1MB 的 JSON');
|
||||
const parsed = JSON.parse(await file.text());
|
||||
if (parsed.format !== 'classhelper-island-layout') throw new Error('方案格式不正确');
|
||||
const value = normalizeIslandLayouts({
|
||||
layout: 'custom',
|
||||
activeSchemeId: parsed.scheme.id,
|
||||
schemes: [parsed.scheme],
|
||||
});
|
||||
const s = value.schemes[0]!;
|
||||
s.id = id();
|
||||
draft.value.schemes.push(s);
|
||||
draft.value.activeSchemeId = s.id;
|
||||
save();
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
}
|
||||
}
|
||||
let drag: {
|
||||
type: 'node' | 'library' | 'row';
|
||||
id: string;
|
||||
kind?: IslandComponentKind;
|
||||
rowId?: string;
|
||||
targetId?: string;
|
||||
} | null = null;
|
||||
function startDrag(
|
||||
event: PointerEvent,
|
||||
type: 'node' | 'library' | 'row',
|
||||
nodeId: string,
|
||||
kind?: IslandComponentKind,
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
/* 合成事件无活动指针;真实触摸使用捕获 */
|
||||
}
|
||||
drag = { type, id: nodeId, kind };
|
||||
}
|
||||
function move(event: PointerEvent) {
|
||||
if (!drag) return;
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
drag.rowId = element?.closest<HTMLElement>('[data-row]')?.dataset.row;
|
||||
drag.targetId = element?.closest<HTMLElement>('[data-node]')?.dataset.node;
|
||||
}
|
||||
function drop() {
|
||||
if (!drag) return;
|
||||
const moving = drag;
|
||||
drag = null;
|
||||
const row = scheme.value.rows.find((r) => r.id === moving.rowId);
|
||||
if (!row) return;
|
||||
if (moving.type === 'library') {
|
||||
add(moving.kind, row);
|
||||
return;
|
||||
}
|
||||
if (moving.type === 'row') {
|
||||
const from = scheme.value.rows.findIndex((r) => r.id === moving.id),
|
||||
to = scheme.value.rows.indexOf(row);
|
||||
if (from >= 0) scheme.value.rows.splice(to, 0, scheme.value.rows.splice(from, 1)[0]!);
|
||||
save();
|
||||
return;
|
||||
}
|
||||
const list = findList(moving.id),
|
||||
node = list?.find((n) => n.id === moving.id);
|
||||
if (!list || !node || allNodes([node]).some((n) => n.id === moving.targetId)) return;
|
||||
const target = allNodes(row.nodes).find((n) => n.id === moving.targetId);
|
||||
list.splice(list.indexOf(node), 1);
|
||||
if (target && ['group', 'stack', 'slide', 'rolling'].includes(target.kind)) target.children.push(node);
|
||||
else if (target) {
|
||||
const targetList = findList(target.id)!;
|
||||
targetList.splice(targetList.indexOf(target), 0, node);
|
||||
} else row.nodes.push(node);
|
||||
selectedId.value = node.id;
|
||||
save();
|
||||
}
|
||||
let panning: { x: number; y: number; originX: number; originY: number } | null = null;
|
||||
function panStart(e: PointerEvent) {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
panning = { x: e.clientX, y: e.clientY, originX: pan.value.x, originY: pan.value.y };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
function panMove(e: PointerEvent) {
|
||||
if (panning)
|
||||
pan.value = { x: panning.originX + e.clientX - panning.x, y: panning.originY + e.clientY - panning.y };
|
||||
move(e);
|
||||
}
|
||||
let observer: ResizeObserver | undefined,
|
||||
measureTimer: ReturnType<typeof setTimeout> | undefined,
|
||||
clock: ReturnType<typeof setInterval>;
|
||||
function measure() {
|
||||
clearTimeout(measureTimer);
|
||||
measureTimer = setTimeout(() => {
|
||||
if (inner.value) {
|
||||
const rect = inner.value.getBoundingClientRect(),
|
||||
scale = props.editing ? canvasScale.value : 1;
|
||||
window.island?.reportSize({
|
||||
width: Math.ceil(rect.width / scale + 24),
|
||||
height: Math.ceil(rect.height / scale + 24),
|
||||
});
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
watch([() => props.editing, scheme, () => props.state], () => void nextTick(measure), { deep: true });
|
||||
onMounted(() => {
|
||||
clock = setInterval(() => (now.value = Date.now()), 1000);
|
||||
observer = new ResizeObserver(measure);
|
||||
if (inner.value) observer.observe(inner.value);
|
||||
window.island?.onViewport((v) => (viewport.value = v));
|
||||
measure();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
clearInterval(clock);
|
||||
clearTimeout(measureTimer);
|
||||
observer?.disconnect();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="editing ? 'layout-editor' : 'custom-window'"
|
||||
@pointermove="panMove"
|
||||
@pointerup="
|
||||
drop();
|
||||
panning = null;
|
||||
"
|
||||
@pointercancel="
|
||||
drag = null;
|
||||
panning = null;
|
||||
"
|
||||
>
|
||||
<header v-if="editing">
|
||||
<strong>编辑灵动岛</strong>
|
||||
<select
|
||||
v-model="draft.activeSchemeId"
|
||||
@change="
|
||||
selectedId = '';
|
||||
save();
|
||||
"
|
||||
>
|
||||
<option v-for="s in draft.schemes" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
<button @click="newScheme">新方案</button>
|
||||
<button @click="exportScheme">导出</button>
|
||||
<label>
|
||||
导入
|
||||
<input type="file" accept="application/json" @change="importScheme" />
|
||||
</label>
|
||||
<button
|
||||
@click="
|
||||
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="
|
||||
save();
|
||||
emit('done');
|
||||
"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</header>
|
||||
<aside v-if="editing" class="library">
|
||||
<h3>组件库</h3>
|
||||
<button
|
||||
v-for="kind in ISLAND_COMPONENTS"
|
||||
:key="kind"
|
||||
@pointerdown="startDrag($event, 'library', '', kind)"
|
||||
@click="selectedKind = kind"
|
||||
>
|
||||
{{ ISLAND_COMPONENT_LABELS[kind as IslandComponentKind] }}
|
||||
</button>
|
||||
<select v-model="selectedKind">
|
||||
<option v-for="kind in ISLAND_COMPONENTS" :key="kind" :value="kind">
|
||||
{{ ISLAND_COMPONENT_LABELS[kind as IslandComponentKind] }}
|
||||
</option>
|
||||
</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>
|
||||
</aside>
|
||||
<main :class="{ canvas: editing }" @pointerdown="panStart">
|
||||
<div
|
||||
ref="shell"
|
||||
class="custom-card"
|
||||
:class="{ separated: scheme.separated }"
|
||||
:style="[
|
||||
shellStyle,
|
||||
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 }">
|
||||
<section
|
||||
v-for="(row, i) in scheme.rows"
|
||||
:key="row.id"
|
||||
:data-row="row.id"
|
||||
class="layout-row"
|
||||
:style="{ gap: `${row.gap}px` }"
|
||||
>
|
||||
<div v-if="editing" class="row-tools">
|
||||
<button @pointerdown="startDrag($event, 'row', row.id)">⠿ 拖动行</button>
|
||||
<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>
|
||||
<label>
|
||||
<input type="checkbox" v-model="row.allowNotifications" @change="save" />
|
||||
允许通知
|
||||
</label>
|
||||
<button
|
||||
:disabled="scheme.rows.length < 2"
|
||||
@click="
|
||||
scheme.rows.splice(i, 1);
|
||||
save();
|
||||
"
|
||||
>
|
||||
删除行
|
||||
</button>
|
||||
</div>
|
||||
<div class="row-content">
|
||||
<ComponentNode
|
||||
v-for="n in row.nodes"
|
||||
:key="n.id"
|
||||
:node="n"
|
||||
:state="state"
|
||||
:now="now"
|
||||
:schedules="schedules"
|
||||
:editing="editing"
|
||||
:selected-id="selectedId"
|
||||
:alert-node-id="alertNodeId"
|
||||
@select="selectedId = $event"
|
||||
@drag="(e, id) => startDrag(e, 'node', id)"
|
||||
>
|
||||
<template #alerts><div id="custom-alert-host" :ref="hostRef" v-show="hasMessage" /></template>
|
||||
</ComponentNode>
|
||||
<div
|
||||
v-if="row.id === alertRow?.id && !alertNodeId"
|
||||
id="custom-alert-host"
|
||||
:ref="hostRef"
|
||||
v-show="hasMessage"
|
||||
/>
|
||||
</div>
|
||||
<button v-if="editing" @click="addRow(i)">在下方新建主界面行</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div v-if="!alertRow" id="custom-alert-host" :ref="hostRef" style="display: none" />
|
||||
<aside v-if="editing" class="settings">
|
||||
<template v-if="selected">
|
||||
<h3>{{ ISLAND_COMPONENT_LABELS[selected.kind] }} 组件设置</h3>
|
||||
<button @click="remove">删除组件</button>
|
||||
<button @click="duplicate">创建副本</button>
|
||||
<label>
|
||||
包裹到新容器
|
||||
<select @change="wrap(($event.target as HTMLSelectElement).value as IslandComponentKind)">
|
||||
<option value="">选择容器</option>
|
||||
<option v-for="kind in ['slide', 'rolling', 'group', 'stack']" :key="kind" :value="kind">
|
||||
{{ ISLAND_COMPONENT_LABELS[kind as IslandComponentKind] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="selected.kind === 'clock'">
|
||||
<label>
|
||||
<input type="checkbox" v-model="selected.settings.hour12" @change="save" />
|
||||
12 小时制
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="selected.settings.seconds" @change="save" />
|
||||
显示秒
|
||||
</label>
|
||||
</template>
|
||||
<label v-if="selected.kind === 'countdown'">
|
||||
倒计时目标
|
||||
<select v-model="selected.settings.target" @change="save">
|
||||
<option value="next">下一节课</option>
|
||||
<option value="school">放学</option>
|
||||
</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"
|
||||
>
|
||||
{{
|
||||
{
|
||||
secondaryFontSize: '次级字号',
|
||||
bodyFontSize: '正文字号',
|
||||
emphasizedFontSize: '强调字号',
|
||||
largeFontSize: '大号字号',
|
||||
}[font]
|
||||
}}
|
||||
<input type="number" v-model.number="selected.appearance[font]" @change="save" />
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="selected.appearance.customForegroundEnabled" @change="save" />
|
||||
自定义前景色
|
||||
<input type="color" v-model="selected.appearance.foreground" @change="save" />
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="selected.appearance.customBackgroundEnabled" @change="save" />
|
||||
自定义背景色
|
||||
<input type="color" v-model="selected.appearance.background" @change="save" />
|
||||
</label>
|
||||
<label>
|
||||
<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>
|
||||
<p v-else>点选组件后显示它独立的设置。</p>
|
||||
<p role="alert">{{ error }}</p>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.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>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ embedded?: boolean }>();
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
@@ -493,6 +494,7 @@ let hitRectTrailingTimer: number | null = null;
|
||||
|
||||
/** 把岛体矩形上报给主进程:主进程按光标位置兜底校正命中(不依赖 mousemove 转发) */
|
||||
function reportHitRect(rect: DOMRect | null): void {
|
||||
if (props.embedded) return;
|
||||
const payload = rect ? { x: rect.left, y: rect.top, width: rect.width, height: rect.height } : null;
|
||||
const key = payload
|
||||
? `${Math.round(payload.x)},${Math.round(payload.y)},${Math.round(payload.width)},${Math.round(payload.height)}`
|
||||
@@ -534,6 +536,7 @@ function reportHitRect(rect: DOMRect | null): void {
|
||||
* 用户点下去时窗口恰好处于穿透态,表现就是**点了没反应**(实测日志里 true/false 每几毫秒翻转一次)。
|
||||
*/
|
||||
function updateInteractive(clientX: number, clientY: number): void {
|
||||
if (props.embedded) return;
|
||||
lastPointer = { x: clientX, y: clientY };
|
||||
const node = document.querySelector('.island-card') as HTMLElement | null;
|
||||
let inside = false;
|
||||
@@ -639,7 +642,11 @@ onMounted(async () => {
|
||||
固定包围盒窗口内的**单个**灵动岛(照搬 WinIsland):
|
||||
岛的宽高/圆角由弹簧逐帧逼近,窗口本身不动;胶囊层与展开层在里面交叉淡入。
|
||||
-->
|
||||
<div class="island-root">
|
||||
<div
|
||||
class="island-root"
|
||||
:class="{ embedded: props.embedded }"
|
||||
:style="props.embedded ? { width: `${morph.width}px`, height: `${morph.height}px` } : undefined"
|
||||
>
|
||||
<div
|
||||
class="island-card"
|
||||
:class="islandClass"
|
||||
@@ -896,6 +903,14 @@ body {
|
||||
|
||||
/* ---------------------------------------------------------------- 卡片与形状 */
|
||||
|
||||
.island-root.embedded .island-card {
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.island-card {
|
||||
position: absolute;
|
||||
display: block;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref, shallowRef } from 'vue';
|
||||
import {
|
||||
defaultIslandLayouts,
|
||||
type IslandLayoutSettings,
|
||||
type IslandState,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import FixedIsland from './IslandApp.vue';
|
||||
import CustomIsland from './CustomIsland.vue';
|
||||
const settings = ref(defaultIslandLayouts()),
|
||||
editing = ref(false),
|
||||
ready = ref(false);
|
||||
const schedules = ref<ScheduleDto[]>([]);
|
||||
const alertHost = shallowRef<HTMLElement | null>(null);
|
||||
const state = ref<IslandState>({
|
||||
mode: 'hidden',
|
||||
active: null,
|
||||
queued: [],
|
||||
inClass: false,
|
||||
currentPeriodEnd: null,
|
||||
reason: null,
|
||||
listExpanded: false,
|
||||
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);
|
||||
}
|
||||
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));
|
||||
settings.value = await bridge.getLayout();
|
||||
state.value = await bridge.getState();
|
||||
editing.value = await bridge.getEditing();
|
||||
}
|
||||
await nextTick();
|
||||
ready.value = true;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<CustomIsland
|
||||
v-if="settings.layout === 'custom'"
|
||||
:settings="settings"
|
||||
:state="state"
|
||||
:editing="editing"
|
||||
:schedules="schedules"
|
||||
@save="save"
|
||||
@done="done"
|
||||
@host="alertHost = $event"
|
||||
/>
|
||||
<Teleport v-if="ready" :disabled="settings.layout === 'fixed' || !alertHost" :to="alertHost || 'body'">
|
||||
<FixedIsland
|
||||
v-show="settings.layout === 'fixed' || !!alertHost"
|
||||
:embedded="settings.layout === 'custom'"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import IslandApp from './IslandApp.vue';
|
||||
import IslandApp from './LayoutIslandApp.vue';
|
||||
import { applyMotionTokens } from '../renderer/composables/motion.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReleaseProgressDto as ContractReleaseProgressDto } from '@classhelper/shared';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
@@ -85,6 +86,29 @@ export function pendingUpdateDir(): string {
|
||||
let mainWindowHiddenAt: number | null = null;
|
||||
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
|
||||
let installing = false;
|
||||
let updateProgress: ContractReleaseProgressDto = {
|
||||
version: '',
|
||||
stage: 'idle',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
percent: null,
|
||||
error: '',
|
||||
};
|
||||
export function getUpdateProgress() {
|
||||
return { ...updateProgress };
|
||||
}
|
||||
let progressPublishedAt = 0;
|
||||
function publishProgress(patch: Partial<typeof updateProgress>) {
|
||||
const previousStage = updateProgress.stage;
|
||||
updateProgress = { ...updateProgress, ...patch };
|
||||
updateProgress.percent = updateProgress.totalBytes
|
||||
? Math.min(100, Math.round((updateProgress.downloadedBytes / updateProgress.totalBytes) * 100))
|
||||
: null;
|
||||
if (previousStage === updateProgress.stage && Date.now() - progressPublishedAt < 250) return;
|
||||
progressPublishedAt = Date.now();
|
||||
for (const win of BrowserWindow.getAllWindows())
|
||||
if (!win.isDestroyed()) win.webContents.send('client:update-progress', getUpdateProgress());
|
||||
}
|
||||
/**
|
||||
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
|
||||
*
|
||||
@@ -187,6 +211,7 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
|
||||
}
|
||||
|
||||
downloading.add(version);
|
||||
publishProgress({ version, stage: 'downloading', downloadedBytes: 0, totalBytes: info.size, error: '' });
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -211,20 +236,24 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
|
||||
try {
|
||||
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
await handle.write(chunk);
|
||||
publishProgress({ downloadedBytes: updateProgress.downloadedBytes + chunk.byteLength });
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
publishProgress({ stage: 'verifying' });
|
||||
const digest = await sha256File(temp);
|
||||
const expected = info.sha256.trim().toLowerCase();
|
||||
if (!expected) {
|
||||
// 服务端没给哈希就没法校验:宁可不装,也不留一个来源不明的安装包在磁盘上
|
||||
await fsp.rm(temp, { force: true });
|
||||
publishProgress({ stage: 'failed', error: '服务端未提供 sha256,拒绝更新' });
|
||||
logger.error(`更新 ${version} 的安装包没有提供 sha256,已删除(不做信任放行)`);
|
||||
return;
|
||||
}
|
||||
if (digest !== expected) {
|
||||
publishProgress({ stage: 'failed', error: '安装包 sha256 校验失败' });
|
||||
await fsp.rm(temp, { force: true });
|
||||
logger.error(
|
||||
`更新 ${version} 的安装包校验失败(sha256 不符),已删除:期望 ${expected},实际 ${digest}`,
|
||||
@@ -234,6 +263,7 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
|
||||
|
||||
await fsp.rm(target, { force: true });
|
||||
await fsp.rename(temp, target);
|
||||
publishProgress({ stage: 'waiting' });
|
||||
if (loadPolicy()?.updateTargetVersion !== version) {
|
||||
removeManagedInstaller(target);
|
||||
return;
|
||||
@@ -247,6 +277,7 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
|
||||
await maybeInstallPendingUpdate();
|
||||
} catch (error) {
|
||||
await fsp.rm(temp, { force: true }).catch(() => undefined);
|
||||
publishProgress({ stage: 'failed', error: String(error) });
|
||||
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
downloading.delete(version);
|
||||
@@ -342,6 +373,7 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
|
||||
}
|
||||
|
||||
installing = true;
|
||||
publishProgress({ stage: 'installing' });
|
||||
logger.info(`开始静默安装更新 ${version}:${installer}`);
|
||||
try {
|
||||
const exe = app.getPath('exe');
|
||||
|
||||
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { BrowserWindow, app, dialog } from 'electron';
|
||||
import { BrowserWindow, app } from 'electron';
|
||||
import {
|
||||
CLIENT_COMMAND_LABELS,
|
||||
CLIENT_HEARTBEAT_INTERVAL_MS,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ClientUpdateInfoDto,
|
||||
} from '@classhelper/shared';
|
||||
import { maybeInstallPendingUpdate, considerUpdate, syncUpdatePolicy } from './auto-update.js';
|
||||
import { connectionEvents, type ConnectionEvent } from './config-events.js';
|
||||
import { getConfig } from './config.js';
|
||||
import { island } from './island.js';
|
||||
import { logger } from './logger.js';
|
||||
@@ -45,14 +46,6 @@ const UNAUTHORIZED_BACKOFF_MS = 5 * 60_000;
|
||||
/** 单次上报的超时:教室机网络慢,但也不能挂着不放 */
|
||||
const HEARTBEAT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* 远控命令的「本机二次确认」等待时长:60 秒内没人点确认就按取消处理。
|
||||
*
|
||||
* 为什么必须有这个超时:管理员在办公室下发关机,而教室里可能已经没人了 ——
|
||||
* 一个永远挂着等确认的对话框会让这条命令一直"待执行",既没执行也看不出失败。
|
||||
*/
|
||||
const COMMAND_CONFIRM_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 「在线」判定窗口:与 shared 的 `CLIENT_ONLINE_WINDOW_MS`(90 秒)同口径 ——
|
||||
* 连续丢两次心跳(30 秒 × 3)才算离线。这里不复用那个常量是因为它属于**服务端**的判定口径,
|
||||
@@ -171,6 +164,7 @@ async function beat(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const epoch = connectionEpoch;
|
||||
state.beating = true;
|
||||
let backoff = false;
|
||||
try {
|
||||
@@ -194,6 +188,7 @@ async function beat(): Promise<void> {
|
||||
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (epoch !== connectionEpoch) return;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// 令牌失效:退避到 5 分钟一次,直到用户重新登录
|
||||
backoff = true;
|
||||
@@ -209,6 +204,7 @@ async function beat(): Promise<void> {
|
||||
|
||||
const envelope = (await response.json()) as ApiResponse<ClientHeartbeatResponse>;
|
||||
if (!envelope.success || !envelope.data?.deviceId) throw new Error('心跳响应格式不正确');
|
||||
if (epoch !== connectionEpoch) return;
|
||||
const payload = envelope.data;
|
||||
state.lastHeartbeatAt = new Date().toISOString();
|
||||
state.lastError = '';
|
||||
@@ -226,13 +222,16 @@ async function beat(): Promise<void> {
|
||||
);
|
||||
} catch (error) {
|
||||
// 网络错误 / 超时 / JSON 解析失败:按原节奏重试
|
||||
if (epoch !== connectionEpoch) return;
|
||||
state.lastError = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`客户端上报失败(将于 ${HEARTBEAT_INTERVAL_TEXT} 后重试):${state.lastError}`);
|
||||
} finally {
|
||||
state.beating = false;
|
||||
// 每次心跳后检查已下载的更新是否满足安装策略。
|
||||
void maybeInstallPendingUpdate();
|
||||
scheduleNext(backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS);
|
||||
scheduleNext(
|
||||
epoch !== connectionEpoch ? 0 : backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,14 +240,9 @@ const HEARTBEAT_INTERVAL_TEXT = `${Math.round(CLIENT_HEARTBEAT_INTERVAL_MS / 100
|
||||
/**
|
||||
* 服务端下发的远控命令(关机 / 重启 / 注销 / 锁屏)。
|
||||
*
|
||||
* 两道护栏缺一不可(用户要求「必须二次确认」):
|
||||
* 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 ——
|
||||
* 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道;
|
||||
* 2. **同一条命令只执行一次**:等待确认时内存去重,处理结果落盘,重启后也只重试回执。
|
||||
*
|
||||
* 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey):
|
||||
* 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存,
|
||||
* 下一次心跳只重试回执、不重复弹窗。
|
||||
* 按产品要求收到即执行,保留十分钟有效期、持久去重与设备归属校验。
|
||||
* 执行结果必须回执;网络失败只重试回执,不重复执行。
|
||||
* 冒烟在执行器处强制返回假结果,永不调用系统命令。
|
||||
*/
|
||||
const inFlightCommands = new Set<string>();
|
||||
type CommandOutcome = { ok: boolean; result: string };
|
||||
@@ -286,6 +280,7 @@ function rememberCommand(commandId: string, outcome: CommandOutcome): void {
|
||||
|
||||
async function handleCommand(command: ClientCommandDto | null): Promise<void> {
|
||||
if (!command) return;
|
||||
const commandConfig = getConfig();
|
||||
const label = CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
|
||||
|
||||
const expired =
|
||||
@@ -297,65 +292,38 @@ async function handleCommand(command: ClientCommandDto | null): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 正在确认或执行时不能用预存的中间结果抢先回执。
|
||||
// 正在执行时不能用预存的中间结果抢先回执。
|
||||
if (inFlightCommands.has(command.id)) return;
|
||||
// 处理过但回执没送达:只重试回执
|
||||
readCommandResults();
|
||||
const pending = commandResults.get(command.id);
|
||||
if (pending) {
|
||||
await ackCommand(command.id, pending.ok, pending.result);
|
||||
await ackCommand(command.id, pending.ok, pending.result, commandConfig);
|
||||
return;
|
||||
}
|
||||
inFlightCommands.add(command.id);
|
||||
try {
|
||||
const confirmed = await confirmCommand(label, command.payload);
|
||||
if (!confirmed) {
|
||||
const result = '本机用户取消(或 60 秒未确认)';
|
||||
logger.warn(`远控命令「${label}」未执行:${result}`);
|
||||
await ackCommand(command.id, false, result);
|
||||
return;
|
||||
}
|
||||
// 注销可能立刻结束本进程,先落盘,重启后不能再次执行同一条指令。
|
||||
rememberCommand(command.id, {
|
||||
ok: false,
|
||||
result: '本机已确认并提交系统执行,最终结果未回传,请核对设备状态',
|
||||
result: '已提交系统执行,最终结果未回传,请核对设备状态',
|
||||
});
|
||||
if (getConfig().serverUrl !== commandConfig.serverUrl || getConfig().token !== commandConfig.token)
|
||||
return;
|
||||
const outcome = await executeCommand(command.kind);
|
||||
logger.warn(`远控命令「${label}」执行${outcome.ok ? '成功' : '失败'}:${outcome.result}`);
|
||||
await ackCommand(command.id, outcome.ok, outcome.result);
|
||||
await ackCommand(command.id, outcome.ok, outcome.result, commandConfig);
|
||||
} finally {
|
||||
inFlightCommands.delete(command.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 本机二次确认对话框:默认「取消」,60 秒未操作按取消处理 */
|
||||
async function confirmCommand(label: string, note: string): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), COMMAND_CONFIRM_TIMEOUT_MS);
|
||||
const answered = dialog
|
||||
.showMessageBox({
|
||||
signal: controller.signal,
|
||||
type: 'warning',
|
||||
title: '来自管理员的远程指令',
|
||||
message: `管理员要求本机「${label}」`,
|
||||
detail: `${note ? `备注:${note}\n` : ''}确认后本机将立即${label};60 秒内未确认视为取消。`,
|
||||
buttons: ['取消', '确认执行'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
noLink: true,
|
||||
})
|
||||
.then((result) => !controller.signal.aborted && result.response === 1)
|
||||
.catch(() => false);
|
||||
|
||||
try {
|
||||
return await answered;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行系统命令:只支持 Windows(本项目客户端只发 Windows 版) */
|
||||
async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; result: string }> {
|
||||
if (process.env.ELECTRON_SMOKE_TEST === '1')
|
||||
return { ok: true, result: '冒烟假执行器:已接收,未调用系统命令' };
|
||||
if (!['shutdown', 'restart', 'logoff', 'lock'].includes(kind))
|
||||
return { ok: false, result: '未知的远控动作' };
|
||||
if (process.platform !== 'win32') return { ok: false, result: '仅支持 Windows' };
|
||||
|
||||
const commands: Record<ClientCommandKind, { file: string; args: string[] }> = {
|
||||
@@ -390,9 +358,14 @@ async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; r
|
||||
}
|
||||
|
||||
/** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */
|
||||
async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> {
|
||||
async function ackCommand(
|
||||
commandId: string,
|
||||
ok: boolean,
|
||||
result: string,
|
||||
config: ReturnType<typeof getConfig>,
|
||||
): Promise<void> {
|
||||
rememberCommand(commandId, { ok, result });
|
||||
const config = getConfig();
|
||||
// 回执始终发给接收命令时的服务器,切换实例不能把旧命令带到新实例。
|
||||
if (!config.token) return;
|
||||
try {
|
||||
const response = await fetch(`${config.serverUrl}/api/clients/commands/${commandId}/ack`, {
|
||||
@@ -426,6 +399,19 @@ async function handleUpdate(info: ClientUpdateInfoDto | null, targetVersion: str
|
||||
await considerUpdate(info, target);
|
||||
}
|
||||
|
||||
let connectionEpoch = 0;
|
||||
/** 显式身份事件清除旧服务端退避;旧请求返回也不得覆盖新服务器状态。 */
|
||||
export function resetClientAgent(reason: ConnectionEvent): void {
|
||||
connectionEpoch++;
|
||||
clearTimer();
|
||||
state.lastHeartbeatAt = null;
|
||||
state.lastError = '';
|
||||
state.updateTargetVersion = '';
|
||||
logger.info(`客户端连接状态已重置:${reason}`);
|
||||
if (state.running && !state.beating) scheduleNext(0);
|
||||
}
|
||||
connectionEvents.on('changed', resetClientAgent);
|
||||
|
||||
/** 启动心跳(重复调用无副作用) */
|
||||
export function startClientAgent(): void {
|
||||
if (state.running) return;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
export type ConnectionEvent = 'server-changed' | 'token-changed' | 'login' | 'logout';
|
||||
export const connectionEvents = new EventEmitter();
|
||||
export function connectionChanged(event: ConnectionEvent) {
|
||||
connectionEvents.emit('changed', event);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { IslandLayoutSettings as ContractIslandLayoutSettings } from '@classhelper/shared';
|
||||
import { connectionChanged } from './config-events.js';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app, safeStorage } from 'electron';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
normalizeIslandLayouts,
|
||||
DEFAULT_SERVER_URL,
|
||||
HOMEWORK_PHRASE_DEFAULTS,
|
||||
HOMEWORK_PHRASE_MAX_COUNT,
|
||||
@@ -21,6 +24,7 @@ const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
|
||||
};
|
||||
|
||||
interface PersistedConfig {
|
||||
islandLayout?: ContractIslandLayoutSettings;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
/** 加密后的 token(base64)或明文(当系统不支持加密时) */
|
||||
@@ -194,6 +198,7 @@ function readPersistedFromDisk(): PersistedConfig {
|
||||
const content = fs.readFileSync(configFilePath(), 'utf8');
|
||||
const parsed = JSON.parse(content) as Partial<PersistedConfig>;
|
||||
return {
|
||||
islandLayout: normalizeIslandLayouts(parsed.islandLayout),
|
||||
serverUrl:
|
||||
typeof parsed.serverUrl === 'string' && parsed.serverUrl
|
||||
? (normalizeServerUrl(parsed.serverUrl) ?? DEFAULT_CONFIG.serverUrl)
|
||||
@@ -360,6 +365,7 @@ function normalizeServerUrl(input: string): string | null {
|
||||
function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
|
||||
const persisted = state.config;
|
||||
return {
|
||||
islandLayout: normalizeIslandLayouts(persisted.islandLayout),
|
||||
serverUrl: persisted.serverUrl,
|
||||
username: persisted.username,
|
||||
token: state.token,
|
||||
@@ -425,6 +431,9 @@ export function getConfig(): DesktopStoredConfig {
|
||||
export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredConfig {
|
||||
const state = loadCache();
|
||||
const persisted = state.config;
|
||||
if (patch.islandLayout !== undefined) persisted.islandLayout = normalizeIslandLayouts(patch.islandLayout);
|
||||
const oldServer = persisted.serverUrl;
|
||||
const oldToken = state.token;
|
||||
|
||||
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
|
||||
const normalized = normalizeServerUrl(patch.serverUrl);
|
||||
@@ -485,6 +494,8 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
||||
if (typeof patch.deviceKey === 'string' && patch.deviceKey && patch.deviceKey !== persisted.deviceKey) {
|
||||
logger.warn('忽略对设备标识(deviceKey)的改写:它是这台机器在服务端的身份,不允许变更');
|
||||
}
|
||||
if (persisted.serverUrl !== oldServer && (!patch.token || patch.token === oldToken))
|
||||
patch = { ...patch, token: null };
|
||||
if (patch.token !== undefined) {
|
||||
const encrypted = encryptToken(patch.token);
|
||||
persisted.token = encrypted.token;
|
||||
@@ -494,6 +505,11 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
||||
}
|
||||
|
||||
scheduleWrite(persisted);
|
||||
if (persisted.serverUrl !== oldServer) connectionChanged('server-changed');
|
||||
if (state.token !== oldToken) {
|
||||
connectionChanged('token-changed');
|
||||
connectionChanged(state.token ? 'login' : 'logout');
|
||||
}
|
||||
return toDesktopConfig(state);
|
||||
}
|
||||
|
||||
@@ -508,6 +524,7 @@ export function clearConfig(): DesktopStoredConfig {
|
||||
const current = cache?.config ?? DEFAULT_CONFIG;
|
||||
const kept: PersistedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
islandLayout: normalizeIslandLayouts(current.islandLayout),
|
||||
deviceKey: current.deviceKey ?? '',
|
||||
autoLaunch: current.autoLaunch === true,
|
||||
autoUpdate: current.autoUpdate !== false,
|
||||
@@ -519,6 +536,7 @@ export function clearConfig(): DesktopStoredConfig {
|
||||
ensureDeviceKey(kept);
|
||||
const state: ConfigCache = { config: kept, token: null };
|
||||
cache = state;
|
||||
connectionChanged('logout');
|
||||
scheduleWrite(state.config, true);
|
||||
return toDesktopConfig(state);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { island } from './island.js';
|
||||
import { getUpdateProgress } from './auto-update.js';
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||
import type { UpdateInfo } from '@classhelper/shared';
|
||||
import { IPC_CHANNELS } from './channels.js';
|
||||
@@ -12,6 +14,7 @@ import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
|
||||
export { IPC_CHANNELS };
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
ipcMain.handle('client:get-update-progress', () => getUpdateProgress());
|
||||
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
|
||||
|
||||
ipcMain.handle(
|
||||
@@ -21,7 +24,9 @@ export function registerIpcHandlers(): void {
|
||||
const allowed = { ...patch };
|
||||
delete allowed.pendingUpdateFile;
|
||||
delete allowed.pendingUpdateVersion;
|
||||
return saveConfig(allowed);
|
||||
const saved = saveConfig(allowed);
|
||||
if (allowed.islandLayout) island.syncCustomLayout();
|
||||
return saved;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ScheduleDto as ContractScheduleDto } from '@classhelper/shared';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
normalizeIslandLayouts,
|
||||
ISLAND_POSITIONS,
|
||||
ISLAND_SHADOW_PAD,
|
||||
ISLAND_SIZE_DELTA,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
type IslandState,
|
||||
} from '@classhelper/shared';
|
||||
import { logger } from './logger.js';
|
||||
import { getConfig } from './config.js';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
|
||||
/** 生产环境的灵动岛页面地址(导航白名单的精确比对用) */
|
||||
const prodIslandUrl = pathToFileURL(path.join(__dirname, '../renderer/island.html')).href;
|
||||
@@ -130,6 +132,7 @@ export interface IslandPushContext {
|
||||
}
|
||||
|
||||
export interface IslandClassStatePayload {
|
||||
schedules?: ContractScheduleDto[];
|
||||
inClass: boolean;
|
||||
currentPeriodEnd?: string | null;
|
||||
week?: number;
|
||||
@@ -189,6 +192,70 @@ class IslandController {
|
||||
* 只有投影所需的 10px 留白,不再吞掉桌面点击。
|
||||
*/
|
||||
private touchMode = false;
|
||||
private editingLayout = false;
|
||||
private customSize = { width: 424, height: 80 };
|
||||
private customDesiredSize = { width: 424, height: 80 };
|
||||
private customTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private get customLayout(): boolean {
|
||||
return getConfig().islandLayout.layout === 'custom';
|
||||
}
|
||||
getEditingLayout(): boolean {
|
||||
return this.editingLayout;
|
||||
}
|
||||
syncCustomLayout(): void {
|
||||
this.win?.webContents.send('island:layout', getConfig().islandLayout);
|
||||
this.syncWindow();
|
||||
}
|
||||
editLayout(enabled = true): void {
|
||||
this.editingLayout = enabled;
|
||||
if (enabled) saveConfig({ islandLayout: { ...getConfig().islandLayout, layout: 'custom' } });
|
||||
this.win?.webContents.send('island:layout', getConfig().islandLayout);
|
||||
this.win?.webContents.send('island:editing', enabled);
|
||||
if (this.win && !this.win.isDestroyed()) {
|
||||
this.win.hide();
|
||||
if (enabled) {
|
||||
const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea;
|
||||
this.win.setBounds(area);
|
||||
this.win.setOpacity(1);
|
||||
if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual')) {
|
||||
this.win.show();
|
||||
this.win.focus();
|
||||
}
|
||||
this.setInteractive(true, '布局编辑');
|
||||
} else {
|
||||
const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea;
|
||||
this.customSize = {
|
||||
width: Math.max(40, Math.min(this.customDesiredSize.width, area.width - 2 * ISLAND_SHADOW_PAD)),
|
||||
height: Math.max(24, Math.min(this.customDesiredSize.height, area.height - 2 * ISLAND_SHADOW_PAD)),
|
||||
};
|
||||
this.win.webContents.send('island:viewport', this.customSize);
|
||||
this.applyWindowLayout();
|
||||
this.syncWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
reportCustomSize(value: { width: number; height: number }): void {
|
||||
if (!this.customLayout || !Number.isFinite(value?.width) || !Number.isFinite(value?.height)) return;
|
||||
this.customDesiredSize = { ...value };
|
||||
if (this.editingLayout) return;
|
||||
if (this.customTimer) clearTimeout(this.customTimer);
|
||||
this.customTimer = setTimeout(() => {
|
||||
this.customTimer = null;
|
||||
if (!this.win || !this.customLayout || this.editingLayout) return;
|
||||
const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea;
|
||||
const current = this.win.getBounds();
|
||||
const width = Math.max(40, Math.min(Math.ceil(value.width), area.width - 2 * ISLAND_SHADOW_PAD));
|
||||
const height = Math.max(24, Math.min(Math.ceil(value.height), area.height - 2 * ISLAND_SHADOW_PAD));
|
||||
// 可见/编辑期间不主动缩窗;只有隐藏后收缩。渲染器按获准尺寸铺底,窗口=卡片+2×留白。
|
||||
this.customSize = {
|
||||
width: this.win.isVisible() ? Math.max(width, current.width - 2 * ISLAND_SHADOW_PAD) : width,
|
||||
height: this.win.isVisible() ? Math.max(height, current.height - 2 * ISLAND_SHADOW_PAD) : height,
|
||||
};
|
||||
this.applyWindowLayout();
|
||||
this.win.webContents.send('island:viewport', this.customSize);
|
||||
this.setHitRect({ x: ISLAND_SHADOW_PAD, y: ISLAND_SHADOW_PAD, ...this.customSize });
|
||||
}, 120);
|
||||
}
|
||||
/** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */
|
||||
private hitRect: { x: number; y: number; width: number; height: number } | null = null;
|
||||
/** 光标命中兜底轮询定时器(见 syncHitFromCursor 注释) */
|
||||
@@ -334,6 +401,7 @@ class IslandController {
|
||||
* 因此这里再用**指针位置**兜一层:指针还在岛上就绝不收起(详见 isCursorOnIsland)。
|
||||
*/
|
||||
win.on('blur', () => {
|
||||
if (this.editingLayout) return;
|
||||
// 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启)
|
||||
if (!this.blurCollapseEnabled) return;
|
||||
if (this.state.mode !== 'expanded' || this.state.inClass) return;
|
||||
@@ -346,7 +414,7 @@ class IslandController {
|
||||
}
|
||||
// 触摸屏上没有"指针"可依据(isCursorOnIsland 恒为 false),只能用更宽的宽限期覆盖
|
||||
// 上面实测到的 70~520ms 抖动区间,否则手指点开后约 0.5s 会莫名缩回胶囊。
|
||||
const grace = this.touchMode ? BLUR_GRACE_TOUCH_MS : BLUR_GRACE_MS;
|
||||
const grace = this.touchMode || this.editingLayout ? BLUR_GRACE_TOUCH_MS : BLUR_GRACE_MS;
|
||||
if (sinceActivated < grace) {
|
||||
logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`);
|
||||
return;
|
||||
@@ -605,6 +673,8 @@ class IslandController {
|
||||
|
||||
/** 上课状态变化:进入上课隐藏;下课则自动弹出队列 */
|
||||
setClassState(payload: IslandClassStatePayload): void {
|
||||
if (Array.isArray(payload.schedules))
|
||||
this.win?.webContents.send('island:schedules', payload.schedules.slice(0, 100));
|
||||
const wasInClass = this.state.inClass;
|
||||
this.state.inClass = payload.inClass;
|
||||
this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null;
|
||||
@@ -845,6 +915,26 @@ class IslandController {
|
||||
if (this.state.active || this.state.queued.length > 0) return;
|
||||
this.fadeOut();
|
||||
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
|
||||
if (this.customLayout)
|
||||
setTimeout(() => {
|
||||
// 提醒沿原路径收回后,隐藏窗口再缩回组件栏;时钟等普通组件继续显示。
|
||||
if (
|
||||
!this.win ||
|
||||
this.win.isDestroyed() ||
|
||||
!this.customLayout ||
|
||||
this.editingLayout ||
|
||||
this.state.mode !== 'hidden'
|
||||
)
|
||||
return;
|
||||
this.win.hide();
|
||||
const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea;
|
||||
this.customSize = {
|
||||
width: Math.max(40, Math.min(this.customDesiredSize.width, area.width - 2 * ISLAND_SHADOW_PAD)),
|
||||
height: Math.max(24, Math.min(this.customDesiredSize.height, area.height - 2 * ISLAND_SHADOW_PAD)),
|
||||
};
|
||||
this.win.webContents.send('island:viewport', this.customSize);
|
||||
this.syncWindow();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1081,6 +1171,22 @@ class IslandController {
|
||||
*/
|
||||
private syncWindow(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
if (this.editingLayout) {
|
||||
this.setInteractive(true, '布局编辑');
|
||||
return;
|
||||
}
|
||||
if (this.customLayout) {
|
||||
this.cancelFade();
|
||||
this.applyWindowLayout();
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
if (
|
||||
!this.win.isVisible() &&
|
||||
!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual')
|
||||
)
|
||||
this.win.showInactive();
|
||||
this.setInteractive(true, '自定义组件栏');
|
||||
return;
|
||||
}
|
||||
|
||||
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知 / 叫人"允许出现在屏幕上)
|
||||
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
|
||||
@@ -1141,6 +1247,11 @@ class IslandController {
|
||||
* - 触摸模式:贴合当前形态(岛体 + 2×阴影留白),否则大包围盒的透明区会吞掉桌面的触摸。
|
||||
*/
|
||||
private windowBox(): { width: number; height: number } {
|
||||
if (this.customLayout)
|
||||
return {
|
||||
width: this.customSize.width + 2 * ISLAND_SHADOW_PAD,
|
||||
height: this.customSize.height + 2 * ISLAND_SHADOW_PAD,
|
||||
};
|
||||
const size = this.formSize();
|
||||
if (!this.touchMode) {
|
||||
return {
|
||||
@@ -1200,6 +1311,7 @@ class IslandController {
|
||||
private applyWindowLayout(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const next = this.windowBounds();
|
||||
if (this.editingLayout) return;
|
||||
const current = this.win.getBounds();
|
||||
if (
|
||||
current.x === next.x &&
|
||||
@@ -1236,6 +1348,15 @@ class IslandController {
|
||||
}
|
||||
const y = anchor.vMode === 'bottom' ? anchor.vValue + pad - height : anchor.vValue - pad;
|
||||
|
||||
if (this.customLayout) {
|
||||
const area = screen.getDisplayNearestPoint({ x, y }).workArea;
|
||||
return {
|
||||
x: Math.max(area.x, Math.min(x, area.x + area.width - width)),
|
||||
y: Math.max(area.y, Math.min(y, area.y + area.height - height)),
|
||||
width: Math.min(width, area.width),
|
||||
height: Math.min(height, area.height),
|
||||
};
|
||||
}
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
@@ -1254,6 +1375,7 @@ class IslandController {
|
||||
*/
|
||||
private applyFocusable(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
if (this.editingLayout) return;
|
||||
if (!this.win.isFocusable()) this.win.setFocusable(true);
|
||||
if (this.state.mode === 'expanded') {
|
||||
this.win.focus();
|
||||
@@ -1390,7 +1512,11 @@ class IslandController {
|
||||
// 但**注入光标**(冒烟用 setHitTestCursor 模拟指针在岛上/岛外)照常生效,
|
||||
// 否则「光标轮询校正命中」「岛外穿透」这类断言全会被自己的开关弄红。
|
||||
const passthrough = this.testInputPassthrough && this.cursorOverride === null;
|
||||
const next = passthrough ? false : this.touchMode ? true : interactive;
|
||||
const next = passthrough
|
||||
? false
|
||||
: this.touchMode || this.editingLayout || this.customLayout
|
||||
? true
|
||||
: interactive;
|
||||
if (this.interactive === next) return;
|
||||
this.interactive = next;
|
||||
logger.info(
|
||||
@@ -1497,6 +1623,7 @@ class IslandController {
|
||||
|
||||
/** 当前形态下**岛本身**的尺寸(不是窗口尺寸;响应进程的 `.island-card` 宽高) */
|
||||
private formSize(): { width: number; height: number } {
|
||||
if (this.customLayout) return this.customSize;
|
||||
// 多条通知的列表形态:高度由统一布局算法算出(渲染进程算的是同一个数)
|
||||
const layout = this.listLayout();
|
||||
if (layout) return { width: SIZES.expanded.width, height: layout.height };
|
||||
@@ -1868,6 +1995,19 @@ export const island = new IslandController();
|
||||
|
||||
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
|
||||
export function registerIslandIpc(): void {
|
||||
ipcMain.handle('island:get-layout', () => getConfig().islandLayout);
|
||||
ipcMain.handle('island:save-layout', (_event, value: unknown) => {
|
||||
const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout;
|
||||
island.syncCustomLayout();
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle('island:edit-layout', () => island.editLayout());
|
||||
ipcMain.handle('island:get-editing', () => island.getEditingLayout());
|
||||
ipcMain.handle('island:set-editing', (_event, value: unknown) => island.editLayout(value === true));
|
||||
ipcMain.on('island:content-size', (event, value) => {
|
||||
if (event.sender === island.getWindow()?.webContents) island.reportCustomSize(value);
|
||||
});
|
||||
|
||||
ipcMain.on('island:remove-notification', (_event, id: string) => {
|
||||
if (typeof id === 'string') island.removeNotification(id);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { considerUpdate, getUpdateProgress, syncUpdatePolicy } from './auto-update.js';
|
||||
import { createServer } from 'node:http';
|
||||
import { app } from 'electron';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { beatNow, getClientAgentStatus, stopClientAgent, startClientAgent } from './client-agent.js';
|
||||
export async function verifyAgentSmoke(
|
||||
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
|
||||
) {
|
||||
const original = getConfig(),
|
||||
previous = process.env.ELECTRON_SMOKE_CLIENT_AGENT;
|
||||
let firstCalls = 0,
|
||||
secondCalls = 0,
|
||||
acked = false,
|
||||
invalidToken = false;
|
||||
const one = createServer((req, res) => {
|
||||
firstCalls++;
|
||||
res.writeHead(401, { 'content-type': 'application/json' });
|
||||
res.end('{}');
|
||||
});
|
||||
const two = createServer((req, res) => {
|
||||
if (req.headers.authorization !== 'Bearer v14-local-fake-token') invalidToken = true;
|
||||
if (req.url === '/fake-installer') {
|
||||
res.setHeader('content-length', String(Buffer.byteLength('仅测试,不是可执行文件')));
|
||||
res.end('仅测试,不是可执行文件');
|
||||
return;
|
||||
}
|
||||
res.setHeader('content-type', 'application/json');
|
||||
if (req.url?.includes('/ack')) {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
const value = JSON.parse(body);
|
||||
acked =
|
||||
value.deviceKey === original.deviceKey &&
|
||||
value.ok === true &&
|
||||
value.result.includes('冒烟假执行器');
|
||||
res.end(JSON.stringify({ success: true, data: {} }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
secondCalls++;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
deviceId: 'local-smoke-device',
|
||||
serverTime: new Date().toISOString(),
|
||||
updateTargetVersion: null,
|
||||
updateMode: 'idle',
|
||||
update: null,
|
||||
command: acked
|
||||
? null
|
||||
: {
|
||||
id: `local-smoke-${app.getVersion()}-${Date.now()}`,
|
||||
deviceId: 'local-smoke-device',
|
||||
kind: 'lock',
|
||||
payload: '仅假执行',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
||||
ackedAt: null,
|
||||
result: '',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await Promise.all([
|
||||
new Promise<void>((r) => one.listen(0, '127.0.0.1', r)),
|
||||
new Promise<void>((r) => two.listen(0, '127.0.0.1', r)),
|
||||
]);
|
||||
const url = (server: typeof one) => `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
try {
|
||||
process.env.ELECTRON_SMOKE_CLIENT_AGENT = '1';
|
||||
stopClientAgent();
|
||||
saveConfig({ serverUrl: url(one), token: 'v14-old-fake-token' });
|
||||
startClientAgent();
|
||||
await beatNow();
|
||||
await check(
|
||||
'401 后明确进入退避状态',
|
||||
() => firstCalls > 0 && getClientAgentStatus().lastError.includes('401'),
|
||||
);
|
||||
const begin = Date.now();
|
||||
saveConfig({ serverUrl: url(two) });
|
||||
await check(
|
||||
'换服务端清除旧令牌与退避,设备身份保持不变',
|
||||
() =>
|
||||
getConfig().token === null &&
|
||||
getConfig().deviceKey === original.deviceKey &&
|
||||
!getClientAgentStatus().lastError,
|
||||
);
|
||||
saveConfig({ token: 'v14-local-fake-token' });
|
||||
await beatNow();
|
||||
await check('新服务端登录后30秒内自动心跳', async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (secondCalls === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 30));
|
||||
return secondCalls > 0 && Date.now() - begin < 30000 && getClientAgentStatus().online && !invalidToken;
|
||||
});
|
||||
await check('收到远控立即假执行并以deviceKey回执(未调用系统命令)', async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!acked && Date.now() < deadline) await new Promise((r) => setTimeout(r, 30));
|
||||
return acked;
|
||||
});
|
||||
await check('安装包哈希失败会显示失败并拒绝待安装记录', async () => {
|
||||
saveConfig({ autoUpdate: true });
|
||||
const info = {
|
||||
version: '99.0.0',
|
||||
fileName: 'smoke-update.exe',
|
||||
size: Buffer.byteLength('仅测试,不是可执行文件'),
|
||||
sha256: 'bad',
|
||||
notes: '仅本机假数据',
|
||||
url: '/fake-installer',
|
||||
};
|
||||
syncUpdatePolicy({
|
||||
deviceId: 'local-smoke-device',
|
||||
serverTime: new Date().toISOString(),
|
||||
command: null,
|
||||
update: null,
|
||||
updateTargetVersion: info.version,
|
||||
updateMode: 'idle',
|
||||
updateNotBefore: null,
|
||||
});
|
||||
await considerUpdate(info, info.version);
|
||||
return (
|
||||
getUpdateProgress().stage === 'failed' &&
|
||||
getUpdateProgress().error.includes('sha256') &&
|
||||
getConfig().pendingUpdateVersion !== info.version
|
||||
);
|
||||
});
|
||||
await check('下载字节与校验完成显示等待安装(冒烟禁止运行安装器)', async () => {
|
||||
const info = {
|
||||
version: '99.0.1',
|
||||
fileName: 'smoke-update.exe',
|
||||
size: Buffer.byteLength('仅测试,不是可执行文件'),
|
||||
sha256: createHash('sha256').update('仅测试,不是可执行文件').digest('hex'),
|
||||
notes: '仅本机假数据',
|
||||
url: '/fake-installer',
|
||||
};
|
||||
syncUpdatePolicy({
|
||||
deviceId: 'local-smoke-device',
|
||||
serverTime: new Date().toISOString(),
|
||||
command: null,
|
||||
update: null,
|
||||
updateTargetVersion: info.version,
|
||||
updateMode: 'idle',
|
||||
updateNotBefore: null,
|
||||
});
|
||||
await considerUpdate(info, info.version);
|
||||
const progress = getUpdateProgress();
|
||||
return (
|
||||
progress.stage === 'waiting' &&
|
||||
progress.percent === 100 &&
|
||||
progress.downloadedBytes === info.size &&
|
||||
getConfig().pendingUpdateVersion === info.version
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
stopClientAgent();
|
||||
saveConfig(original);
|
||||
if (previous === undefined) delete process.env.ELECTRON_SMOKE_CLIENT_AGENT;
|
||||
else process.env.ELECTRON_SMOKE_CLIENT_AGENT = previous;
|
||||
startClientAgent();
|
||||
await Promise.all([
|
||||
new Promise<void>((r) => one.close(() => r())),
|
||||
new Promise<void>((r) => two.close(() => r())),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { island } from './island.js';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { defaultIslandLayouts, ISLAND_SHADOW_PAD } from '@classhelper/shared';
|
||||
export async function verifyLayoutSmoke(
|
||||
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
|
||||
) {
|
||||
const original = getConfig().islandLayout;
|
||||
await island.init(null);
|
||||
island.setTestInputPassthrough(true);
|
||||
const win = island.getWindow()!;
|
||||
const pause = () => new Promise<void>((r) => setTimeout(r, 400));
|
||||
try {
|
||||
await pause();
|
||||
await check('默认 fixed 布局且尺寸上报不参与', async () => {
|
||||
saveConfig({ islandLayout: defaultIslandLayouts() });
|
||||
const before = win.getBounds();
|
||||
island.reportCustomSize({ width: 900, height: 900 });
|
||||
await pause();
|
||||
const after = win.getBounds();
|
||||
return (
|
||||
before.width === after.width &&
|
||||
before.height === after.height &&
|
||||
getConfig().islandLayout.layout === 'fixed'
|
||||
);
|
||||
});
|
||||
await check('自定义编辑模式可进入并添加组件行', async () => {
|
||||
island.editLayout();
|
||||
await pause();
|
||||
return win.webContents.executeJavaScript(
|
||||
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.includes('在下方新建'));b?.click();return Boolean(document.querySelector('.layout-editor')); })()`,
|
||||
);
|
||||
});
|
||||
await check('触摸拖动可跨行并持久化', async () => {
|
||||
await pause();
|
||||
await win.webContents.executeJavaScript(`(() => {
|
||||
const handle=document.querySelector('.node-handle[data-kind=clock]');
|
||||
const row=document.querySelectorAll('[data-row]')[1]; if(!handle||!row)throw Error('缺少拖动目标');
|
||||
const r=row.getBoundingClientRect(); const x=r.left+Math.min(20,r.width/2),y=r.top+r.height/2;
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:19,pointerType:'touch',clientX:10,clientY:10}));
|
||||
handle.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
|
||||
handle.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
|
||||
})()`);
|
||||
await pause();
|
||||
return getConfig().islandLayout.schemes[0]?.rows[1]?.nodes.some((n) => n.kind === 'clock') === true;
|
||||
});
|
||||
await check('组件可以包裹进容器且设置独立保存', async () => {
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => { const select=document.querySelector('.settings select');if(!select)throw Error('未选中组件');select.value='group';select.dispatchEvent(new Event('change',{bubbles:true})); })()`,
|
||||
);
|
||||
await pause();
|
||||
return (
|
||||
getConfig().islandLayout.schemes[0]?.rows.some((r) =>
|
||||
r.nodes.some((n) => n.kind === 'group' && n.children.some((c) => c.kind === 'clock')),
|
||||
) === true
|
||||
);
|
||||
});
|
||||
if (process.env.ISLAND_SHOTS_DIR) {
|
||||
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-editor.png'),
|
||||
(await win.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
}
|
||||
await check('退出编辑后配置可从 getConfig 读回', async () => {
|
||||
island.editLayout(false);
|
||||
await pause();
|
||||
return !island.getEditingLayout() && getConfig().islandLayout.schemes[0]!.rows.length === 2;
|
||||
});
|
||||
await check('自定义窗口等于卡片加阴影留白且夹紧工作区', async () => {
|
||||
await pause();
|
||||
const rect = await win.webContents.executeJavaScript(
|
||||
`(() => {const r=document.querySelector('.custom-card').getBoundingClientRect();return {width:r.width,height:r.height};})()`,
|
||||
);
|
||||
const box = win.getBounds();
|
||||
return (
|
||||
Math.abs(box.width - rect.width - 2 * ISLAND_SHADOW_PAD) <= 2 &&
|
||||
Math.abs(box.height - rect.height - 2 * ISLAND_SHADOW_PAD) <= 2
|
||||
);
|
||||
});
|
||||
await check('自定义提醒复用原渲染器且进入主要行', async () => {
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
island.pushNotification({
|
||||
id: 'custom-smoke-alert',
|
||||
title: '组件化提醒验收',
|
||||
content: '通知、作业与叫人共用原卡片行为',
|
||||
priority: 'URGENT',
|
||||
createdAt: new Date().toISOString(),
|
||||
teacherName: '临时教师',
|
||||
courseName: null,
|
||||
});
|
||||
await pause();
|
||||
await pause();
|
||||
return win.webContents.executeJavaScript(
|
||||
`(() => {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 changed = getConfig().islandLayout;
|
||||
const rows = changed.schemes[0]!.rows;
|
||||
rows[0]!.allowNotifications = false;
|
||||
saveConfig({ islandLayout: changed });
|
||||
island.syncCustomLayout();
|
||||
await pause();
|
||||
await pause();
|
||||
return win.webContents.executeJavaScript(
|
||||
`(() => {const host=document.querySelector('#custom-alert-host');return Boolean(host?.querySelector('.island-root.embedded')&&host.closest('[data-row]')===document.querySelectorAll('[data-row]')[1]);})()`,
|
||||
);
|
||||
});
|
||||
if (process.env.ISLAND_SHOTS_DIR)
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-alert.png'),
|
||||
(await win.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
island.handleAction({ action: 'dismiss-all' });
|
||||
await pause();
|
||||
await pause();
|
||||
await pause();
|
||||
await check('提醒收回后缩回组件栏,保留时钟与可见状态', async () => {
|
||||
const content = await win.webContents.executeJavaScript(
|
||||
`Boolean(document.querySelector('.custom-card .clock'))`,
|
||||
);
|
||||
const visible = process.env.ELECTRON_SMOKE_MODE === 'visual' ? win.isVisible() : !win.isVisible();
|
||||
return content && visible && island.getState().mode === 'hidden';
|
||||
});
|
||||
} finally {
|
||||
saveConfig({ islandLayout: original });
|
||||
island.syncCustomLayout();
|
||||
win.hide();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { verifyLayoutSmoke } from './smoke-layout.js';
|
||||
import { verifyAgentSmoke } from './smoke-agent.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
@@ -79,6 +81,8 @@ export async function runQuickSmoke(win: BrowserWindow): Promise<void> {
|
||||
return result.detail;
|
||||
});
|
||||
}
|
||||
await verifyAgentSmoke(check);
|
||||
await verifyLayoutSmoke(check);
|
||||
await check('检查结束时没有可见或聚焦的验证窗口', () =>
|
||||
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyLayoutSmoke } from './smoke-layout.js';
|
||||
import fs from 'node:fs';
|
||||
import { runQuickSmoke } from './smoke-quick.js';
|
||||
import path from 'node:path';
|
||||
@@ -4603,6 +4604,14 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
await verifyLayoutSmoke(async (name, action) => {
|
||||
try {
|
||||
const value = await action();
|
||||
record(name, value !== false, typeof value === 'string' ? value : '');
|
||||
} catch (error) {
|
||||
record(name, false, String(error));
|
||||
}
|
||||
});
|
||||
writeResult();
|
||||
|
||||
// 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { island } from './island.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Menu, Tray, app, nativeImage } from 'electron';
|
||||
@@ -38,6 +39,7 @@ export function createTray(handlers: TrayHandlers): Tray | null {
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{ label: '显示主窗口', click: () => handlers.onShow() },
|
||||
{ label: '编辑灵动岛布局', click: () => island.editLayout() },
|
||||
{ label: '隐藏到托盘', click: () => handlers.onHide() },
|
||||
{ type: 'separator' },
|
||||
{ label: '退出 ClassHelper', click: () => handlers.onQuit() },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReleaseProgressDto as ContractReleaseProgressDto } from '@classhelper/shared';
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
|
||||
import type { ClientAgentStatus } from '../main/client-agent.js';
|
||||
@@ -19,6 +20,7 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
|
||||
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
|
||||
*/
|
||||
const bridge: DesktopBridge = {
|
||||
islandEditLayout: () => ipcRenderer.invoke('island:edit-layout'),
|
||||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||||
// 冒烟凭据:只在冒烟模式下透传,供渲染进程的冒烟钩子构造测试数据用。
|
||||
// **不走代码字面量**——本文件与渲染产物都会进 app.asar,写死账号等于随安装包把教师口令发出去。
|
||||
@@ -31,6 +33,12 @@ const bridge: DesktopBridge = {
|
||||
classPassword: process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '',
|
||||
}
|
||||
: undefined,
|
||||
getUpdateProgress: () => ipcRenderer.invoke('client:get-update-progress'),
|
||||
onUpdateProgress: (handler: (progress: ContractReleaseProgressDto) => void) => {
|
||||
const listener = (_event: unknown, value: ContractReleaseProgressDto) => handler(value);
|
||||
ipcRenderer.on('client:update-progress', listener);
|
||||
return () => ipcRenderer.removeListener('client:update-progress', listener);
|
||||
},
|
||||
getConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.getConfig),
|
||||
saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch),
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
IslandLayoutSettings as ContractIslandLayoutSettings,
|
||||
ScheduleDto as ContractScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type { IslandAppearance, IslandState } from '@classhelper/shared';
|
||||
|
||||
@@ -6,6 +10,16 @@ import type { IslandAppearance, IslandState } from '@classhelper/shared';
|
||||
* 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
|
||||
*/
|
||||
export interface IslandBridge {
|
||||
getLayout(): Promise<ContractIslandLayoutSettings>;
|
||||
saveLayout(value: ContractIslandLayoutSettings): Promise<ContractIslandLayoutSettings>;
|
||||
onLayout(handler: (value: ContractIslandLayoutSettings) => void): void;
|
||||
getEditing(): Promise<boolean>;
|
||||
setEditing(value: boolean): Promise<void>;
|
||||
onEditing(handler: (value: boolean) => void): void;
|
||||
reportSize(value: { width: number; height: number }): void;
|
||||
onViewport(handler: (value: { width: number; height: number }) => void): void;
|
||||
onSchedules(handler: (value: ContractScheduleDto[]) => void): void;
|
||||
|
||||
onState(handler: (state: IslandState) => void): void;
|
||||
sendAction(
|
||||
action:
|
||||
@@ -38,6 +52,24 @@ export interface IslandBridge {
|
||||
}
|
||||
|
||||
const bridge: IslandBridge = {
|
||||
getLayout: () => ipcRenderer.invoke('island:get-layout'),
|
||||
saveLayout: (value) => ipcRenderer.invoke('island:save-layout', value),
|
||||
onLayout: (handler) => {
|
||||
ipcRenderer.on('island:layout', (_event, value) => handler(value));
|
||||
},
|
||||
getEditing: () => ipcRenderer.invoke('island:get-editing'),
|
||||
setEditing: (value) => ipcRenderer.invoke('island:set-editing', value),
|
||||
onEditing: (handler) => {
|
||||
ipcRenderer.on('island:editing', (_event, value) => handler(value));
|
||||
},
|
||||
reportSize: (value) => ipcRenderer.send('island:content-size', value),
|
||||
onViewport: (handler) => {
|
||||
ipcRenderer.on('island:viewport', (_event, value) => handler(value));
|
||||
},
|
||||
onSchedules: (handler) => {
|
||||
ipcRenderer.on('island:schedules', (_event, value) => handler(value));
|
||||
},
|
||||
|
||||
onState: (handler) => {
|
||||
ipcRenderer.on('island:state', (_event, state: IslandState) => handler(state));
|
||||
},
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import type { ReleaseProgressDto } from '@classhelper/shared';
|
||||
const progress = ref<ReleaseProgressDto>();
|
||||
let off: (() => void) | undefined;
|
||||
const labels = {
|
||||
idle: '尚未下载',
|
||||
downloading: '下载中',
|
||||
verifying: '校验中',
|
||||
ready: '完成',
|
||||
waiting: '等待安装策略',
|
||||
installing: '安装中',
|
||||
failed: '失败',
|
||||
};
|
||||
onMounted(async () => {
|
||||
off = window.desktop?.onUpdateProgress((p) => (progress.value = p));
|
||||
progress.value = await window.desktop?.getUpdateProgress();
|
||||
});
|
||||
onUnmounted(() => off?.());
|
||||
</script>
|
||||
<template>
|
||||
<section v-if="progress && progress.stage !== 'idle'" role="status">
|
||||
<h3>客户端更新 · {{ labels[progress.stage] }}</h3>
|
||||
<el-progress :percentage="progress.percent ?? 0" />
|
||||
<p>{{ progress.version }} {{ progress.error }}</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ActiveClassPlanWeekDto as ContractActiveClassPlanWeekDto } from '@classhelper/shared';
|
||||
import { watch, type WatchStopHandle } from 'vue';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
@@ -7,7 +8,8 @@ import {
|
||||
type NotificationDto,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import { notificationApi, scheduleApi } from '../api/index.js';
|
||||
import { api } from '../api/http.js';
|
||||
import { notificationApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import { getCacheScope } from '../cache/scope.js';
|
||||
import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
|
||||
@@ -59,7 +61,10 @@ export async function refreshIslandSchedules(): Promise<void> {
|
||||
const result = await fetchWithCache<ScheduleDto[]>(
|
||||
'schedules',
|
||||
`week:${week}`,
|
||||
() => scheduleApi.list({ classId, week }),
|
||||
async () =>
|
||||
(
|
||||
await api.get<ContractActiveClassPlanWeekDto>('/schedules/active-week', { classId, week })
|
||||
).days.flatMap((day) => day.schedules),
|
||||
[],
|
||||
);
|
||||
if (scope !== getCacheScope() || request !== scheduleRequest) return;
|
||||
@@ -73,15 +78,19 @@ function recomputeClassState(force = false): void {
|
||||
const appStore = useAppStore();
|
||||
const status = resolveClassStatus(schedules, new Date(), scheduleWeek);
|
||||
const nextInClass = status.inClass;
|
||||
const samePeriod =
|
||||
currentPeriod?.scheduleId === status.current?.scheduleId &&
|
||||
currentPeriod?.endTime === status.current?.endTime;
|
||||
currentPeriod = status.current;
|
||||
|
||||
if (!force && nextInClass === inClass) return;
|
||||
if (!force && nextInClass === inClass && samePeriod) return;
|
||||
inClass = nextInClass;
|
||||
|
||||
window.desktop?.islandSetClassState({
|
||||
inClass,
|
||||
currentPeriodEnd: currentPeriod?.endTime ?? null,
|
||||
week: appStore.currentWeek,
|
||||
schedules: schedules.filter((s) => s.dayOfWeek === (new Date().getDay() || 7)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getMaxTermWeeks } from '@classhelper/shared';
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
|
||||
@@ -65,12 +66,12 @@ export const useAppStore = defineStore('app', () => {
|
||||
const lastSyncAt = ref<number | null>(null);
|
||||
const cacheStoreStats = ref<CacheStoreStat[]>([]);
|
||||
const currentWeek = ref(1);
|
||||
const maxWeek = ref(20);
|
||||
const maxWeek = ref(getMaxTermWeeks());
|
||||
const initialized = ref(false);
|
||||
let termRequest = 0;
|
||||
onCacheScopeChanged(() => {
|
||||
currentWeek.value = 1;
|
||||
maxWeek.value = 20;
|
||||
maxWeek.value = getMaxTermWeeks();
|
||||
termRequest += 1;
|
||||
clearOfflineSince();
|
||||
});
|
||||
@@ -81,7 +82,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
const scope = getCacheScope();
|
||||
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(classId), {
|
||||
currentWeek: 1,
|
||||
maxWeek: 20,
|
||||
maxWeek: getMaxTermWeeks(),
|
||||
});
|
||||
if (request !== termRequest || scope !== getCacheScope() || (result.error && !result.updatedAt)) return;
|
||||
currentWeek.value = result.data.currentWeek;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { io, type Socket } from 'socket.io-client';
|
||||
import {
|
||||
PRIORITY_LABELS,
|
||||
SOCKET_EVENTS,
|
||||
type GradeDto,
|
||||
type GradeUpdateEvent,
|
||||
type HomeworkDto,
|
||||
type NotificationDto,
|
||||
type ServerToClientEvents,
|
||||
@@ -48,6 +48,9 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
||||
}
|
||||
|
||||
function bind(instance: Socket): void {
|
||||
instance.on(SOCKET_EVENTS.clientReleaseProgress, (payload) => {
|
||||
dispatch(SOCKET_EVENTS.clientReleaseProgress, payload);
|
||||
});
|
||||
instance.on('connect', () => {
|
||||
connected.value = true;
|
||||
connecting.value = false;
|
||||
@@ -106,9 +109,13 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
||||
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
|
||||
});
|
||||
|
||||
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeDto) => {
|
||||
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeUpdateEvent) => {
|
||||
mark(SOCKET_EVENTS.gradeUpdated);
|
||||
ElMessage.success(`成绩更新:${payload.examName} ${payload.score}/${payload.totalScore}`);
|
||||
ElMessage.success(
|
||||
'score' in payload
|
||||
? `成绩更新:${payload.examName} ${payload.score}/${payload.totalScore}`
|
||||
: '班级成绩分析已更新',
|
||||
);
|
||||
dispatch(SOCKET_EVENTS.gradeUpdated, payload);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { CollectiveGradeAnalysisDto as ContractCollectiveGradeAnalysisDto } from '@classhelper/shared';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
type GradeDto,
|
||||
type StudentGradeDetailDto,
|
||||
} from '@classhelper/shared';
|
||||
import { api } from '../api/http.js';
|
||||
import { gradeApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import ScoreBarChart from '../components/ScoreBarChart.vue';
|
||||
@@ -28,6 +31,7 @@ const realtime = useRealtimeStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const grades = ref<GradeDto[]>([]);
|
||||
const collective = ref<ContractCollectiveGradeAnalysisDto | null>(null);
|
||||
const fromCache = ref(false);
|
||||
const updatedAt = ref<number | null>(null);
|
||||
|
||||
@@ -106,6 +110,31 @@ const examAverages = computed(() => {
|
||||
async function loadGrades(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (auth.isClassSession) {
|
||||
const result = await fetchWithCache<
|
||||
ContractCollectiveGradeAnalysisDto | { mode: 'personal'; items: GradeDto[] }
|
||||
>('grades', 'overview', () => api.get('/grades/overview'), {
|
||||
mode: 'collective',
|
||||
classId: auth.classId || '',
|
||||
suppressed: true,
|
||||
averagePercent: null,
|
||||
passRate: null,
|
||||
excellentRate: null,
|
||||
subjects: [],
|
||||
distribution: [],
|
||||
trend: [],
|
||||
});
|
||||
collective.value = result.data.mode === 'collective' ? result.data : null;
|
||||
grades.value = result.data.mode === 'personal' ? result.data.items : [];
|
||||
if (collective.value) {
|
||||
queried.value = null;
|
||||
queryNo.value = '';
|
||||
ranking.value = null;
|
||||
}
|
||||
fromCache.value = result.fromCache;
|
||||
updatedAt.value = result.updatedAt;
|
||||
return;
|
||||
}
|
||||
const result = await fetchWithCache<GradeDto[]>('grades', 'my', () => gradeApi.my(), []);
|
||||
grades.value = result.data;
|
||||
fromCache.value = result.fromCache;
|
||||
@@ -134,6 +163,12 @@ const rankingLoading = ref(false);
|
||||
const rankingError = ref('');
|
||||
|
||||
async function loadExams(): Promise<void> {
|
||||
if (collective.value) {
|
||||
examList.value = [];
|
||||
selectedExam.value = '';
|
||||
ranking.value = null;
|
||||
return;
|
||||
}
|
||||
const classId = auth.user?.classId;
|
||||
if (!classId) return;
|
||||
try {
|
||||
@@ -180,18 +215,43 @@ onMounted(async () => {
|
||||
await loadGrades();
|
||||
await loadExams();
|
||||
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
|
||||
realtime.on(SOCKET_EVENTS.classUpdated, onGradeEvent);
|
||||
appStore.onServerRecovered(onRecovered);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
|
||||
realtime.off(SOCKET_EVENTS.classUpdated, onGradeEvent);
|
||||
appStore.offServerRecovered(onRecovered);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<el-card v-if="collective">
|
||||
<h2>全班集体成绩分析</h2>
|
||||
<p>个人成绩查询已关闭;只展示服务端聚合结果。人数不足或稀疏分段不展示。</p>
|
||||
<p>
|
||||
班级平均得分率:{{ collective.averagePercent ?? '样本不足' }} · 及格率:{{
|
||||
collective.passRate ?? '已隐藏'
|
||||
}}
|
||||
· 优秀率:{{ collective.excellentRate ?? '已隐藏' }}
|
||||
</p>
|
||||
<el-table :data="collective.subjects">
|
||||
<el-table-column prop="name" label="科目" />
|
||||
<el-table-column prop="averagePercent" label="平均得分率" />
|
||||
</el-table>
|
||||
<el-table :data="collective.distribution">
|
||||
<el-table-column prop="label" label="分数段" />
|
||||
<el-table-column prop="count" label="人数(稀疏时隐藏)" />
|
||||
</el-table>
|
||||
<el-table :data="collective.trend">
|
||||
<el-table-column prop="examName" label="考试趋势" />
|
||||
<el-table-column prop="averagePercent" label="平均得分率" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
<template v-if="!collective">
|
||||
<div v-if="!collective" class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">{{ auth.isClassSession ? '本班成绩' : '我的成绩' }}</h2>
|
||||
<p class="page-subtitle">
|
||||
@@ -407,6 +467,7 @@ onUnmounted(() => {
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import UpdateProgress from '../../components/UpdateProgress.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { UpdateInfo } from '@classhelper/shared';
|
||||
@@ -70,6 +71,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UpdateProgress />
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">关于</h2>
|
||||
|
||||
@@ -23,6 +23,18 @@ import {
|
||||
*/
|
||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
const savingIsland = ref(false);
|
||||
function editLayout() {
|
||||
void window.desktop?.islandEditLayout();
|
||||
}
|
||||
const layoutMode = ref<'fixed' | 'custom'>('fixed');
|
||||
async function changeLayout() {
|
||||
const config = await window.desktop?.getConfig();
|
||||
if (config)
|
||||
await window.desktop?.saveConfig({ islandLayout: { ...config.islandLayout, layout: layoutMode.value } });
|
||||
}
|
||||
onMounted(async () => {
|
||||
layoutMode.value = (await window.desktop?.getConfig())?.islandLayout.layout ?? 'fixed';
|
||||
});
|
||||
|
||||
/**
|
||||
* 左右边距是否有效:只在停靠左 / 右时生效。
|
||||
@@ -94,6 +106,14 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<h2 class="page-title">灵动岛</h2>
|
||||
|
||||
+12
-1
@@ -1,9 +1,15 @@
|
||||
import type {
|
||||
IslandLayoutSettings as ContractIslandLayoutSettings,
|
||||
ReleaseProgressDto as ContractReleaseProgressDto,
|
||||
ScheduleDto as ContractScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
/**
|
||||
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
|
||||
* 渲染进程只依赖这些最小 API,不接触 Node.js。
|
||||
*/
|
||||
import type { IslandAppearance, IslandNotification, IslandState, UpdateInfo } from '@classhelper/shared';
|
||||
import type { ClientAgentStatus } from '../main/client-agent.js';
|
||||
import type { IslandBridge } from '../preload/island.js';
|
||||
|
||||
/** 服务端联动状态:渲染进程要显示它,因此在这里转出(`desktop.d.ts` 是两端契约的单一入口) */
|
||||
export type { ClientAgentStatus };
|
||||
@@ -24,6 +30,7 @@ export interface HomeworkBoardSettings {
|
||||
}
|
||||
|
||||
export interface DesktopStoredConfig {
|
||||
islandLayout: ContractIslandLayoutSettings;
|
||||
/** 后端服务地址,例如 http://127.0.0.1:4000 */
|
||||
serverUrl: string;
|
||||
/** 上次登录的用户名(便于自动填充) */
|
||||
@@ -103,12 +110,16 @@ export interface IslandPushContext {
|
||||
}
|
||||
|
||||
export interface IslandClassStatePayload {
|
||||
schedules?: ContractScheduleDto[];
|
||||
inClass: boolean;
|
||||
currentPeriodEnd?: string | null;
|
||||
week?: number;
|
||||
}
|
||||
|
||||
export interface DesktopBridge {
|
||||
islandEditLayout(): Promise<void>;
|
||||
getUpdateProgress(): Promise<ContractReleaseProgressDto>;
|
||||
onUpdateProgress(handler: (progress: ContractReleaseProgressDto) => void): () => void;
|
||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
||||
smokeTest: boolean;
|
||||
/**
|
||||
@@ -199,7 +210,7 @@ export interface DesktopBridge {
|
||||
}
|
||||
|
||||
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
|
||||
export interface IslandRendererBridge {
|
||||
export interface IslandRendererBridge extends IslandBridge {
|
||||
onState(handler: (state: IslandState) => void): void;
|
||||
/** 订阅外观设置(CSS 变量实时生效) */
|
||||
onAppearance(handler: (appearance: IslandAppearance) => void): void;
|
||||
|
||||
Reference in new issue
Block a user