初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,585 @@
import {
announce,
createLocalState,
registerWorkspace,
setActiveButton,
} from "../core.js";
import { refreshColorTrigger, setColorValue } from "../color-picker.js";
import { hexToRgb, rgbToHex } from "../color-math.js";
import {
BRUSH_MODE_NORMAL,
BRUSH_MODE_PIXEL,
PixelUndoHistory,
brushModeSpec,
brushStampPoints,
clampCanvasView,
clampFloatingButton,
linePoints,
normalizeBrushSize,
restoreCanvasToolState,
zoomCanvasView,
} from "../canvas-tools-model.js";
import { setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import {
boardPoint,
commitPixelChanges,
fillPixelLayer,
getBoardOverlay,
getInteractionMode,
getPixelBuffer,
renderPixelDraft,
} from "../scene-board.js";
const canvas = getBoardOverlay();
const localState = createLocalState("canvas-tools", 2);
const previousLocalState = createLocalState("canvas-tools", 1).load(null);
const legacyState = createLocalState("canvas", 3).load(null)
|| createLocalState("canvas", 2).load(null)
|| {};
const toolButtons = Array.from(document.querySelectorAll("[data-canvas-tool]"));
const paintColor = document.getElementById("paint-color");
const backgroundColor = document.getElementById("background-color");
const brushSizeInput = document.getElementById("brush-size");
const brushSizePresets = Array.from(document.querySelectorAll("[data-brush-size]"));
const pixelEditToggle = document.getElementById("pixel-edit-toggle");
const pixelEditStatus = document.getElementById("pixel-edit-status");
const pixelGrid = document.getElementById("pixel-grid");
const boardStage = document.getElementById("board-stage");
const canvasWorkbench = document.querySelector(".canvas-workbench");
const startEditButton = document.getElementById("start-canvas-edit");
const assistive = document.getElementById("canvas-edit-assistive");
const assistiveToggle = document.getElementById("canvas-assistive-toggle");
const assistiveMenu = document.getElementById("canvas-assistive-menu");
const editorModeHint = document.getElementById("canvas-edit-mode-hint");
const undoButton = document.getElementById("undo-canvas-edit");
const exitEditButton = document.getElementById("exit-canvas-edit");
const moveViewButton = document.getElementById("move-canvas-view");
const continueEditButton = document.getElementById("continue-canvas-edit");
const savedTools = restoreCanvasToolState(localState.load(null), previousLocalState || legacyState);
let tool = savedTools.tool;
let previousDrawingTool = tool;
let pixelEditEnabled = savedTools.pixelEditEnabled;
let normalBrushSize = savedTools.normalBrushSize;
let pixelBrushSize = savedTools.pixelBrushSize;
let drawing = false;
let drawingPointerId = null;
let lastPoint = null;
let drawingBefore = null;
let editorActive = false;
let editorMode = "draw";
let view = { scale: 1, x: 0, y: 0 };
let viewGesture = null;
let assistiveDrag = null;
let suppressAssistiveClick = false;
let assistivePosition = { x: 36, y: window.innerHeight / 2 };
const viewPointers = new Map();
const undoHistory = new PixelUndoHistory();
paintColor.value = savedTools.paintColor || paintColor.value;
backgroundColor.value = savedTools.backgroundColor || backgroundColor.value;
function brushMode() {
return pixelEditEnabled ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
}
function currentBrushSize() {
return pixelEditEnabled ? pixelBrushSize : normalBrushSize;
}
function setPixel(x, y, rgb) {
if (x < 0 || x > 63 || y < 0 || y > 63) return;
const offset = (y * 64 + x) * 3;
const pixels = getPixelBuffer();
pixels[offset] = rgb.r;
pixels[offset + 1] = rgb.g;
pixels[offset + 2] = rgb.b;
}
function activeRgb() {
return tool === "eraser" ? hexToRgb(backgroundColor.value) : hexToRgb(paintColor.value);
}
function stampBrush(centerX, centerY, rgb) {
brushStampPoints(centerX, centerY, currentBrushSize(), brushMode())
.forEach((point) => setPixel(point.x, point.y, rgb));
}
function paintLine(from, to) {
const rgb = activeRgb();
linePoints(from, to).forEach((point) => stampBrush(point.x, point.y, rgb));
}
function paintEvent(event) {
const samples = typeof event.getCoalescedEvents === "function" ? event.getCoalescedEvents() : [event];
for (const sample of samples.length ? samples : [event]) {
const point = boardPoint(sample);
paintLine(lastPoint || point, point);
lastPoint = point;
}
renderPixelDraft();
}
function pixelSnapshot() {
return Uint8Array.from(getPixelBuffer());
}
function updateUndoControl() {
if (undoHistory.size > 0) setControlReady(undoButton);
else setControlUnavailable(undoButton, "还没有可以撤销的像素操作。");
}
function clearUndoHistory() {
undoHistory.clear();
updateUndoControl();
}
function recordPixelOperation(before) {
const recorded = undoHistory.record(before, getPixelBuffer());
updateUndoControl();
return recorded;
}
function viewportBounds() {
return {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
baseSize: Math.max(1, boardStage.offsetWidth),
minVisible: 48,
};
}
function applyCanvasView(next = view) {
view = clampCanvasView(next, viewportBounds());
boardStage.style.setProperty("--canvas-view-scale", String(view.scale));
boardStage.style.setProperty("--canvas-view-x", `${view.x}px`);
boardStage.style.setProperty("--canvas-view-y", `${view.y}px`);
}
function resetCanvasView() {
view = { scale: 1, x: 0, y: 0 };
applyCanvasView();
}
function applyAssistivePosition(next = assistivePosition) {
assistivePosition = clampFloatingButton(next, {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
size: 56,
inset: 8,
});
assistive.style.setProperty("--assistive-x", `${assistivePosition.x}px`);
assistive.style.setProperty("--assistive-y", `${assistivePosition.y}px`);
assistive.dataset.menuHorizontal = assistivePosition.x > window.innerWidth / 2 ? "left" : "right";
assistive.dataset.menuVertical = assistivePosition.y > window.innerHeight / 2 ? "up" : "down";
}
function resetAssistivePosition() {
applyAssistivePosition({ x: 36, y: window.innerHeight / 2 });
}
function closeAssistiveMenu({ restoreFocus = false } = {}) {
assistiveMenu.hidden = true;
assistiveToggle.setAttribute("aria-expanded", "false");
assistiveToggle.setAttribute("aria-label", "打开画布编辑菜单");
if (restoreFocus && editorActive) assistiveToggle.focus();
}
function openAssistiveMenu() {
applyAssistivePosition();
assistiveMenu.hidden = false;
assistiveToggle.setAttribute("aria-expanded", "true");
assistiveToggle.setAttribute("aria-label", "关闭画布编辑菜单");
const firstAvailable = assistiveMenu.querySelector('button:not([aria-disabled="true"])');
firstAvailable?.focus();
}
function toggleAssistiveMenu() {
if (assistiveMenu.hidden) openAssistiveMenu();
else closeAssistiveMenu({ restoreFocus: true });
}
function updateEditorModeControls() {
const moving = editorMode === "move";
if (editorActive) document.body.dataset.canvasEditorMode = editorMode;
else delete document.body.dataset.canvasEditorMode;
editorModeHint.textContent = moving
? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。"
: "绘画模式:直接在画布上绘制。";
if (moving) {
setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。");
setControlReady(continueEditButton);
} else {
setControlReady(moveViewButton);
setControlUnavailable(continueEditButton, "当前已经是绘画模式。");
}
updateUndoControl();
}
function resetViewGesture() {
const points = Array.from(viewPointers.values());
if (points.length >= 2) {
const [first, second] = points;
viewGesture = {
kind: "pinch",
startView: { ...view },
startMidpoint: { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 },
startDistance: Math.max(1, Math.hypot(second.x - first.x, second.y - first.y)),
};
} else if (points.length === 1) {
viewGesture = { kind: "pan", startView: { ...view }, startPoint: { ...points[0] } };
} else {
viewGesture = null;
}
}
function setEditorMode(mode) {
if (!editorActive) return;
if (mode === "move") finishDrawing();
editorMode = mode === "move" ? "move" : "draw";
viewPointers.clear();
viewGesture = null;
updateEditorModeControls();
closeAssistiveMenu({ restoreFocus: true });
announce(editorMode === "move"
? "已进入移动画布视线模式:单指拖动,双指缩放"
: "已继续编辑画布");
}
function enterCanvasEditor() {
if (editorActive || getInteractionMode() !== "pixels") return;
editorActive = true;
editorMode = "draw";
canvas.dataset.canvasEditing = "true";
document.body.classList.add("canvas-edit-active");
assistive.hidden = false;
closeAssistiveMenu();
updateEditorModeControls();
window.requestAnimationFrame(() => {
resetCanvasView();
resetAssistivePosition();
assistiveToggle.focus();
});
announce("已进入全屏画布编辑;左侧悬浮按钮可撤销、移动视线或退出");
}
function exitCanvasEditor({ returnToTools = true } = {}) {
if (!editorActive) return;
finishDrawing();
editorActive = false;
editorMode = "draw";
viewPointers.clear();
viewGesture = null;
closeAssistiveMenu();
assistive.hidden = true;
delete canvas.dataset.canvasEditing;
document.body.classList.remove("canvas-edit-active");
delete document.body.dataset.canvasEditorMode;
boardStage.style.removeProperty("--canvas-view-scale");
boardStage.style.removeProperty("--canvas-view-x");
boardStage.style.removeProperty("--canvas-view-y");
view = { scale: 1, x: 0, y: 0 };
if (returnToTools) {
window.requestAnimationFrame(() => {
canvasWorkbench.scrollIntoView({ block: "start", behavior: "smooth" });
startEditButton.focus({ preventScroll: true });
});
}
announce("已退出画布编辑,可以重新选择颜色和画笔工具");
}
function undoPixelOperation() {
const previous = undoHistory.undo();
if (!previous) {
updateUndoControl();
return;
}
getPixelBuffer().set(previous);
commitPixelChanges("pixel-undo");
updateUndoControl();
announce("已撤销上一个像素操作");
}
function persistTools() {
localState.save({
paintColor: paintColor.value,
backgroundColor: backgroundColor.value,
tool,
pixelEditEnabled,
normalBrushSize,
pixelBrushSize,
});
}
function renderBrushSize() {
const mode = brushMode();
const brushSize = currentBrushSize();
const spec = brushModeSpec(mode);
brushSizeInput.min = String(spec.min);
brushSizeInput.max = String(spec.max);
brushSizeInput.value = String(brushSize);
document.getElementById("brush-size-value").textContent = pixelEditEnabled
? `${brushSize} × ${brushSize}`
: `${brushSize} px`;
const preview = document.getElementById("brush-size-preview");
const previewSize = Math.max(4, brushSize * 2);
preview.style.width = `${previewSize}px`;
preview.style.height = `${previewSize}px`;
preview.dataset.shape = pixelEditEnabled ? "square" : "round";
brushSizePresets.forEach((button, index) => {
const value = spec.presets[index];
button.dataset.brushSize = String(value);
button.textContent = String(value);
});
setActiveButton(
brushSizePresets,
brushSizePresets.find((button) => Number(button.dataset.brushSize) === brushSize),
);
pixelEditToggle.setAttribute("aria-pressed", String(pixelEditEnabled));
pixelEditToggle.textContent = pixelEditEnabled ? "退出单像素编辑" : "开启单像素编辑";
pixelEditStatus.hidden = !pixelEditEnabled;
pixelEditStatus.textContent = pixelEditEnabled
? `画布已按 64×64 方形像素分格,当前笔尖为 ${brushSize}×${brushSize}。`
: "";
}
function setBrushSize(value) {
const normalized = normalizeBrushSize(value, brushMode());
if (pixelEditEnabled) pixelBrushSize = normalized;
else normalBrushSize = normalized;
renderBrushSize();
persistTools();
}
function updatePixelGridVisibility() {
pixelGrid.hidden = !(pixelEditEnabled && getInteractionMode() === "pixels");
}
function togglePixelEditMode() {
pixelEditEnabled = !pixelEditEnabled;
renderBrushSize();
updatePixelGridVisibility();
persistTools();
announce(pixelEditEnabled
? `已进入单像素编辑模式:画布已按 64×64 方形像素分格,笔尖为 ${pixelBrushSize}×${pixelBrushSize} 正方形。`
: `已退出单像素编辑模式:恢复 ${normalBrushSize} px 圆形笔尖。`);
}
function selectTool(button) {
tool = button.dataset.canvasTool;
if (tool !== "eyedropper") previousDrawingTool = tool;
setActiveButton(toolButtons, button);
persistTools();
}
toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button)));
canvas.addEventListener("pointerdown", (event) => {
if (!editorActive || getInteractionMode() !== "pixels") return;
if (editorMode === "move") {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
canvas.setPointerCapture(event.pointerId);
resetViewGesture();
return;
}
if (drawing) return;
if (tool === "eyedropper") {
const point = boardPoint(event);
const offset = (point.y * 64 + point.x) * 3;
const pixels = getPixelBuffer();
setColorValue(paintColor, rgbToHex({
r: pixels[offset],
g: pixels[offset + 1],
b: pixels[offset + 2],
}), { recordHistory: true });
tool = previousDrawingTool;
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
persistTools();
announce(`已吸取像素层 (${point.x}, ${point.y}) 的颜色`);
return;
}
event.preventDefault();
drawing = true;
drawingPointerId = event.pointerId;
drawingBefore = pixelSnapshot();
lastPoint = boardPoint(event);
canvas.setPointerCapture(event.pointerId);
paintEvent(event);
});
canvas.addEventListener("pointermove", (event) => {
if (editorActive && editorMode === "move" && viewPointers.has(event.pointerId)) {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
const points = Array.from(viewPointers.values());
if (viewGesture?.kind === "pinch" && points.length >= 2) {
const [first, second] = points;
const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
const distance = Math.max(1, Math.hypot(second.x - first.x, second.y - first.y));
const scaled = zoomCanvasView(
viewGesture.startView,
viewGesture.startView.scale * (distance / viewGesture.startDistance),
viewGesture.startMidpoint,
viewportBounds(),
);
applyCanvasView({
...scaled,
x: scaled.x + midpoint.x - viewGesture.startMidpoint.x,
y: scaled.y + midpoint.y - viewGesture.startMidpoint.y,
});
} else if (viewGesture?.kind === "pan" && points.length === 1) {
applyCanvasView({
...viewGesture.startView,
x: viewGesture.startView.x + points[0].x - viewGesture.startPoint.x,
y: viewGesture.startView.y + points[0].y - viewGesture.startPoint.y,
});
}
return;
}
if (drawing && drawingPointerId === event.pointerId && editorActive
&& editorMode === "draw" && getInteractionMode() === "pixels") paintEvent(event);
});
function finishDrawing() {
if (!drawing) return;
drawing = false;
drawingPointerId = null;
lastPoint = null;
const changed = recordPixelOperation(drawingBefore);
drawingBefore = null;
if (changed) commitPixelChanges("pixel-draw");
persistTools();
}
function finishCanvasPointer(event) {
if (viewPointers.delete(event.pointerId)) {
resetViewGesture();
return;
}
if (drawingPointerId === event.pointerId) finishDrawing();
}
canvas.addEventListener("pointerup", finishCanvasPointer);
canvas.addEventListener("pointercancel", finishCanvasPointer);
canvas.addEventListener("lostpointercapture", finishCanvasPointer);
canvas.addEventListener("wheel", (event) => {
if (!editorActive || editorMode !== "move") return;
event.preventDefault();
const factor = Math.exp(-event.deltaY * 0.002);
applyCanvasView(zoomCanvasView(view, view.scale * factor, {
x: event.clientX,
y: event.clientY,
}, viewportBounds()));
}, { passive: false });
paintColor.addEventListener("input", persistTools);
backgroundColor.addEventListener("input", persistTools);
brushSizeInput.addEventListener("input", () => setBrushSize(brushSizeInput.value));
brushSizePresets.forEach((button) => button.addEventListener("click", () => setBrushSize(button.dataset.brushSize)));
pixelEditToggle.addEventListener("click", togglePixelEditMode);
document.getElementById("fill-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
const rgb = hexToRgb(backgroundColor.value);
fillPixelLayer([rgb.r, rgb.g, rgb.b]);
recordPixelOperation(before);
announce("背景颜色已填充到像素层,文字元素保持不变");
});
document.getElementById("clear-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
fillPixelLayer([0, 0, 0]);
recordPixelOperation(before);
announce("像素层已清空,文字元素保持不变");
});
startEditButton.addEventListener("click", enterCanvasEditor);
undoButton.addEventListener("click", undoPixelOperation);
exitEditButton.addEventListener("click", () => exitCanvasEditor());
moveViewButton.addEventListener("click", () => setEditorMode("move"));
continueEditButton.addEventListener("click", () => setEditorMode("draw"));
assistiveToggle.addEventListener("pointerdown", (event) => {
if (!editorActive) return;
event.preventDefault();
event.stopPropagation();
assistiveDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
origin: { ...assistivePosition },
moved: false,
};
assistiveToggle.setPointerCapture(event.pointerId);
});
assistiveToggle.addEventListener("pointermove", (event) => {
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const dx = event.clientX - assistiveDrag.startX;
const dy = event.clientY - assistiveDrag.startY;
if (Math.hypot(dx, dy) > 6) assistiveDrag.moved = true;
if (assistiveDrag.moved) {
closeAssistiveMenu();
applyAssistivePosition({ x: assistiveDrag.origin.x + dx, y: assistiveDrag.origin.y + dy });
}
});
function finishAssistiveDrag(event) {
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
suppressAssistiveClick = assistiveDrag.moved;
assistiveDrag = null;
event.stopPropagation();
}
assistiveToggle.addEventListener("pointerup", finishAssistiveDrag);
assistiveToggle.addEventListener("pointercancel", finishAssistiveDrag);
assistiveToggle.addEventListener("lostpointercapture", finishAssistiveDrag);
assistiveToggle.addEventListener("click", () => {
if (suppressAssistiveClick) {
suppressAssistiveClick = false;
return;
}
toggleAssistiveMenu();
});
document.addEventListener("keydown", (event) => {
if (!editorActive || event.key !== "Escape") return;
event.preventDefault();
if (!assistiveMenu.hidden) closeAssistiveMenu({ restoreFocus: true });
else exitCanvasEditor();
});
window.addEventListener("resize", () => {
if (!editorActive) return;
applyCanvasView();
applyAssistivePosition();
});
document.addEventListener("matrix:composition-applied", clearUndoHistory);
document.addEventListener("matrix:canvas-history-checkpoint", clearUndoHistory);
document.addEventListener("matrix:scene-replaced", clearUndoHistory);
refreshColorTrigger(paintColor);
refreshColorTrigger(backgroundColor);
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
renderBrushSize();
persistTools();
updateEditorModeControls();
registerWorkspace({
id: "canvas",
title: "像素画布",
elementId: "workspace-canvas",
order: 30,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter() {
renderPixelDraft();
updatePixelGridVisibility();
},
onLeave() {
exitCanvasEditor({ returnToTools: false });
finishDrawing();
pixelGrid.hidden = true;
},
});
@@ -0,0 +1,960 @@
import {
announce,
apiBlob,
apiJson,
registerWorkspace,
setActiveButton,
} from "../core.js";
import { refreshCurrentDisplayPreview } from "../scene-board.js";
import { refreshColorTrigger } from "../color-picker.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { renderUiCopy, renderUiValue } from "../ui-copy-runtime.js";
import {
LatestSeekDispatcher,
formatPlaybackTime,
projectPlaybackPosition,
} from "../current-display-playback.js";
const statusEls = {
orientation: document.getElementById("status-orientation"),
brightness: document.getElementById("status-brightness"),
currentContent: document.getElementById("topbar-current-content"),
cpu: document.getElementById("topbar-cpu"),
memory: document.getElementById("topbar-memory"),
lowVoltageWarning: document.getElementById("topbar-low-voltage-warning"),
voltage: document.getElementById("topbar-voltage"),
};
const fillButtons = Array.from(document.querySelectorAll("[data-test-fill]"));
const customFillButton = document.getElementById("custom-test-fill");
const customColor = document.getElementById("test-color");
const refreshStatusButton = document.getElementById("refresh-status");
const displayTestControls = document.getElementById("display-test-controls");
const testBrightness = document.getElementById("test-brightness");
const testBrightnessValue = document.getElementById("test-brightness-value");
const testBrightnessProtection = document.getElementById("test-brightness-protection");
const displayTestFeedback = document.getElementById("display-test-feedback");
const exitDisplayTest = document.getElementById("exit-display-test");
const storageEls = {
total: document.getElementById("device-total-space"),
free: document.getElementById("device-free-space"),
software: document.getElementById("device-software-size"),
templates: document.getElementById("device-template-size"),
feedback: document.getElementById("device-storage-feedback"),
};
const resourceDialog = document.getElementById("resource-details-dialog");
const resourceDialogClose = document.getElementById("resource-details-close");
const resourceDetailsMessage = document.getElementById("resource-details-message");
const resourceCoreList = document.getElementById("resource-core-list");
const resourceDetailEls = {
cpuAppCores: document.getElementById("resource-cpu-app-cores"),
cpuSinglePercent: document.getElementById("resource-cpu-single-percent"),
cpuAppPercent: document.getElementById("resource-cpu-app-percent"),
cpuTotalPercent: document.getElementById("resource-cpu-total-percent"),
memoryApp: document.getElementById("resource-memory-app"),
memoryAppPercent: document.getElementById("resource-memory-app-percent"),
memoryTotalPercent: document.getElementById("resource-memory-total-percent"),
};
const currentDisplayDialog = document.getElementById("current-display-dialog");
const currentDisplayDialogName = document.getElementById("current-display-dialog-name");
const currentDisplayDialogClose = document.getElementById("current-display-dialog-close");
const currentDisplayPreviewSurface = document.getElementById("current-display-preview-surface");
const currentDisplayPreviewImage = document.getElementById("current-display-preview-image");
const currentDisplayPreviewPlaceholder = document.getElementById("current-display-preview-placeholder");
const currentDisplayPlaybackControls = document.getElementById("current-display-playback-controls");
const currentDisplayProgress = document.getElementById("current-display-progress");
const currentDisplayProgressTime = document.getElementById("current-display-progress-time");
const currentDisplayPlayPause = document.getElementById("current-display-play-pause");
const currentDisplaySpeedButtons = Array.from(document.querySelectorAll("[data-playback-speed]"));
const currentDisplayPlaybackFeedback = document.getElementById("current-display-playback-feedback");
const currentDisplaySeekUnavailableReason = document.getElementById("current-display-seek-unavailable-reason");
let loadedOnce = false;
let lastStatusIdentity = null;
let latestResources = null;
let latestProtection = null;
let latestStatus = null;
let currentDisplayOpenIdentity = null;
let currentDisplayPlayback = null;
let currentDisplayPlaybackSampledAt = 0;
let currentDisplayPreviewTimer = null;
let currentDisplayPreviewPending = false;
let currentDisplayPreviewRequested = false;
let currentDisplayPreviewSequence = 0;
let currentDisplayPreviewUrl = null;
let currentDisplayAnimationFrame = null;
let currentDisplayDragging = false;
let currentDisplayFinalSeekPosition = null;
let currentDisplayControlPending = false;
let customTestColor = "#40A0FF";
let testActionPending = false;
let desiredTestBrightness = null;
let applyingTestBrightness = false;
let storageTimer = null;
const STORAGE_REFRESH_INTERVAL_MS = 300000;
const LOW_VOLTAGE_WARNING_TEXT = Object.freeze({
limiting: "电压过低,屏幕亮度限制",
critical: "电压过低,屏幕已经禁用,请充电",
});
function finiteNumber(value) {
if (value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function formatPercent(value) {
const number = finiteNumber(value);
return number === null ? "-" : `${number.toFixed(1)}%`;
}
function formatMemoryDetailed(bytes) {
const number = finiteNumber(bytes);
if (number === null || number < 0) return "-";
const mebibytes = number / (1024 * 1024);
return `${mebibytes.toFixed(1)} MiB`;
}
function formatMemoryCompact(bytes) {
const number = finiteNumber(bytes);
if (number === null || number < 0) return "-";
return `${Math.round(number / (1024 * 1024))}M`;
}
function formatCoreEquivalent(value) {
const number = finiteNumber(value);
return number === null ? "-" : `${number.toFixed(1)}核`;
}
function renderCoreMeters(cpu) {
resourceCoreList.replaceChildren();
const count = Number.isInteger(Number(cpu.logical_cpu_count))
? Math.max(0, Number(cpu.logical_cpu_count))
: 0;
const values = Array.isArray(cpu.cores_percent) ? cpu.cores_percent : [];
for (let index = 0; index < count; index += 1) {
const value = finiteNumber(values[index]);
const row = document.createElement("div");
row.className = "resource-core-row";
const label = document.createElement("span");
label.textContent = `CPU ${index + 1}`;
const progress = document.createElement("progress");
progress.max = 100;
progress.value = value === null ? 0 : Math.max(0, Math.min(100, value));
progress.setAttribute("aria-label", `CPU ${index + 1} 占用`);
const output = document.createElement("output");
output.textContent = formatPercent(value);
row.append(label, progress, output);
resourceCoreList.appendChild(row);
}
}
function updateResourceDetails(resources) {
const status = resources?.status || "starting";
const cpu = resources?.cpu || {};
const memory = resources?.memory || {};
const coreCount = finiteNumber(cpu.logical_cpu_count);
const coreEquivalent = finiteNumber(cpu.application_core_equivalent);
resourceDetailEls.cpuAppCores.textContent = coreEquivalent === null
? "-"
: `${coreEquivalent.toFixed(2)} / ${coreCount ?? "-"} 核`;
resourceDetailEls.cpuSinglePercent.textContent = coreEquivalent === null
? "-"
: formatPercent(coreEquivalent * 100);
resourceDetailEls.cpuAppPercent.textContent = formatPercent(cpu.application_percent);
resourceDetailEls.cpuTotalPercent.textContent = formatPercent(cpu.total_percent);
resourceDetailEls.memoryApp.textContent = formatMemoryDetailed(memory.application_bytes);
resourceDetailEls.memoryAppPercent.textContent = formatPercent(memory.application_percent);
resourceDetailEls.memoryTotalPercent.textContent = formatPercent(memory.total_percent);
renderCoreMeters(cpu);
if (status === "error") {
resourceDetailsMessage.textContent = "资源读取异常,屏幕、电压和其他控制功能不受影响。";
} else if (status !== "ok") {
resourceDetailsMessage.textContent = "正在收集资源样本…";
} else if (coreEquivalent === null) {
resourceDetailsMessage.textContent = "正在等待第二个 CPU 差分样本;内存数据已可用。";
} else {
resourceDetailsMessage.textContent = "";
}
resourceDetailsMessage.hidden = resourceDetailsMessage.textContent === "";
}
function showTopbarResources(resources) {
latestResources = resources || null;
const status = resources?.status || "starting";
statusEls.cpu.dataset.status = status;
statusEls.memory.dataset.status = status;
if (status === "error") {
statusEls.cpu.textContent = "CPU !";
statusEls.memory.textContent = "RAM !";
statusEls.cpu.setAttribute("aria-label", "CPU 读取异常,查看资源占用详情");
statusEls.memory.setAttribute("aria-label", "内存读取异常,查看资源占用详情");
updateResourceDetails(resources);
return;
}
const cpu = resources?.cpu || {};
const memory = resources?.memory || {};
const cpuCompact = finiteNumber(cpu.application_core_equivalent) === null
? "CPU -"
: `CPU ${formatCoreEquivalent(cpu.application_core_equivalent)} · ${formatPercent(cpu.total_percent)}`;
const memoryCompact = finiteNumber(memory.application_bytes) === null
? "RAM -"
: `RAM ${formatMemoryCompact(memory.application_bytes)} · ${formatPercent(memory.total_percent)}`;
statusEls.cpu.textContent = cpuCompact;
statusEls.memory.textContent = memoryCompact;
statusEls.cpu.setAttribute("aria-label", `${cpuCompact},查看 CPU 占用详情`);
statusEls.memory.setAttribute("aria-label", `${memoryCompact},查看内存占用详情`);
updateResourceDetails(resources);
}
function openResourceDetails() {
updateResourceDetails(latestResources);
if (!resourceDialog.open) resourceDialog.showModal();
resourceDialogClose.focus();
}
statusEls.cpu.addEventListener("click", openResourceDetails);
statusEls.memory.addEventListener("click", openResourceDetails);
function activeCurrentDisplayPlayback(status = latestStatus) {
const content = status?.state?.current_content;
const playback = status?.state?.animation_playback;
if (
content?.category !== "animation"
|| playback?.active !== true
|| typeof playback.session_id !== "string"
|| playback.animation_id !== content.id
) return null;
return playback;
}
function currentDisplayIdentity(status = latestStatus) {
const content = status?.state?.current_content || {};
const playback = activeCurrentDisplayPlayback(status);
return [
content.category || "unsaved",
content.id || "",
content.name || "未保存内容",
playback?.session_id || "",
].join(":");
}
function setCurrentDisplayPlaybackFeedback(copyId = "", defaultText = "", values = {}, state = "idle") {
if (copyId) renderUiCopy(copyId, currentDisplayPlaybackFeedback, defaultText, values);
else renderUiValue(currentDisplayPlaybackFeedback, "");
currentDisplayPlaybackFeedback.dataset.state = state;
currentDisplayPlaybackFeedback.hidden = !defaultText;
}
function syncCurrentDisplayControlAvailability() {
const controls = [currentDisplayPlayPause, ...currentDisplaySpeedButtons];
if (currentDisplayControlPending) {
[currentDisplayProgress, ...controls].forEach((control) => setControlBusy(control));
return;
}
setControlReady(currentDisplayProgress);
if (currentDisplayDragging) {
const reason = currentDisplaySeekUnavailableReason.textContent || "请先完成当前进度跳转。";
controls.forEach((control) => setControlUnavailable(control, reason));
} else {
controls.forEach((control) => setControlReady(control));
}
}
function renderCurrentDisplayPosition(positionMs, { force = false } = {}) {
if (!currentDisplayPlayback) return;
const total = Math.max(1, Number(currentDisplayPlayback.total_duration_ms) || 1);
const position = Math.max(0, Math.min(total - 1, Number(positionMs) || 0));
if (force || !currentDisplayDragging) currentDisplayProgress.value = String(Math.floor(position));
currentDisplayProgressTime.textContent = `${formatPlaybackTime(position)} / ${formatPlaybackTime(total)}`;
}
function applyCurrentDisplayPlayback(playback, { preservePosition = currentDisplayDragging } = {}) {
if (!playback?.active) return;
currentDisplayPlayback = { ...playback };
currentDisplayPlaybackSampledAt = performance.now();
const total = Math.max(1, Number(playback.total_duration_ms) || 1);
currentDisplayProgress.max = String(total - 1);
renderUiCopy(
playback.paused ? "copy.dynamic.current_display.resume" : "copy.dynamic.current_display.pause",
currentDisplayPlayPause,
playback.paused ? "继续播放" : "暂停",
);
currentDisplayPlayPause.setAttribute("aria-pressed", String(Boolean(playback.paused)));
currentDisplaySpeedButtons.forEach((button) => {
button.setAttribute("aria-pressed", String(Number(button.dataset.playbackSpeed) === Number(playback.speed)));
});
if (!preservePosition) renderCurrentDisplayPosition(playback.position_ms, { force: true });
syncCurrentDisplayControlAvailability();
if (currentDisplayAnimationFrame === null) {
currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress);
}
}
function animateCurrentDisplayProgress(now) {
currentDisplayAnimationFrame = null;
if (!currentDisplayDialog.open || currentDisplayPlaybackControls.hidden || !currentDisplayPlayback) return;
if (!currentDisplayDragging) {
renderCurrentDisplayPosition(
projectPlaybackPosition(currentDisplayPlayback, now - currentDisplayPlaybackSampledAt),
);
}
currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress);
}
function scheduleCurrentDisplayPreview(delay = null) {
if (!currentDisplayDialog.open || document.visibilityState !== "visible") return;
if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer);
const configured = Number(latestStatus?.ui?.preview_refresh_interval_ms);
const wait = delay === null ? Math.max(1, Number.isFinite(configured) ? configured : 1000) : delay;
currentDisplayPreviewTimer = window.setTimeout(() => {
currentDisplayPreviewTimer = null;
refreshCurrentDisplayDialogFrame();
}, wait);
}
async function refreshCurrentDisplayDialogFrame() {
if (!currentDisplayDialog.open || document.visibilityState !== "visible") return;
if (currentDisplayPreviewPending) {
currentDisplayPreviewRequested = true;
return;
}
currentDisplayPreviewPending = true;
currentDisplayPreviewRequested = false;
const sequence = currentDisplayPreviewSequence;
try {
const blob = await apiBlob("/api/display/current-frame");
if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return;
const nextUrl = URL.createObjectURL(blob);
const previousUrl = currentDisplayPreviewUrl;
currentDisplayPreviewUrl = nextUrl;
currentDisplayPreviewImage.src = nextUrl;
currentDisplayPreviewImage.hidden = false;
currentDisplayPreviewPlaceholder.hidden = true;
if (previousUrl !== null) URL.revokeObjectURL(previousUrl);
} catch (error) {
if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return;
currentDisplayPreviewImage.hidden = true;
currentDisplayPreviewPlaceholder.hidden = false;
renderUiCopy(
"copy.dynamic.current_display.load_failed",
currentDisplayPreviewPlaceholder,
"当前画面读取失败:{reason}",
{ reason: error.message },
);
} finally {
currentDisplayPreviewPending = false;
if (!currentDisplayDialog.open) return;
if (sequence !== currentDisplayPreviewSequence) {
if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0);
return;
}
if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0);
else scheduleCurrentDisplayPreview();
}
}
function requestCurrentDisplayPreviewRefresh() {
if (currentDisplayPreviewPending) {
currentDisplayPreviewRequested = true;
return;
}
scheduleCurrentDisplayPreview(0);
}
function cleanupCurrentDisplayDialog() {
currentDisplayOpenIdentity = null;
currentDisplayPlayback = null;
currentDisplayDragging = false;
currentDisplayFinalSeekPosition = null;
currentDisplayControlPending = false;
currentDisplayPreviewSequence += 1;
currentDisplayPreviewRequested = false;
seekDispatcher.clear();
if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer);
currentDisplayPreviewTimer = null;
if (currentDisplayAnimationFrame !== null) window.cancelAnimationFrame(currentDisplayAnimationFrame);
currentDisplayAnimationFrame = null;
if (currentDisplayPreviewUrl !== null) URL.revokeObjectURL(currentDisplayPreviewUrl);
currentDisplayPreviewUrl = null;
currentDisplayPreviewImage.removeAttribute("src");
currentDisplayPreviewImage.hidden = true;
currentDisplayPreviewPlaceholder.hidden = false;
renderUiCopy(
"copy.dynamic.current_display.loading",
currentDisplayPreviewPlaceholder,
"正在读取当前画面…",
);
setCurrentDisplayPlaybackFeedback();
}
function closeCurrentDisplayDialog() {
if (currentDisplayDialog.open) currentDisplayDialog.close();
else cleanupCurrentDisplayDialog();
}
async function openCurrentDisplayDialog() {
if (currentDisplayDialog.open) return;
setControlBusy(statusEls.currentContent);
try {
await refreshStatus({ source: "manual" });
} catch (_error) {
if (!latestStatus) return;
} finally {
setControlReady(statusEls.currentContent);
}
const content = latestStatus?.state?.current_content || { name: "未保存内容" };
const playback = activeCurrentDisplayPlayback();
currentDisplayOpenIdentity = currentDisplayIdentity();
currentDisplayDialogName.textContent = content.name || "未保存内容";
currentDisplayPlaybackControls.hidden = !playback;
currentDisplayDialog.showModal();
currentDisplayPreviewSequence += 1;
if (playback) applyCurrentDisplayPlayback(playback, { preservePosition: false });
refreshCurrentDisplayDialogFrame();
if (playback) currentDisplayProgress.focus();
else currentDisplayPreviewSurface.focus();
}
function syncCurrentDisplayDialogStatus(status) {
latestStatus = status;
if (!currentDisplayDialog.open) return;
const identity = currentDisplayIdentity(status);
if (identity !== currentDisplayOpenIdentity) {
closeCurrentDisplayDialog();
return;
}
const playback = activeCurrentDisplayPlayback(status);
if (playback) applyCurrentDisplayPlayback(playback);
}
async function sendCurrentDisplayPlaybackPatch(fields) {
const sessionId = currentDisplayPlayback?.session_id;
if (!sessionId) throw new Error("当前动图播放会话已经结束");
const result = await apiJson("/api/display/animation-playback", {
method: "PATCH",
body: JSON.stringify({ session_id: sessionId, ...fields }),
});
const playback = result.animation_playback;
if (
currentDisplayDialog.open
&& currentDisplayPlayback?.session_id === sessionId
&& playback?.session_id === sessionId
) {
if (latestStatus?.state) latestStatus.state.animation_playback = playback;
if (Number.isInteger(result.state_revision) && latestStatus?.service?.instance_id) {
lastStatusIdentity = `${latestStatus.service.instance_id}:${result.state_revision}`;
}
applyCurrentDisplayPlayback(playback);
requestCurrentDisplayPreviewRefresh();
}
return playback;
}
function handleCurrentDisplayPlaybackError(error) {
const defaultText = "动图播放控制失败:{reason}";
setCurrentDisplayPlaybackFeedback(
"copy.dynamic.current_display.control_failed",
defaultText,
{ reason: error.message },
"error",
);
announce(`动图播放控制失败:${error.message}`, true);
if (error.status === 409) {
closeCurrentDisplayDialog();
refreshStatus({ source: "manual" }).catch(() => {});
}
}
async function sendCurrentDisplaySeek(positionMs) {
const playback = await sendCurrentDisplayPlaybackPatch({ position_ms: Math.round(positionMs) });
if (currentDisplayFinalSeekPosition === positionMs) {
currentDisplayDragging = false;
currentDisplayFinalSeekPosition = null;
applyCurrentDisplayPlayback(playback, { preservePosition: false });
syncCurrentDisplayControlAvailability();
}
}
const seekDispatcher = new LatestSeekDispatcher(sendCurrentDisplaySeek, {
intervalMs: 100,
onError: (error, positionMs) => {
if (currentDisplayFinalSeekPosition === positionMs) {
currentDisplayDragging = false;
currentDisplayFinalSeekPosition = null;
syncCurrentDisplayControlAvailability();
}
handleCurrentDisplayPlaybackError(error);
},
});
statusEls.currentContent.addEventListener("click", openCurrentDisplayDialog);
currentDisplayDialogClose.addEventListener("click", closeCurrentDisplayDialog);
currentDisplayDialog.addEventListener("close", cleanupCurrentDisplayDialog);
currentDisplayDialog.addEventListener("click", (event) => {
if (currentDisplayPlaybackControls.hidden || event.target === currentDisplayDialog) {
closeCurrentDisplayDialog();
}
});
currentDisplayProgress.addEventListener("pointerdown", () => {
currentDisplayDragging = true;
currentDisplayFinalSeekPosition = null;
syncCurrentDisplayControlAvailability();
});
currentDisplayProgress.addEventListener("input", () => {
currentDisplayDragging = true;
currentDisplayFinalSeekPosition = null;
const position = Number(currentDisplayProgress.value);
renderCurrentDisplayPosition(position, { force: true });
syncCurrentDisplayControlAvailability();
seekDispatcher.enqueue(position);
});
function flushCurrentDisplaySeek() {
if (!currentDisplayDragging) return;
const position = Number(currentDisplayProgress.value);
currentDisplayFinalSeekPosition = position;
seekDispatcher.enqueue(position, { flush: true });
}
currentDisplayProgress.addEventListener("change", flushCurrentDisplaySeek);
currentDisplayProgress.addEventListener("pointerup", flushCurrentDisplaySeek);
currentDisplayProgress.addEventListener("pointercancel", flushCurrentDisplaySeek);
currentDisplayPlayPause.addEventListener("click", async () => {
if (!currentDisplayPlayback || currentDisplayControlPending || currentDisplayDragging) return;
currentDisplayControlPending = true;
syncCurrentDisplayControlAvailability();
setCurrentDisplayPlaybackFeedback(
currentDisplayPlayback.paused
? "copy.dynamic.current_display.resuming"
: "copy.dynamic.current_display.pausing",
currentDisplayPlayback.paused ? "正在继续播放…" : "正在暂停…",
{},
"pending",
);
try {
const playback = await sendCurrentDisplayPlaybackPatch({ paused: !currentDisplayPlayback.paused });
setCurrentDisplayPlaybackFeedback(
playback.paused
? "copy.dynamic.current_display.paused"
: "copy.dynamic.current_display.resumed",
playback.paused ? "动图已暂停" : "动图继续播放",
{},
"success",
);
} catch (error) {
handleCurrentDisplayPlaybackError(error);
} finally {
currentDisplayControlPending = false;
syncCurrentDisplayControlAvailability();
}
});
currentDisplaySpeedButtons.forEach((button) => {
button.addEventListener("click", async () => {
const speed = Number(button.dataset.playbackSpeed);
if (
!currentDisplayPlayback
|| currentDisplayControlPending
|| currentDisplayDragging
|| speed === Number(currentDisplayPlayback.speed)
) return;
currentDisplayControlPending = true;
syncCurrentDisplayControlAvailability();
setCurrentDisplayPlaybackFeedback(
"copy.dynamic.current_display.speed_changing",
"正在切换到 {speed}×…",
{ speed },
"pending",
);
try {
await sendCurrentDisplayPlaybackPatch({ speed });
setCurrentDisplayPlaybackFeedback(
"copy.dynamic.current_display.speed_changed",
"已切换到 {speed}×",
{ speed },
"success",
);
} catch (error) {
handleCurrentDisplayPlaybackError(error);
} finally {
currentDisplayControlPending = false;
syncCurrentDisplayControlAvailability();
}
});
});
document.addEventListener("visibilitychange", () => {
if (!currentDisplayDialog.open) return;
if (document.visibilityState === "visible") requestCurrentDisplayPreviewRefresh();
else if (currentDisplayPreviewTimer !== null) {
window.clearTimeout(currentDisplayPreviewTimer);
currentDisplayPreviewTimer = null;
}
});
function showTopbarVoltage(voltage) {
const status = voltage?.status || "starting";
statusEls.voltage.dataset.status = status;
if (status === "ok" && Number.isFinite(Number(voltage.volts))) {
renderUiCopy(
"copy.dynamic.device.voltage_ok",
statusEls.voltage,
"屏幕输入电压 {volts} V",
{ volts: Number(voltage.volts).toFixed(2) },
);
} else if (status === "disconnected") {
renderUiCopy("copy.dynamic.device.voltage_disconnected", statusEls.voltage, "电压传感器断开");
} else if (status === "error") {
renderUiCopy("copy.dynamic.device.voltage_error", statusEls.voltage, "电压读取异常");
} else {
renderUiCopy("copy.dynamic.device.voltage_loading", statusEls.voltage, "正在读取电压…");
}
}
function showCurrentContent(state) {
const name = typeof state?.current_content?.name === "string"
? state.current_content.name
: "未保存内容";
renderUiCopy(
"copy.dynamic.device.current_content",
statusEls.currentContent,
"当前画面:{name}",
{ name },
);
}
function showTopbarLowVoltageProtection(protection) {
const requestedMode = typeof protection?.mode === "string" ? protection.mode : "unavailable";
const warningMode = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, requestedMode);
const mode = warningMode
? requestedMode
: ["disabled", "unavailable", "inactive"].includes(requestedMode)
? requestedMode
: "unavailable";
const previousMode = statusEls.lowVoltageWarning.dataset.mode;
const visible = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, mode);
statusEls.lowVoltageWarning.dataset.mode = mode;
if (!visible) {
statusEls.lowVoltageWarning.hidden = true;
if (previousMode !== mode) statusEls.lowVoltageWarning.textContent = "";
return;
}
if (previousMode !== mode) {
statusEls.lowVoltageWarning.textContent = LOW_VOLTAGE_WARNING_TEXT[mode];
}
statusEls.lowVoltageWarning.hidden = false;
}
function showDeviceBrightness(state) {
const userBrightness = finiteNumber(state?.brightness);
const effectiveBrightness = finiteNumber(state?.effective_brightness);
if (userBrightness === null) {
statusEls.brightness.textContent = "-";
return;
}
if (effectiveBrightness === null) {
statusEls.brightness.textContent = `${userBrightness}%`;
return;
}
statusEls.brightness.textContent = `用户 ${userBrightness}% · 实际 ${effectiveBrightness}%`;
}
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) return "-";
if (bytes < 1024) return `${bytes} B`;
const units = ["KiB", "MiB", "GiB", "TiB"];
let size = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && size >= 1024; index += 1) {
size /= 1024;
unit = units[index];
}
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`;
}
function normalizedHex(value) {
const match = /^#([0-9A-Fa-f]{6})$/.exec(String(value || "").trim());
return match ? `#${match[1].toUpperCase()}` : null;
}
function showTestColor(value, { editable = false } = {}) {
const color = normalizedHex(value) || customTestColor;
customColor.value = color;
customColor.dataset.colorReadonly = String(!editable);
customColor.setAttribute("aria-label", editable ? "编辑自定义测试颜色" : "查看测试颜色");
refreshColorTrigger(customColor);
}
function setDisplayTestFeedback(text = "", state = "idle") {
displayTestFeedback.textContent = text;
displayTestFeedback.dataset.state = state;
displayTestFeedback.hidden = !text;
}
function showTestProtection(testValue, effectiveValue) {
const mode = latestProtection?.mode;
if (mode !== "limiting" && mode !== "critical") {
testBrightnessProtection.hidden = true;
testBrightnessProtection.textContent = "";
return;
}
testBrightnessProtection.dataset.mode = mode;
if (mode === "critical") {
testBrightnessProtection.textContent = "电压过低,纯色测试暂时由 35% 亮度的低电图标覆盖;退出或恢复电压前,测试颜色和亮度会保留。";
} else {
const limit = finiteNumber(latestProtection?.brightness_limit_percent);
const effective = finiteNumber(effectiveValue);
testBrightnessProtection.textContent = `电压过低,当前允许的最高亮度为 ${limit ?? "-"}%;测试设置 ${testValue}%,实际 ${effective ?? "-"}%。`;
}
testBrightnessProtection.hidden = false;
}
function syncDisplayTest(state, protection = latestProtection) {
latestProtection = protection || null;
const displayTest = state?.display_test || {};
const active = displayTest.active === true;
displayTestControls.hidden = !active;
if (!active) {
setActiveButton(fillButtons, null);
customFillButton.classList.remove("active");
customFillButton.setAttribute("aria-pressed", "false");
showTestColor(customTestColor);
showTestProtection(50, state?.effective_brightness);
return;
}
const color = normalizedHex(displayTest.color) || customTestColor;
const brightness = Math.max(1, Math.min(100, Number(displayTest.brightness) || 50));
const matchingFixed = fillButtons.find((button) => button.dataset.testFill === color) || null;
setActiveButton(fillButtons, matchingFixed);
customFillButton.classList.toggle("active", !matchingFixed);
customFillButton.setAttribute("aria-pressed", String(!matchingFixed));
testBrightness.value = String(brightness);
testBrightnessValue.textContent = `${brightness}%`;
showTestColor(color, { editable: !matchingFixed });
showTestProtection(brightness, state?.effective_brightness);
}
export async function refreshDeviceStorage() {
storageEls.feedback.textContent = "正在读取设备容量…";
storageEls.feedback.dataset.state = "pending";
storageEls.feedback.hidden = false;
try {
const data = await apiJson("/api/device-storage");
storageEls.total.textContent = formatBytes(data.device_total_bytes);
storageEls.free.textContent = formatBytes(data.device_free_bytes);
storageEls.software.textContent = formatBytes(data.software_bytes);
storageEls.templates.textContent = formatBytes(data.templates_bytes);
storageEls.feedback.textContent = "";
storageEls.feedback.hidden = true;
return data;
} catch (error) {
storageEls.feedback.textContent = `容量读取失败:${error.message}`;
storageEls.feedback.dataset.state = "error";
announce(storageEls.feedback.textContent, true);
throw error;
}
}
export async function refreshStatus({ source = "manual" } = {}) {
try {
const data = await apiJson("/api/status");
syncCurrentDisplayDialogStatus(data);
statusEls.orientation.textContent = `${data.state.orientation}°`;
showDeviceBrightness(data.state);
showCurrentContent(data.state);
showTopbarResources(data.resources);
showTopbarLowVoltageProtection(data.power?.low_voltage_protection);
showTopbarVoltage(data.power?.screen_input_voltage);
syncDisplayTest(data.state, data.power?.low_voltage_protection);
const statusIdentity = `${data.service?.instance_id || "legacy"}:${data.state.revision ?? "legacy"}`;
const externalChange = source === "poll" && lastStatusIdentity !== null && statusIdentity !== lastStatusIdentity;
lastStatusIdentity = statusIdentity;
document.dispatchEvent(new CustomEvent("matrix:status", {
detail: { ...data, externalChange },
}));
if (externalChange) {
announce("设备状态已由其他页面更新;本页画板未被自动应用");
}
return data;
} catch (error) {
if (source !== "poll") announce(`状态读取失败:${error.message}`, true);
throw error;
}
}
fillButtons.forEach((button) => {
button.addEventListener("click", () => startDisplayTest(
button,
button.dataset.testFill,
button.textContent.trim(),
));
});
async function startDisplayTest(button, color, label, { openEditor = false } = {}) {
if (testActionPending) return;
testActionPending = true;
[...fillButtons, customFillButton].forEach((control) => setControlBusy(control));
setDisplayTestFeedback(`正在显示${label}…`, "pending");
try {
await apiJson("/api/display/test/fill", {
method: "POST",
body: JSON.stringify({ color }),
});
showTestColor(color, { editable: openEditor });
setDisplayTestFeedback(`${label}测试已开始`, "success");
announce(`${label}测试已开始`);
refreshCurrentDisplayPreview();
await refreshStatus();
if (openEditor) {
showTestColor(color, { editable: true });
customColor.click();
}
} catch (error) {
setDisplayTestFeedback(`纯色测试失败:${error.message}`, "error");
announce(`纯色测试失败:${error.message}`, true);
} finally {
testActionPending = false;
[...fillButtons, customFillButton].forEach((control) => setControlReady(control));
}
}
customFillButton.addEventListener("click", () => startDisplayTest(
customFillButton,
customTestColor,
"自定义纯色",
{ openEditor: true },
));
customColor.addEventListener("change", async () => {
if (customColor.dataset.colorReadonly === "true") return;
const requested = normalizedHex(customColor.value);
if (!requested || requested === customTestColor) return;
setControlBusy(customColor, { message: "正在保存并应用自定义纯色…" });
setDisplayTestFeedback("正在保存并应用自定义纯色…", "pending");
try {
const config = await apiJson("/api/config", {
method: "PUT",
body: JSON.stringify({ custom_test_color: requested }),
});
customTestColor = normalizedHex(config.custom_test_color) || requested;
await apiJson("/api/display/test/fill", {
method: "POST",
body: JSON.stringify({ color: customTestColor }),
});
setDisplayTestFeedback(`自定义纯色 ${customTestColor} 已保存并应用`, "success");
announce(`自定义纯色 ${customTestColor} 已保存并应用`);
refreshCurrentDisplayPreview();
await refreshStatus();
} catch (error) {
setDisplayTestFeedback(`自定义纯色保存或应用失败:${error.message}`, "error");
announce(displayTestFeedback.textContent, true);
await refreshStatus().catch(() => {});
} finally {
setControlReady(customColor);
}
});
async function processTestBrightnessQueue() {
if (applyingTestBrightness) return;
applyingTestBrightness = true;
while (desiredTestBrightness !== null) {
const requested = desiredTestBrightness;
desiredTestBrightness = null;
setDisplayTestFeedback(`正在应用测试亮度 ${requested}%…`, "pending");
try {
const response = await apiJson("/api/display/test/brightness", {
method: "PUT",
body: JSON.stringify({ brightness: requested }),
});
const applied = Number(response.display_test?.brightness) || requested;
testBrightness.value = String(applied);
testBrightnessValue.textContent = `${applied}%`;
showTestProtection(applied, response.effective_brightness);
setDisplayTestFeedback(`测试亮度 ${applied}% 已生效,不会修改用户亮度`, "success");
} catch (error) {
setDisplayTestFeedback(`测试亮度应用失败:${error.message}`, "error");
announce(displayTestFeedback.textContent, true);
if (desiredTestBrightness === null) await refreshStatus().catch(() => {});
}
}
applyingTestBrightness = false;
refreshCurrentDisplayPreview();
await refreshStatus().catch(() => {});
}
function queueTestBrightness(value) {
desiredTestBrightness = Math.max(1, Math.min(100, Number(value)));
void processTestBrightnessQueue();
}
testBrightness.addEventListener("input", () => {
testBrightnessValue.textContent = `${testBrightness.value}%`;
showTestProtection(testBrightness.value, null);
queueTestBrightness(testBrightness.value);
});
testBrightness.addEventListener("change", () => queueTestBrightness(testBrightness.value));
exitDisplayTest.addEventListener("click", async () => {
setControlBusy(exitDisplayTest, { label: "正在退出…", message: "正在退出纯色测试…" });
setDisplayTestFeedback("正在退出纯色测试…", "pending");
try {
await apiJson("/api/display/test", { method: "DELETE" });
setDisplayTestFeedback();
announce("已退出纯色测试,恢复进入测试前的设备状态");
refreshCurrentDisplayPreview();
await refreshStatus();
} catch (error) {
setDisplayTestFeedback(`退出测试失败:${error.message}`, "error");
announce(displayTestFeedback.textContent, true);
} finally {
setControlReady(exitDisplayTest);
}
});
refreshStatusButton.addEventListener("click", async () => {
setControlBusy(refreshStatusButton, { label: "正在刷新…" });
announce("正在刷新设备状态…");
try {
await refreshStatus();
announce("设备状态已刷新");
} catch (_error) {
// refreshStatus already reports the failure.
} finally {
setControlReady(refreshStatusButton);
}
});
registerWorkspace({
id: "device",
title: "设备状态和测试",
elementId: "workspace-device",
order: 10,
persistence: "none",
async onEnter() {
if (!loadedOnce) {
loadedOnce = true;
try {
const config = await apiJson("/api/config");
customTestColor = normalizedHex(config.custom_test_color) || customTestColor;
showTestColor(customTestColor);
} catch (error) {
announce(`自定义纯色读取失败:${error.message}`, true);
}
}
await refreshStatus();
await refreshDeviceStorage().catch(() => {});
window.clearInterval(storageTimer);
storageTimer = window.setInterval(() => refreshDeviceStorage().catch(() => {}), STORAGE_REFRESH_INTERVAL_MS);
},
onLeave() {
window.clearInterval(storageTimer);
storageTimer = null;
},
});
document.addEventListener("matrix:composition-applied", () => refreshStatus().catch(() => {}));
@@ -0,0 +1,322 @@
import { announce, apiJson } from "../core.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
const selectedName = document.getElementById("frp-selected-name");
const serviceState = document.getElementById("frp-service-state");
const feedback = document.getElementById("frp-feedback");
const serviceToggle = document.getElementById("frp-service-toggle");
const editButton = document.getElementById("frp-edit-config");
const diagnosticsButton = document.getElementById("network-diagnostics-run");
const dialog = document.getElementById("frp-config-dialog");
const configSelect = document.getElementById("frp-config-select");
const configName = document.getElementById("frp-config-name");
const configFormat = document.getElementById("frp-config-format");
const configContent = document.getElementById("frp-config-content");
const configFile = document.getElementById("frp-config-file");
const configFeedback = document.getElementById("frp-config-feedback");
const newButton = document.getElementById("frp-config-new");
const uploadButton = document.getElementById("frp-config-upload");
const deleteButton = document.getElementById("frp-config-delete");
const closeButton = document.getElementById("frp-config-close");
const saveButton = document.getElementById("frp-config-save");
const diagnosticsDialog = document.getElementById("network-diagnostics-dialog");
const diagnosticsSummary = document.getElementById("network-diagnostics-summary");
const diagnosticsChecks = document.getElementById("network-diagnostics-checks");
const diagnosticsClose = document.getElementById("network-diagnostics-close");
const SUPPORTED_SUFFIXES = new Set([".toml", ".yaml", ".yml", ".json", ".ini"]);
const MAX_CONFIG_BYTES = 1024 * 1024;
let state = null;
let editingId = null;
let generation = 0;
function setMessage(element, text, kind = "idle") {
element.textContent = text;
element.dataset.state = kind;
element.hidden = !text;
}
function profileById(id) {
return state?.profiles?.find((profile) => profile.id === id) || null;
}
function serviceLabel(service) {
if (!service?.installed) return "frpc 未安装";
if (service.running) return service.enabled ? "运行中(开机自动启动)" : "运行中";
if (service.state === "failed") return "启动失败";
return service.enabled ? "等待启动" : "关闭";
}
function renderState(next) {
state = next;
const selected = profileById(next?.selected_id);
selectedName.textContent = selected?.name || "无";
serviceState.textContent = serviceLabel(next?.service);
const running = next?.service?.running === true;
serviceToggle.textContent = running || next?.service?.enabled ? "关闭frp" : "启动frp";
if (!next?.available) {
setControlUnavailable(serviceToggle, next?.error || "FRP 配置目录不可用。");
setMessage(feedback, next?.error || "FRP 配置目录不可用。", "error");
} else if (!next?.service?.installed) {
setControlUnavailable(serviceToggle, "frpc 系统软件尚未安装。");
setMessage(feedback, "frpc 系统软件尚未安装。", "error");
} else if (!selected && !running) {
setControlUnavailable(serviceToggle, "请先导入或新建 FRP 配置。");
setMessage(feedback, "尚未保存 FRP 配置。", "idle");
} else {
setControlReady(serviceToggle);
setMessage(feedback, next?.service?.last_error || "", next?.service?.last_error ? "error" : "idle");
}
}
function renderProfileOptions() {
configSelect.replaceChildren();
if (!state?.profiles?.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "暂无配置";
configSelect.append(option);
configSelect.disabled = true;
} else {
configSelect.disabled = false;
state.profiles.forEach((profile) => {
const option = document.createElement("option");
option.value = profile.id;
option.textContent = profile.name;
configSelect.append(option);
});
configSelect.value = editingId || state.selected_id || state.profiles[0].id;
}
deleteButton.disabled = !editingId;
}
async function requestText(path, options = {}) {
const response = await fetch(path, { ...options, cache: "no-store" });
if (!response.ok) {
let message = response.statusText;
try { message = (await response.json()).detail || message; } catch {}
throw new Error(message);
}
return response.text();
}
async function loadProfile(id) {
const currentGeneration = generation;
const profile = profileById(id);
if (!profile) {
editingId = null;
configName.value = "";
configContent.value = "";
renderProfileOptions();
return;
}
setMessage(configFeedback, "正在读取配置…");
const content = await requestText(`/api/frp/configs/${encodeURIComponent(id)}`);
if (generation !== currentGeneration) return;
editingId = id;
configName.value = profile.name;
const suffix = profile.filename.slice(profile.filename.lastIndexOf(".")).toLowerCase();
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
configContent.value = content;
renderProfileOptions();
setMessage(configFeedback, "");
}
export async function loadFrpSettings() {
const currentGeneration = ++generation;
try {
const next = await apiJson("/api/frp");
if (generation !== currentGeneration) return;
renderState(next);
} catch (error) {
setMessage(feedback, `FRP 状态读取失败:${error.message}`, "error");
}
}
export function discardFrpDrafts() {
generation += 1;
editingId = null;
configName.value = "";
configContent.value = "";
configFile.value = "";
setMessage(configFeedback, "");
if (dialog.open) dialog.close();
if (diagnosticsDialog.open) diagnosticsDialog.close();
}
editButton.addEventListener("click", async () => {
try {
await loadFrpSettings();
editingId = state?.selected_id || state?.profiles?.[0]?.id || null;
renderProfileOptions();
if (editingId) await loadProfile(editingId);
else {
configName.value = "";
configFormat.value = ".toml";
configContent.value = "";
}
dialog.showModal();
(editingId ? configContent : configName).focus();
} catch (error) {
announce(`打开 FRP 配置失败:${error.message}`, true);
}
});
closeButton.addEventListener("click", () => dialog.close());
dialog.addEventListener("close", () => {
configFile.value = "";
setMessage(configFeedback, "");
});
newButton.addEventListener("click", () => {
editingId = null;
configSelect.value = "";
configName.value = "";
configFormat.value = ".toml";
configContent.value = "";
deleteButton.disabled = true;
setMessage(configFeedback, "请输入完整配置;保存前会使用 frpc 官方校验。", "idle");
configName.focus();
});
uploadButton.addEventListener("click", () => configFile.click());
configFile.addEventListener("change", async () => {
const file = configFile.files?.[0];
if (!file) return;
const suffix = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
if (!SUPPORTED_SUFFIXES.has(suffix)) {
setMessage(configFeedback, "请选择 TOML、YAML、JSON 或 INI 配置文件。", "error");
return;
}
if (file.size > MAX_CONFIG_BYTES) {
setMessage(configFeedback, "配置文件不能超过 1 MiB。", "error");
return;
}
try {
const content = await file.text();
editingId = null;
configName.value = file.name.slice(0, -suffix.length);
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
configContent.value = content;
deleteButton.disabled = true;
setMessage(configFeedback, "文件已读取,点击保存后执行官方校验。", "idle");
} catch (error) {
setMessage(configFeedback, `读取文件失败:${error.message}`, "error");
} finally {
configFile.value = "";
}
});
configSelect.addEventListener("change", async () => {
const id = configSelect.value;
if (!id) return;
try {
if (id !== state?.selected_id) {
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(id)}/select`, { method: "POST" });
renderState(result.frp);
}
await loadProfile(id);
} catch (error) {
setMessage(configFeedback, `切换配置失败:${error.message}`, "error");
renderProfileOptions();
}
});
saveButton.addEventListener("click", async () => {
const name = configName.value.trim();
const content = configContent.value;
if (!name || !content.trim()) {
setMessage(configFeedback, "请输入配置名称和完整配置内容。", "error");
return;
}
setControlBusy(saveButton, { label: "正在校验…", message: "正在使用 frpc 校验配置" });
try {
const suffix = configFormat.value;
const path = editingId
? `/api/frp/configs/${encodeURIComponent(editingId)}?name=${encodeURIComponent(name)}`
: `/api/frp/configs?filename=${encodeURIComponent(`frpc${suffix}`)}&name=${encodeURIComponent(name)}`;
const response = await fetch(path, {
method: editingId ? "PUT" : "POST",
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: content,
cache: "no-store",
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || response.statusText);
editingId = result.profile.id;
renderState(result.frp);
renderProfileOptions();
setMessage(configFeedback, result.frp.service.running ? "配置已保存并重启 FRP。" : "配置已保存并通过校验。", "success");
announce("FRP 配置已保存");
} catch (error) {
setMessage(configFeedback, `配置保存失败:${error.message}`, "error");
} finally {
setControlReady(saveButton);
}
});
deleteButton.addEventListener("click", async () => {
if (!editingId) return;
const profile = profileById(editingId);
if (!window.confirm(`确认删除 FRP 配置“${profile?.name || "未命名"}”?`)) return;
setControlBusy(deleteButton, { label: "正在删除…" });
try {
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(editingId)}`, { method: "DELETE" });
renderState(result.frp);
editingId = result.frp.selected_id || result.frp.profiles?.[0]?.id || null;
renderProfileOptions();
if (editingId) await loadProfile(editingId);
else {
configName.value = "";
configContent.value = "";
}
announce("FRP 配置已删除");
} catch (error) {
setMessage(configFeedback, `删除失败:${error.message}`, "error");
} finally {
setControlReady(deleteButton);
}
});
serviceToggle.addEventListener("click", async () => {
const stopping = state?.service?.running || state?.service?.enabled;
setControlBusy(serviceToggle, { label: stopping ? "正在关闭…" : "正在启动…" });
try {
const result = await apiJson(`/api/frp/service/${stopping ? "stop" : "start"}`, { method: "POST" });
renderState(result.frp);
announce(stopping ? "FRP 已关闭" : "FRP 已启动");
} catch (error) {
setMessage(feedback, `${stopping ? "关闭" : "启动"} FRP 失败:${error.message}`, "error");
await loadFrpSettings();
}
});
diagnosticsButton.addEventListener("click", async () => {
diagnosticsSummary.textContent = "正在检测网络,请稍候…";
diagnosticsSummary.dataset.state = "idle";
diagnosticsChecks.replaceChildren();
diagnosticsDialog.showModal();
setControlBusy(diagnosticsButton, { label: "正在检测…" });
try {
const result = await apiJson("/api/network/diagnostics", { method: "POST" });
diagnosticsSummary.textContent = result.diagnosis;
diagnosticsSummary.dataset.state = result.overall === "ok" ? "success" : "error";
result.checks.forEach((check) => {
const item = document.createElement("li");
item.dataset.state = check.ok ? "success" : (check.warning ? "warning" : "error");
item.textContent = `${check.ok ? "通过" : (check.warning ? "提示" : "失败")}:${check.message}`;
diagnosticsChecks.append(item);
});
} catch (error) {
diagnosticsSummary.textContent = `网络检测失败:${error.message}`;
diagnosticsSummary.dataset.state = "error";
} finally {
setControlReady(diagnosticsButton);
}
});
diagnosticsClose.addEventListener("click", () => diagnosticsDialog.close());
document.addEventListener("matrix:status", (event) => {
if (event.detail?.frp) renderState(event.detail.frp);
});
@@ -0,0 +1,410 @@
import { announce, apiJson, registerWorkspace } from "../core.js";
import { clampCropCenter, cropCenterBounds, cropGeometry } from "../media-crop-geometry.js";
import { shouldReuseMediaJobCard } from "../media-import-ui-model.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
const form = document.getElementById("media-upload-form");
const fileInput = document.getElementById("media-source-file");
const uploadButton = document.getElementById("media-upload-button");
const uploadProgress = document.getElementById("media-upload-progress");
const refreshButton = document.getElementById("refresh-media-imports");
const feedback = document.getElementById("media-import-feedback");
const jobsRoot = document.getElementById("media-import-jobs");
const stateNames = {
uploading: ["copy.dynamic.media.state_uploading", "上传中"],
analyzing: ["copy.dynamic.media.state_analyzing", "分析中"],
awaiting_settings: ["copy.dynamic.media.state_awaiting", "等待设置"],
queued: ["copy.dynamic.media.state_queued", "排队中"],
converting: ["copy.dynamic.media.state_converting", "转换中"],
failed: ["copy.dynamic.media.state_failed", "失败"],
};
let initialized = false;
let pollingTimer = 0;
let priorJobs = new Map();
let emptyState = null;
function setFeedback(copyId, defaultText, values = {}, error = false) {
renderUiCopy(copyId, feedback, defaultText, values);
feedback.dataset.state = error ? "error" : "success";
}
function makeButton(copyId, text, className = "") {
const element = document.createElement("button");
element.type = "button";
renderUiCopy(copyId, element, text);
if (className) element.className = className;
return element;
}
function labelControl(copyId, text, control) {
const label = document.createElement("label");
const span = document.createElement("span");
renderUiCopy(copyId, span, text);
label.append(span, control);
return label;
}
async function cancelJob(id) {
try {
await apiJson("/api/media-imports/" + id, { method: "DELETE" });
setFeedback("copy.dynamic.media.deleted", "任务和源文件已删除。");
await refreshJobs();
} catch (error) {
setFeedback("copy.dynamic.media.delete_failed", "删除任务失败:{error}", { error: error.message }, true);
}
}
function settingsCard(job, card) {
const markDirty = () => { card.dataset.localDraft = "true"; };
const stage = document.createElement("div");
stage.className = "media-crop-stage";
stage.dataset.fit = job.settings.fit_mode;
const sourcePlane = document.createElement("div");
sourcePlane.className = "media-crop-source";
const image = document.createElement("img");
image.alt = job.filename + " 代表帧";
image.src = job.previews[0] || "";
sourcePlane.append(image);
stage.append(sourcePlane);
const tabs = document.createElement("div");
tabs.className = "media-preview-tabs";
job.previews.forEach((url, index) => {
const tab = makeButton("copy.dynamic.media.preview", "代表帧 " + (index + 1));
renderUiCopy("copy.dynamic.media.preview", tab, "代表帧 {index}", { index: index + 1 });
tab.setAttribute("aria-pressed", index === 0 ? "true" : "false");
tab.addEventListener("click", () => {
markDirty();
image.src = url;
tabs.querySelectorAll("button").forEach((item) => {
item.setAttribute("aria-pressed", item === tab ? "true" : "false");
});
});
tabs.append(tab);
});
const grid = document.createElement("div");
grid.className = "media-settings-grid";
const name = document.createElement("input");
name.type = "text"; name.maxLength = 80; name.value = job.name;
const fit = document.createElement("select");
[
["crop", "copy.dynamic.media.mode_crop", "自由取景"],
["contain", "copy.dynamic.media.mode_contain", "完整包含"],
["stretch", "copy.dynamic.media.mode_stretch", "拉伸"],
].forEach((pair) => {
const option = document.createElement("option");
option.value = pair[0];
renderUiCopy(pair[1], option, pair[2]);
option.selected = pair[0] === job.settings.fit_mode;
fit.append(option);
});
const zoom = document.createElement("input");
zoom.type = "range"; zoom.min = "1"; zoom.max = "16";
zoom.step = "0.05"; zoom.value = job.settings.zoom;
const transparency = document.createElement("input");
transparency.type = "color"; transparency.value = job.settings.transparency_color;
const padding = document.createElement("input");
padding.type = "color"; padding.value = job.settings.padding_color;
grid.append(
labelControl("copy.dynamic.media.output_name", "保存名称", name),
labelControl("copy.dynamic.media.fit_mode", "1:1 适配", fit),
labelControl("copy.dynamic.media.crop_zoom", "取景缩放", zoom),
labelControl("copy.dynamic.media.transparency", "透明像素填充色", transparency),
labelControl("copy.dynamic.media.padding", "留白颜色", padding),
);
let centerX = Number(job.settings.center_x);
let centerY = Number(job.settings.center_y);
function sourceSize() {
return {
width: image.naturalWidth || 1,
height: image.naturalHeight || 1,
};
}
function cropBounds() {
const source = sourceSize();
return cropCenterBounds({
viewportWidth: stage.clientWidth || 1,
viewportHeight: stage.clientHeight || 1,
sourceWidth: source.width,
sourceHeight: source.height,
zoom: Number(zoom.value),
});
}
function clampCenter() {
const clamped = clampCropCenter({ centerX, centerY }, cropBounds());
centerX = clamped.centerX;
centerY = clamped.centerY;
}
function updateCropVisual() {
stage.dataset.fit = fit.value;
stage.style.backgroundColor = padding.value;
sourcePlane.style.backgroundColor = transparency.value;
const source = sourceSize();
if (fit.value === "stretch") {
sourcePlane.style.width = "100%";
sourcePlane.style.height = "100%";
sourcePlane.style.left = "0";
sourcePlane.style.top = "0";
return;
}
if (fit.value === "crop") clampCenter();
const geometry = cropGeometry({
viewportWidth: stage.clientWidth || 1,
viewportHeight: stage.clientHeight || 1,
sourceWidth: source.width,
sourceHeight: source.height,
zoom: fit.value === "crop" ? Number(zoom.value) : 1,
centerX: fit.value === "crop" ? centerX : 0.5,
centerY: fit.value === "crop" ? centerY : 0.5,
});
sourcePlane.style.width = geometry.width + "px";
sourcePlane.style.height = geometry.height + "px";
sourcePlane.style.left = geometry.left + "px";
sourcePlane.style.top = geometry.top + "px";
}
name.addEventListener("input", markDirty);
fit.addEventListener("change", () => { markDirty(); updateCropVisual(); });
zoom.addEventListener("input", () => { markDirty(); updateCropVisual(); });
transparency.addEventListener("input", () => { markDirty(); updateCropVisual(); });
padding.addEventListener("input", () => { markDirty(); updateCropVisual(); });
let drag = null;
stage.addEventListener("pointerdown", (event) => {
if (fit.value !== "crop") return;
drag = { x: event.clientX, y: event.clientY, centerX, centerY };
stage.setPointerCapture(event.pointerId);
});
stage.addEventListener("pointermove", (event) => {
if (!drag) return;
markDirty();
const source = sourceSize();
const geometry = cropGeometry({
viewportWidth: stage.clientWidth || 1,
viewportHeight: stage.clientHeight || 1,
sourceWidth: source.width,
sourceHeight: source.height,
zoom: Number(zoom.value), centerX: drag.centerX, centerY: drag.centerY,
});
centerX = drag.centerX - (event.clientX - drag.x) / geometry.width;
centerY = drag.centerY - (event.clientY - drag.y) / geometry.height;
clampCenter();
updateCropVisual();
});
stage.addEventListener("pointerup", () => { drag = null; });
stage.addEventListener("pointercancel", () => { drag = null; });
image.addEventListener("load", updateCropVisual);
const cropResizeObserver = new ResizeObserver(updateCropVisual);
cropResizeObserver.observe(stage);
card.mediaCropDispose = () => cropResizeObserver.disconnect();
updateCropVisual();
const actions = document.createElement("div");
actions.className = "media-job-actions";
const convert = makeButton("copy.dynamic.media.convert", "保存设置并开始转换", "primary-button");
const cancel = makeButton("copy.dynamic.media.cancel_source", "取消并删除源文件", "danger-button");
actions.append(convert, cancel);
convert.addEventListener("click", async () => {
convert.disabled = true;
try {
await apiJson("/api/media-imports/" + job.id + "/settings", {
method: "PUT",
body: JSON.stringify({
name: name.value, fit_mode: fit.value, center_x: centerX, center_y: centerY,
zoom: Number(zoom.value), transparency_color: transparency.value,
padding_color: padding.value,
}),
});
await apiJson("/api/media-imports/" + job.id + "/convert", { method: "POST", body: "{}" });
setFeedback("copy.dynamic.media.queued", "任务已加入转换队列。");
await refreshJobs();
} catch (error) {
if (error.message === "output name already exists") {
setFeedback("copy.dynamic.media.name_conflict", "保存名称已存在,请更换名称。", {}, true);
} else {
setFeedback("copy.dynamic.media.convert_failed", "无法开始转换:{error}", { error: error.message }, true);
}
convert.disabled = false;
}
});
cancel.addEventListener("click", () => cancelJob(job.id));
card.append(tabs, stage, grid, actions);
}
function renderJob(job) {
const card = document.createElement("article");
card.className = "media-job-card";
card.dataset.jobId = job.id;
card.dataset.jobState = job.state;
card.dataset.serverUpdatedAt = job.updated_at;
card.dataset.localDraft = "false";
const heading = document.createElement("div");
heading.className = "media-job-heading";
const title = document.createElement("h3");
title.textContent = job.name;
const state = document.createElement("p");
const stateCopy = stateNames[job.state];
if (job.queue_position) {
renderUiCopy("copy.dynamic.media.queue_position", state, "{state} · 队列第 {position} 位", {
state: stateCopy ? stateCopy[1] : job.state, position: job.queue_position,
});
} else if (stateCopy) {
renderUiCopy(stateCopy[0], state, stateCopy[1]);
} else {
state.textContent = job.state;
}
heading.append(title, state);
const source = document.createElement("p");
source.textContent = job.filename + " · " + (job.source_size / 1024 / 1024).toFixed(1) + " MiB";
card.append(heading, source);
if (job.state === "awaiting_settings") {
settingsCard(job, card);
return card;
}
const progress = document.createElement("progress");
progress.max = 100; progress.value = job.progress || 0;
card.append(progress);
if (job.error) {
const error = document.createElement("p");
error.textContent = job.error; error.className = "settings-feedback";
error.dataset.state = "error"; card.append(error);
}
const actions = document.createElement("div");
actions.className = "media-job-actions";
if (job.state === "failed") {
const retry = makeButton("copy.dynamic.media.retry", "重试", "primary-button");
retry.addEventListener("click", async () => {
try {
await apiJson("/api/media-imports/" + job.id + "/retry", { method: "POST", body: "{}" });
await refreshJobs();
} catch (error) {
setFeedback("copy.dynamic.media.retry_failed", "重试失败:{error}", { error: error.message }, true);
}
});
actions.append(retry);
}
const cancel = makeButton("copy.dynamic.media.cancel", "取消并删除", "danger-button");
cancel.addEventListener("click", () => cancelJob(job.id));
actions.append(cancel); card.append(actions);
return card;
}
function renderedCardState(card) {
if (!card) return null;
return {
id: card.dataset.jobId,
state: card.dataset.jobState,
updatedAt: card.dataset.serverUpdatedAt,
dirty: card.dataset.localDraft === "true",
};
}
function reconcileJobCards(jobs) {
const oldCards = new Map(
Array.from(jobsRoot.querySelectorAll(":scope > .media-job-card"))
.map((card) => [card.dataset.jobId, card]),
);
if (jobs.length) {
emptyState?.remove();
jobs.forEach((job, index) => {
const oldCard = oldCards.get(job.id) || null;
const card = shouldReuseMediaJobCard(renderedCardState(oldCard), job)
? oldCard
: renderJob(job);
if (oldCard && card !== oldCard) {
oldCard.mediaCropDispose?.();
oldCard.replaceWith(card);
}
const position = jobsRoot.children[index] || null;
if (position !== card) jobsRoot.insertBefore(card, position);
oldCards.delete(job.id);
});
oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); });
return;
}
oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); });
if (!emptyState) {
emptyState = document.createElement("p");
emptyState.className = "media-job-empty";
renderUiCopy("copy.dynamic.media.empty", emptyState, "当前没有等待或转换中的任务。");
}
if (!emptyState.isConnected) jobsRoot.append(emptyState);
}
async function refreshJobs() {
if (document.hidden) return;
try {
const result = await apiJson("/api/media-imports");
const next = new Map(result.jobs.map((job) => [job.id, job]));
for (const pair of priorJobs) {
const id = pair[0], job = pair[1];
if (!next.has(id) && ["queued", "converting"].includes(job.state)) {
const message = "“" + job.name + "”转换完成,已加入内容库。";
setFeedback("copy.dynamic.media.completed", "“{name}”转换完成,已加入内容库。", { name: job.name });
announce(message);
document.getElementById("refresh-templates")?.click();
document.getElementById("refresh-animations")?.click();
}
}
priorJobs = next;
reconcileJobCards(result.jobs);
} catch (error) {
setFeedback("copy.dynamic.media.list_failed", "任务列表读取失败:{error}", { error: error.message }, true);
}
}
function upload(file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/media-imports?filename=" + encodeURIComponent(file.name));
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.responseType = "json";
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) uploadProgress.value = event.loaded * 100 / event.total;
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
else reject(new Error(xhr.response?.detail || xhr.statusText || "上传失败"));
});
xhr.addEventListener("error", () => reject(new Error("上传连接中断")));
xhr.send(file);
});
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = fileInput.files[0];
if (!file) return;
uploadButton.disabled = true; uploadProgress.hidden = false; uploadProgress.value = 0;
try {
await upload(file);
form.reset();
setFeedback("copy.dynamic.media.uploaded", "上传完成,设备正在分析媒体。");
await refreshJobs();
} catch (error) {
setFeedback("copy.dynamic.media.upload_failed", "上传失败:{error}", { error: error.message }, true);
} finally {
uploadButton.disabled = false; uploadProgress.hidden = true;
}
});
refreshButton.addEventListener("click", refreshJobs);
document.addEventListener("visibilitychange", () => {
window.clearInterval(pollingTimer);
if (!document.hidden && initialized) {
refreshJobs(); pollingTimer = window.setInterval(refreshJobs, 2000);
}
});
async function initialize() {
if (!initialized) {
initialized = true; pollingTimer = window.setInterval(refreshJobs, 2000);
}
await refreshJobs();
}
registerWorkspace({
id: "media-import", title: "媒体内容转换", elementId: "workspace-media-import",
order: 45, persistence: "server",
capabilities: ["media-upload", "background-conversion"], onEnter: initialize,
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,627 @@
import { announce, apiJson, registerWorkspace } from "../core.js";
import {
animationEditState,
enterTemplateEdit,
getScene,
markTemplateSaved,
ordinarySceneNeedsTemplateSave,
refreshCurrentDisplayPreview,
templateEditState,
} from "../scene-board.js";
import { sceneToStorageValue } from "../scene-model.js";
import { protectCardDragStart, reorderByKey } from "../sortable-card.js";
import { resolveAnimationEditBeforeNavigation } from "./animations.js";
import { refreshStatus } from "./device.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
import {
createAnimationPreviewGroup,
createAnimationThumbnailBox,
} from "../animation-thumbnail-preview.js";
const grid = document.getElementById("template-grid");
const feedback = document.getElementById("template-feedback");
const refreshButton = document.getElementById("refresh-templates");
const selectDefaultButton = document.getElementById("select-default-content");
const saveButton = document.getElementById("save-template");
const nameDialog = document.getElementById("template-name-dialog");
const nameDialogTitle = document.getElementById("template-dialog-title");
const nameDialogNote = document.getElementById("template-dialog-note");
const nameInput = document.getElementById("template-name-input");
const confirmDialog = document.getElementById("template-confirm-dialog");
const confirmMessage = document.getElementById("template-confirm-message");
const saveChoiceDialog = document.getElementById("template-save-choice-dialog");
const saveChoiceMessage = document.getElementById("template-save-choice-message");
const saveCurrentButton = document.getElementById("template-save-current");
const copyDialog = document.getElementById("library-copy-dialog");
const copyTarget = document.getElementById("library-copy-target");
let loading = false;
let animationList = [];
let activeAnimation = null;
let libraryOrder = { items: [], revision: "" };
let draggingLibraryKey = null;
let defaultContent = null;
let selectingDefault = false;
const demoRestrictedMessage = "演示案例不得编辑,请复制";
const libraryDragType = "application/x-matrix-library-item";
const templateAnimationPreviewGroup = createAnimationPreviewGroup();
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) return "-";
if (bytes < 1024) return `${bytes} B`;
const units = ["KiB", "MiB", "GiB", "TiB"];
let size = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && size >= 1024; index += 1) {
size /= 1024;
unit = units[index];
}
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`;
}
function askName(title, initial = "", { note = "" } = {}) {
nameDialogTitle.textContent = title;
nameDialogNote.textContent = note;
nameDialogNote.hidden = !note;
nameInput.value = initial;
nameInput.required = true;
nameDialog.returnValue = "";
nameDialog.showModal();
window.setTimeout(() => {
nameInput.focus();
nameInput.select();
}, 0);
return new Promise((resolve) => {
nameDialog.addEventListener("close", () => {
resolve(nameDialog.returnValue === "save" ? nameInput.value.trim() : null);
}, { once: true });
});
}
function askConfirmation(message, { copyId = "", values = {} } = {}) {
if (copyId) renderUiCopy(copyId, confirmMessage, message, values);
else confirmMessage.textContent = message;
confirmDialog.returnValue = "";
confirmDialog.showModal();
return new Promise((resolve) => {
confirmDialog.addEventListener("close", () => {
resolve(confirmDialog.returnValue === "confirm");
}, { once: true });
});
}
function actionButton(label, action, className = "") {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
if (className) button.className = className;
button.addEventListener("click", () => action(button));
return button;
}
function restrictDemoAction(control, message = demoRestrictedMessage) {
control.classList.add("demo-restricted-action");
setControlUnavailable(control, message);
return control;
}
function syncSaveButton() {
if (!animationEditState() && templateEditState()) {
renderUiCopy("copy.dynamic.template.save_bound", saveButton, "保存模板");
} else {
renderUiCopy("copy.dynamic.template.save_new", saveButton, "保存为模板");
}
}
function askSaveChoice(context) {
renderUiCopy(
"copy.dynamic.template.save_choice",
saveChoiceMessage,
"请选择“{name}”的保存方式。",
{ name: context.name },
);
saveCurrentButton.classList.remove("demo-restricted-action");
setControlReady(saveCurrentButton);
if (context.readOnly) {
restrictDemoAction(saveCurrentButton, "演示案例不得覆盖,请另存为新模板");
}
saveChoiceDialog.returnValue = "";
saveChoiceDialog.showModal();
return new Promise((resolve) => {
saveChoiceDialog.addEventListener("close", () => {
resolve(["current", "new"].includes(saveChoiceDialog.returnValue)
? saveChoiceDialog.returnValue
: "cancel");
}, { once: true });
});
}
async function confirmOrdinarySceneReplacement(name) {
if (!ordinarySceneNeedsTemplateSave()) return true;
return askConfirmation(
"当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?",
{
copyId: "copy.dynamic.template.unsaved_replace",
values: { name },
},
);
}
function setBusy(value) {
loading = value;
if (value) {
setControlBusy(refreshButton);
setControlBusy(selectDefaultButton);
setControlBusy(saveButton);
grid.setAttribute("aria-busy", "true");
} else {
setControlReady(refreshButton);
setControlReady(selectDefaultButton);
setControlReady(saveButton);
grid.removeAttribute("aria-busy");
}
grid.toggleAttribute("inert", value);
}
function isDefaultContent(type, id) {
return defaultContent?.type === type && defaultContent?.id === id;
}
function syncDefaultSelectionButton() {
selectDefaultButton.setAttribute("aria-pressed", String(selectingDefault));
selectDefaultButton.classList.toggle("active", selectingDefault);
selectDefaultButton.textContent = selectingDefault ? "取消设置默认内容" : "设置默认显示内容";
}
async function chooseDefaultContent(type, item) {
await mutate(async () => {
defaultContent = await apiJson("/api/display/default-content", {
method: "PUT",
body: JSON.stringify({ type, id: item.id }),
});
selectingDefault = false;
syncDefaultSelectionButton();
await refreshStatus();
}, `已将“${item.name}”设为默认显示内容并立即显示`);
}
function decorateDefaultContent(card, type, item, { available = true } = {}) {
if (isDefaultContent(type, item.id)) {
card.classList.add("template-default-content");
const badge = document.createElement("span");
badge.className = "template-default-badge";
badge.textContent = "当前默认";
card.insertBefore(badge, card.querySelector(".template-card-content-actions"));
}
if (!selectingDefault) return card;
const actions = document.createElement("div");
actions.className = "template-card-actions template-card-default-actions";
const choose = actionButton(
isDefaultContent(type, item.id) ? "当前默认" : "设为默认",
() => chooseDefaultContent(type, item),
"primary-button",
);
if (isDefaultContent(type, item.id)) {
setControlUnavailable(choose, "这项已经是当前默认显示内容。");
} else if (!available) {
setControlUnavailable(choose, "空动图不能设为默认,请先添加至少一帧。");
}
actions.append(choose);
card.append(actions);
return card;
}
async function mutate(action, successMessage) {
setBusy(true);
feedback.textContent = "正在处理模板操作…";
feedback.dataset.state = "pending";
try {
await action();
announce(successMessage);
setBusy(false);
await refreshTemplates();
} catch (error) {
const conflict = error.code !== "APP_STALE" && (error.status === 409 || error.status === 428);
feedback.textContent = conflict
? "模板已被其他页面修改,列表已刷新;请检查最新内容后重新操作。"
: error.message;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
if (conflict) {
setBusy(false);
await refreshTemplates();
}
} finally {
setBusy(false);
}
}
function appendContentActions(card, { play, edit, copy, rename, remove }) {
const actions = document.createElement("div");
actions.className = "template-card-actions template-card-content-actions";
remove.classList.add("template-card-delete-action");
actions.append(play, edit, copy, rename, remove);
card.append(actions);
}
async function playStaticTemplate(template) {
await mutate(async () => {
await apiJson(`/api/templates/${template.id}/play`, {
method: "POST",
headers: { "If-Match": `"${template.revision}"` },
body: "{}",
});
refreshCurrentDisplayPreview();
await refreshStatus();
}, `开始播放“${template.name}”`);
}
async function editStaticTemplate(template) {
if (!await resolveAnimationEditBeforeNavigation()) return;
if (!await confirmOrdinarySceneReplacement(template.name)) return;
setBusy(true);
feedback.textContent = `正在读取“${template.name}”…`;
feedback.dataset.state = "pending";
try {
const full = await apiJson(`/api/templates/${template.id}`);
enterTemplateEdit(full);
window.location.hash = "#canvas";
announce(`正在编辑模板“${template.name}”`);
} catch (error) {
feedback.textContent = `模板读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setBusy(false);
}
}
function renderCard(template) {
const card = document.createElement("article");
card.className = "template-card";
const imageBox = document.createElement("div");
imageBox.className = "template-image-box";
const image = document.createElement("img");
image.className = "template-image";
image.src = template.thumbnail_url;
image.alt = `${template.name} 模板缩略图`;
image.width = 64;
image.height = 64;
image.draggable = false;
image.loading = "lazy";
image.addEventListener("error", () => {
image.hidden = true;
imageBox.classList.add("template-image-error");
imageBox.setAttribute("aria-label", "缩略图加载失败");
});
imageBox.appendChild(image);
const name = document.createElement("h3");
name.className = "template-card-name";
name.textContent = template.name;
const badge = document.createElement("span");
badge.className = "content-type-badge";
badge.textContent = "静态";
const size = document.createElement("p");
size.className = "template-card-size";
size.textContent = `占用 ${formatBytes(template.size_bytes)}`;
const play = actionButton("播放", () => playStaticTemplate(template), "primary-button");
const edit = actionButton("编辑", () => editStaticTemplate(template));
const copy = actionButton("复制", (control) => copyStaticTemplate(template, control));
const rename = actionButton("重命名", async () => {
const nextName = await askName("重命名模板", template.name);
if (!nextName || nextName === template.name) return;
mutate(() => apiJson(`/api/templates/${template.id}`, {
method: "PATCH",
headers: { "If-Match": `"${template.revision}"` },
body: JSON.stringify({ name: nextName }),
}), `模板已重命名为“${nextName}”`);
});
const remove = actionButton("删除", async () => {
if (!await askConfirmation(`永久删除模板“${template.name}”及其缩略图吗?`)) return;
mutate(() => apiJson(`/api/templates/${template.id}`, {
method: "DELETE",
headers: { "If-Match": `"${template.revision}"` },
}), `模板“${template.name}”已删除`);
}, "danger-button");
if (template.read_only) {
restrictDemoAction(rename);
restrictDemoAction(remove);
}
card.append(imageBox, badge, name, size);
appendContentActions(card, { play, edit, copy, rename, remove });
return decorateDefaultContent(card, "template", template);
}
async function chooseCopyTarget() {
const data = await apiJson("/api/animations");
copyTarget.replaceChildren(new Option("静态模板", "static"), ...data.animations.map((item) => new Option(`动图:${item.name}`, `animation:${item.id}:${item.revision}`)));
copyDialog.returnValue = ""; copyDialog.showModal();
await new Promise((resolve) => copyDialog.addEventListener("close", resolve, { once: true }));
if (copyDialog.returnValue !== "copy") return null;
const [type, animationId, revision] = copyTarget.value.split(":");
return { type, animationId, revision };
}
async function copyStaticTemplate(template, control) {
setControlBusy(control, { label: "正在准备…", message: "正在读取可复制的目标…" });
try {
const target = await chooseCopyTarget(); if (!target) return;
await mutate(() => apiJson("/api/library/copy", { method: "POST", body: JSON.stringify({
source_type: "template", source_id: template.id, source_revision: template.revision,
destination_type: target.type, destination_animation_id: target.animationId || null, destination_revision: target.revision || null,
}) }), `模板“${template.name}”已复制`);
} catch (error) {
feedback.textContent = `复制目标读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setControlReady(control);
}
}
function renderAnimationCard(animation) {
const card = document.createElement("article"); card.className = "template-card";
const imageBox = createAnimationThumbnailBox(templateAnimationPreviewGroup, animation);
const badge = document.createElement("span"); badge.className = "content-type-badge content-type-animation"; badge.textContent = "动图";
const name = document.createElement("h3"); name.className = "template-card-name animation-folder-name"; name.textContent = animation.name;
name.dataset.hoverTooltip = animation.name;
const active = activeAnimation?.id === animation.id;
const meta = document.createElement("p"); meta.className = "template-card-size"; meta.textContent = `${animation.frame_count} 帧 · ${(animation.total_duration_ms / 1000).toFixed(2)} 秒 · ${formatBytes(animation.size_bytes)}${active ? activeAnimation.revision === animation.revision ? " · 正在播放" : " · 正在播放旧版本" : ""}`;
const play = actionButton("播放", () => mutate(async () => {
await apiJson(`/api/animations/${animation.id}/play`, {
method: "POST",
headers: { "If-Match": `"${animation.revision}"` },
body: "{}",
});
refreshCurrentDisplayPreview();
await refreshStatus();
}, `开始播放“${animation.name}”`), "primary-button");
if (!animation.frame_count) setControlUnavailable(play, "请先在该动图中添加至少一帧,再开始播放。");
const copy = actionButton("复制", () => mutate(() => apiJson(`/api/animations/${animation.id}/copy`, { method: "POST", body: "{}" }), `动图“${animation.name}”已复制`));
const edit = actionButton("编辑", () => { window.location.hash = "#animations"; document.dispatchEvent(new CustomEvent("matrix:open-animation", { detail: { id: animation.id } })); });
const rename = actionButton("重命名", async () => { const next = await askName("重命名动图", animation.name); if (next) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "PATCH", headers: { "If-Match": `"${animation.revision}"` }, body: JSON.stringify({ name: next }) }), "动图已重命名"); });
const remove = actionButton("删除", async () => { if (await askConfirmation(`永久删除动图“${animation.name}”及全部帧吗?`)) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "DELETE", headers: { "If-Match": `"${animation.revision}"` } }), "动图已删除"); }, "danger-button");
if (animation.read_only) {
restrictDemoAction(edit);
restrictDemoAction(rename);
restrictDemoAction(remove);
}
card.append(imageBox, badge, name, meta);
appendContentActions(card, { play, edit, copy, rename, remove });
return decorateDefaultContent(card, "animation", animation, { available: animation.frame_count > 0 });
}
function libraryItemKey(item) {
return `${item.type}:${item.id}`;
}
function clearLibraryDragIndicators() {
grid.querySelectorAll(".library-drop-target").forEach((card) => card.classList.remove("library-drop-target"));
}
function saveLibraryOrder(keys) {
const byKey = new Map(libraryOrder.items.map((item) => [libraryItemKey(item), item]));
const items = keys.map((key) => byKey.get(key)).filter(Boolean);
if (items.length !== libraryOrder.items.length) return;
mutate(() => apiJson("/api/library/order", {
method: "PUT",
headers: { "If-Match": `"${libraryOrder.revision}"` },
body: JSON.stringify({ items }),
}), "模板顺序已更新");
}
function moveLibraryItem(index, offset) {
const keys = libraryOrder.items.map(libraryItemKey);
const target = index + offset;
if (target < 0 || target >= keys.length) return;
[keys[index], keys[target]] = [keys[target], keys[index]];
saveLibraryOrder(keys);
}
function prepareDemoCard(card) {
card.classList.add("library-demo-fixed");
card.querySelectorAll("img").forEach((image) => { image.draggable = false; });
protectCardDragStart(card, { locked: true });
const orderActions = document.createElement("div");
orderActions.className = "template-card-actions template-card-order-actions";
const moveUp = restrictDemoAction(
actionButton("上移", () => {}),
"演示案例固定在最前,不能移动。",
);
const moveDown = restrictDemoAction(
actionButton("下移", () => {}),
"演示案例固定在最前,不能移动。",
);
orderActions.append(moveUp, moveDown);
card.insertBefore(orderActions, card.querySelector(".template-card-default-actions"));
return card;
}
function prepareSortableLibraryCard(card, item, index, total) {
const key = libraryItemKey(item);
card.classList.add("sortable-card", "library-sortable-card");
card.dataset.libraryKey = key;
protectCardDragStart(card);
const orderActions = document.createElement("div");
orderActions.className = "template-card-actions template-card-order-actions";
const moveUp = actionButton("上移", () => moveLibraryItem(index, -1));
const moveDown = actionButton("下移", () => moveLibraryItem(index, 1));
if (index === 0) setControlUnavailable(moveUp, "两个演示案例固定在最前,当前已经是第一项。");
if (index === total - 1) setControlUnavailable(moveDown, "当前已经是最后一项,不能继续下移。");
orderActions.append(moveUp, moveDown);
card.insertBefore(orderActions, card.querySelector(".template-card-default-actions"));
card.addEventListener("dragstart", (event) => {
if (event.defaultPrevented) return;
draggingLibraryKey = key;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(libraryDragType, key);
event.dataTransfer.setData("text/plain", key);
});
card.addEventListener("dragover", (event) => {
if (!draggingLibraryKey || draggingLibraryKey === key) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
clearLibraryDragIndicators();
card.classList.add("library-drop-target");
});
card.addEventListener("dragleave", (event) => {
if (!card.contains(event.relatedTarget)) card.classList.remove("library-drop-target");
});
card.addEventListener("drop", (event) => {
if (!draggingLibraryKey) return;
event.preventDefault();
const source = event.dataTransfer.getData(libraryDragType) || draggingLibraryKey;
const keys = libraryOrder.items.map(libraryItemKey);
const reordered = reorderByKey(keys, source, key);
clearLibraryDragIndicators();
if (reordered) saveLibraryOrder(reordered);
});
card.addEventListener("dragend", () => {
draggingLibraryKey = null;
clearLibraryDragIndicators();
});
return card;
}
export async function refreshTemplates() {
if (loading) return;
setControlBusy(refreshButton, { label: "正在刷新…", message: "正在读取模板和动图…" });
feedback.textContent = "正在读取模板…";
feedback.dataset.state = "pending";
try {
const [data, animations, order, selectedDefault] = await Promise.all([
apiJson("/api/templates?include_demo=true"),
apiJson("/api/animations?include_demo=true"),
apiJson("/api/library/order"),
apiJson("/api/display/default-content"),
]);
defaultContent = selectedDefault;
animationList = animations.animations;
libraryOrder = order;
const demoCards = [
...data.templates.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "static" })),
...animationList.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "animation" })),
].sort((left, right) => left.demo_order - right.demo_order);
const userItems = [
...data.templates.filter((item) => !item.is_demo).map((item) => ({ type: "template", id: item.id, value: item })),
...animationList.filter((item) => !item.is_demo).map((item) => ({ type: "animation", id: item.id, value: item })),
];
const userByKey = new Map(userItems.map((item) => [libraryItemKey(item), item]));
const orderedUsers = order.items.map((item) => userByKey.get(libraryItemKey(item))).filter(Boolean);
templateAnimationPreviewGroup.clear();
const renderedDemos = demoCards.map((item) => prepareDemoCard(item.content_type === "static" ? renderCard(item) : renderAnimationCard(item)));
const renderedUsers = orderedUsers.map((item, index) => prepareSortableLibraryCard(
item.type === "template" ? renderCard(item.value) : renderAnimationCard(item.value),
item,
index,
orderedUsers.length,
));
grid.replaceChildren(...renderedDemos, ...renderedUsers);
if (!userItems.length) {
const empty = document.createElement("p");
empty.className = "template-empty panel";
empty.textContent = "还没有模板。请在像素画布或文字显示中保存当前统一画板。";
grid.appendChild(empty);
}
feedback.textContent = `共 ${data.templates.length} 个静态模板、${animationList.length} 个动图`;
feedback.dataset.state = "ready";
announce(feedback.textContent);
} catch (error) {
feedback.textContent = `模板读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setControlReady(refreshButton);
}
}
async function saveSceneAsNew({ initial = "", note = "", bind = true } = {}) {
const name = await askName("保存为模板", initial, { note });
if (!name) return;
await mutate(async () => {
const result = await apiJson("/api/templates", {
method: "POST",
body: JSON.stringify({ name, scene: sceneToStorageValue(getScene()) }),
});
if (bind) markTemplateSaved(result);
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-save" } }));
return result;
}, `模板“${name}”已保存`);
}
async function saveSceneToCurrentTemplate(context) {
await mutate(async () => {
const result = await apiJson(`/api/templates/${context.id}`, {
method: "PUT",
headers: { "If-Match": `"${context.revision}"` },
body: JSON.stringify({ scene: sceneToStorageValue(getScene()) }),
});
markTemplateSaved(result);
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-update" } }));
return result;
}, `模板“${context.name}”已保存`);
}
saveButton.addEventListener("click", async () => {
if (animationEditState()) {
await saveSceneAsNew({
note: "当前帧将被保存为一个静态图模板",
bind: false,
});
return;
}
const context = templateEditState();
if (!context) {
await saveSceneAsNew();
return;
}
const choice = await askSaveChoice(context);
if (choice === "current") {
await saveSceneToCurrentTemplate(context);
} else if (choice === "new") {
await saveSceneAsNew({ initial: `${context.name} - 副本` });
}
});
refreshButton.addEventListener("click", refreshTemplates);
selectDefaultButton.addEventListener("click", async () => {
if (loading) return;
selectingDefault = !selectingDefault;
syncDefaultSelectionButton();
announce(selectingDefault ? "请选择下方内容设为默认显示" : "已取消设置默认显示内容");
await refreshTemplates();
});
document.addEventListener("matrix:library-change", refreshTemplates);
document.addEventListener("matrix:template-edit-change", syncSaveButton);
document.addEventListener("matrix:animation-edit-change", syncSaveButton);
document.addEventListener("matrix:status", (event) => {
const state = event.detail?.state;
activeAnimation = state?.animation_active ? { id: state.animation_id, revision: state.animation_revision } : null;
});
registerWorkspace({
id: "templates",
title: "模板管理",
elementId: "workspace-templates",
order: 60,
persistence: "server",
onEnter: async () => {
templateAnimationPreviewGroup.setActive(true);
await refreshTemplates();
},
onLeave: () => {
templateAnimationPreviewGroup.setActive(false);
templateAnimationPreviewGroup.clear();
},
});
syncDefaultSelectionButton();
syncSaveButton();
@@ -0,0 +1,349 @@
import { announce, apiFile, apiJson, registerWorkspace } from "../core.js";
import { refreshColorTrigger } from "../color-picker.js";
import { createFontPicker } from "../font-picker.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
import {
runControlAction,
setControlReady,
setControlUnavailable,
} from "../interaction-feedback.js";
import {
addTextElement,
deleteTextElement,
duplicateTextElement,
getScene,
getSelectedTextElement,
selectTextElement,
subscribeScene,
updateTextElement,
} from "../scene-board.js";
const form = document.getElementById("text-form");
const fields = document.getElementById("text-fields");
const contentFields = document.getElementById("text-content-fields");
const list = document.getElementById("text-element-list");
const selectionStatus = document.getElementById("text-selection-status");
const duplicateButton = document.getElementById("duplicate-text");
const deleteButton = document.getElementById("delete-text");
const colorTrigger = document.getElementById("text-color");
const fontInput = document.getElementById("text-font");
const fontOptions = document.getElementById("text-font-options");
const importFontButton = document.getElementById("import-font");
const fontFileInput = document.getElementById("font-file");
const fontFeedback = document.getElementById("font-feedback");
let initialized = false;
let suppressForm = false;
let renderedSelectionId = null;
let defaults = { font: "default", size: 12 };
let fontCatalogReady = false;
let fontMaxUploadBytes = 32 * 1024 * 1024;
let acceptedFontExtensions = [".ttf", ".otf", ".ttc", ".otc"];
const fontPicker = createFontPicker({
input: fontInput,
listbox: fontOptions,
onCommit(item) {
const selected = getSelectedTextElement();
if (!selected) return;
updateTextElement(selected.id, { font: item.id }, "form");
setFontFeedbackTemplate(
"copy.dynamic.text.font_selected",
"已选择字体“{name}”",
{ name: item.label },
);
},
});
function field(id) {
return document.getElementById(id);
}
function setFontFeedback(message = "", isError = false) {
fontFeedback.textContent = message;
fontFeedback.hidden = !message;
fontFeedback.dataset.state = isError ? "error" : "success";
}
function setFontFeedbackTemplate(copyId, defaultText, values = {}, isError = false) {
renderUiCopy(copyId, fontFeedback, defaultText, values);
fontFeedback.hidden = false;
fontFeedback.dataset.state = isError ? "error" : "success";
}
function updateFontPickerAvailability(element = getSelectedTextElement()) {
if (!fontCatalogReady) {
fontPicker.setEnabled(false);
setControlUnavailable(fontInput, "字体目录尚未加载完成。");
} else if (!element) {
fontPicker.setEnabled(false);
setControlUnavailable(fontInput, "请先新建或选择一个文字元素;字体导入仍可使用。");
} else {
setControlReady(fontInput);
fontPicker.setEnabled(true);
}
}
function applyElementToFields(element) {
suppressForm = true;
if (element) {
field("text-value").value = element.text;
fontPicker.setValue(element.font);
field("text-size").value = String(element.size);
field("text-x").value = String(element.x);
field("text-y").value = String(element.y);
field("text-align").value = element.align;
colorTrigger.value = element.color;
} else {
field("text-value").value = "";
fontPicker.setValue(defaults.font);
field("text-size").value = String(defaults.size);
field("text-x").value = "32";
field("text-y").value = "26";
field("text-align").value = "center";
colorTrigger.value = "#FFFFFF";
}
refreshColorTrigger(colorTrigger);
contentFields.toggleAttribute("disabled", !element);
fields.toggleAttribute("disabled", !element);
updateFontPickerAvailability(element);
renderedSelectionId = element?.id || null;
suppressForm = false;
}
function displayName(element, index) {
const text = element.text.trim() || "(空文字)";
return `${index + 1}. ${text.replace(/\s+/g, " ").slice(0, 28)}`;
}
function renderElementList(scene, selectedId) {
const items = scene.elements.map((element, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "text-element-item";
button.textContent = displayName(element, index);
button.title = `${element.text || "空文字"} · ${element.size}px · ${element.color}`;
const selected = element.id === selectedId;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
button.addEventListener("click", () => selectTextElement(element.id));
return button;
});
list.replaceChildren(...items);
}
function renderSelection({ scene, selectedTextId, reason }) {
const selected = scene.elements.find((element) => element.id === selectedTextId) || null;
renderElementList(scene, selectedTextId);
if (selected) {
setControlReady(duplicateButton);
setControlReady(deleteButton);
} else {
setControlUnavailable(duplicateButton, "请先新建或选择一个文字元素。");
setControlUnavailable(deleteButton, "请先新建或选择一个文字元素。");
}
selectionStatus.textContent = selected
? `已选中“${selected.text || "空文字"}” · 可在画板拖动,拖右下角控制点等比缩放`
: "请先新建或选择一个文字元素;也可点击画板中文字或从列表选择。";
const shouldRefreshFields = renderedSelectionId !== selectedTextId
|| ["subscribe", "selection", "create", "duplicate", "delete", "gesture"].includes(reason);
if (shouldRefreshFields) applyElementToFields(selected);
}
function numericValue(id, fallback) {
const raw = field(id).value.trim();
const value = Number(raw);
return raw && Number.isFinite(value) ? value : fallback;
}
function formPatch(element) {
return {
text: field("text-value").value,
font: fontPicker.getValue() || "default",
size: numericValue("text-size", element.size),
x: numericValue("text-x", element.x),
y: numericValue("text-y", element.y),
align: field("text-align").value,
color: colorTrigger.value,
};
}
form.addEventListener("input", (event) => {
if (event.target === fontInput || event.target === fontFileInput) return;
if (suppressForm) return;
const selected = getSelectedTextElement();
if (!selected) return;
updateTextElement(selected.id, formPatch(selected), "form");
});
document.getElementById("create-text").addEventListener("click", () => {
const created = addTextElement({
text: "ok",
font: defaults.font,
size: defaults.size,
x: 32,
y: 26,
align: "center",
color: "#FFFFFF",
});
announce(`已创建文字“${created.text}”,编辑完成后再应用统一画板`);
});
duplicateButton.addEventListener("click", () => {
const duplicate = duplicateTextElement();
if (duplicate) announce(`已复制文字“${duplicate.text}”并置于最上层`);
});
deleteButton.addEventListener("click", () => {
const selected = getSelectedTextElement();
if (selected && deleteTextElement(selected.id)) announce(`已删除文字“${selected.text || "空文字"}”`);
});
subscribeScene(renderSelection);
async function loadFontCatalog() {
const catalog = await apiJson("/api/fonts");
fontPicker.setItems(catalog.items);
acceptedFontExtensions = Array.isArray(catalog.accepted_extensions)
? catalog.accepted_extensions.map((value) => String(value).toLowerCase())
: acceptedFontExtensions;
fontMaxUploadBytes = Number(catalog.max_upload_bytes) || fontMaxUploadBytes;
fontCatalogReady = true;
updateFontPickerAvailability();
if (catalog.warnings?.length) {
setFontFeedback(catalog.warnings.join(";"), true);
}
return catalog;
}
function validateFontFile(file) {
const extension = file.name.includes(".")
? `.${file.name.split(".").at(-1).toLowerCase()}`
: "";
if (!acceptedFontExtensions.includes(extension)) {
return "type";
}
if (file.size > fontMaxUploadBytes) {
return "size";
}
return "";
}
function describeFontImportError(error) {
if (error?.code === "APP_STALE") {
return ["copy.dynamic.text.font_error_stale", "页面版本已过期,请刷新后再导入字体。"];
}
if (error?.code === "REQUEST_TIMEOUT") {
return ["copy.dynamic.text.font_error_timeout", "字体导入超时,请检查核桃派网络或服务状态后重试。"];
}
const messages = {
413: ["copy.dynamic.text.font_error_too_large", "字体导入失败:文件超过 32 MiB。"],
415: ["copy.dynamic.text.font_error_type", "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。"],
422: ["copy.dynamic.text.font_error_invalid", "字体导入失败:文件损坏或不包含可用字体。"],
507: ["copy.dynamic.text.font_error_storage", "字体导入失败:设备存储空间不足。"],
};
if (messages[error?.status]) return messages[error.status];
if (error instanceof TypeError) {
return ["copy.dynamic.text.font_error_network", "字体导入失败:无法连接设备,请检查网络后重试。"];
}
return ["copy.dynamic.text.font_error_unknown", "字体导入失败:服务器未能完成导入,请稍后重试。"];
}
importFontButton.addEventListener("click", () => fontFileInput.click());
fontFileInput.addEventListener("change", async () => {
const file = fontFileInput.files?.[0];
fontFileInput.value = "";
if (!file) return;
const validationError = validateFontFile(file);
if (validationError) {
const copy = validationError === "type"
? ["copy.dynamic.text.font_type_error", "只支持 TTF、OTF、TTC、OTC 字体文件。"]
: ["copy.dynamic.text.font_size_error", "字体文件不能超过 32 MiB。"];
setFontFeedbackTemplate(copy[0], copy[1], {}, true);
announce(fontFeedback.textContent, true);
return;
}
try {
setFontFeedbackTemplate(
"copy.dynamic.text.font_importing",
"正在导入字体“{name}”…",
{ name: file.name },
);
announce(fontFeedback.textContent);
const result = await runControlAction(
importFontButton,
{ label: "正在导入…" },
async () => apiFile(
`/api/fonts/import?filename=${encodeURIComponent(file.name)}`,
file,
{ method: "POST" },
),
);
await loadFontCatalog();
const selected = getSelectedTextElement();
const imported = result.fonts?.[0] || null;
if (selected && imported) {
fontPicker.setValue(imported.id);
updateTextElement(selected.id, { font: imported.id }, "form");
}
const name = imported?.label || file.name;
const feedback = result.created
? (selected
? ["copy.dynamic.text.font_imported_applied", "字体“{name}”已导入并应用到当前文字"]
: ["copy.dynamic.text.font_imported", "字体“{name}”已导入"])
: (selected
? ["copy.dynamic.text.font_exists_applied", "字体“{name}”已经存在,已应用到当前文字"]
: ["copy.dynamic.text.font_exists", "字体“{name}”已经存在"]);
setFontFeedbackTemplate(feedback[0], feedback[1], { name });
announce(fontFeedback.textContent);
} catch (error) {
const feedback = describeFontImportError(error);
setFontFeedbackTemplate(feedback[0], feedback[1], {}, true);
announce(fontFeedback.textContent, true);
}
});
async function initializeTextWorkspace() {
if (!initialized) {
initialized = true;
const [configResult, fontsResult] = await Promise.allSettled([
apiJson("/api/config"),
loadFontCatalog(),
]);
if (configResult.status === "fulfilled") {
const config = configResult.value;
defaults = {
font: config.default_font || "default",
size: Number(config.default_text_size) || 12,
};
} else {
announce(`默认文字设置读取失败,已使用内置默认值:${configResult.reason.message}`, true);
}
if (fontsResult.status === "rejected") {
fontCatalogReady = false;
updateFontPickerAvailability();
setFontFeedbackTemplate(
"copy.dynamic.text.font_catalog_failed",
"字体目录读取失败,当前字体值已保留:{error}",
{ error: fontsResult.reason.message },
true,
);
announce(fontFeedback.textContent, true);
}
}
if (!getSelectedTextElement()) {
const topmost = getScene().elements.at(-1);
if (topmost) selectTextElement(topmost.id);
else applyElementToFields(null);
}
}
registerWorkspace({
id: "text",
title: "文字显示",
elementId: "workspace-text",
order: 40,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter: initializeTextWorkspace,
});
@@ -0,0 +1,345 @@
import { announce, apiJson } from "../core.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { createLatchedDraftState } from "../wifi-draft-state.js";
const ssidInput = document.getElementById("wifi-ssid");
const passwordInput = document.getElementById("wifi-password");
const dhcpToggle = document.getElementById("wifi-dhcp-enabled");
const dhcpState = document.getElementById("wifi-dhcp-state");
const dhcpRow = dhcpToggle.closest(".wifi-dhcp-row");
const manualFields = document.getElementById("wifi-manual-fields");
const addressInput = document.getElementById("wifi-address");
const gatewayInput = document.getElementById("wifi-gateway");
const prefixInput = document.getElementById("wifi-prefix");
const dnsInput = document.getElementById("wifi-dns");
const immediateButton = document.getElementById("wifi-apply-immediate");
const nextBootButton = document.getElementById("wifi-apply-next-boot");
const activeStatus = document.getElementById("wifi-active-status");
const feedback = document.getElementById("wifi-feedback");
const delayInput = document.getElementById("wifi-prompt-delay");
const delaySaveButton = document.getElementById("wifi-prompt-delay-save");
const delayFeedback = document.getElementById("wifi-prompt-feedback");
const networkFields = new Map([
["ssid", { control: ssidInput, marker: document.getElementById("wifi-ssid-modified"), highlight: ssidInput }],
["password", { control: passwordInput, marker: document.getElementById("wifi-password-modified"), highlight: passwordInput }],
["dhcp", { control: dhcpToggle, marker: document.getElementById("wifi-dhcp-modified"), highlight: dhcpRow }],
["address", { control: addressInput, marker: document.getElementById("wifi-address-modified"), highlight: addressInput }],
["gateway", { control: gatewayInput, marker: document.getElementById("wifi-gateway-modified"), highlight: gatewayInput }],
["prefix", { control: prefixInput, marker: document.getElementById("wifi-prefix-modified"), highlight: prefixInput }],
["dns", { control: dnsInput, marker: document.getElementById("wifi-dns-modified"), highlight: dnsInput }],
]);
const networkDraft = createLatchedDraftState(networkFields.keys());
const delayDraft = createLatchedDraftState(["prompt_delay_seconds"]);
let wifiPending = false;
let delayPending = false;
let settingsLoaded = false;
let loadGeneration = 0;
let baseline = null;
function setFeedback(message, state = "idle") {
feedback.textContent = message;
feedback.dataset.state = state;
feedback.hidden = !message;
}
function setDelayFeedback(message, state = "idle") {
delayFeedback.textContent = message;
delayFeedback.dataset.state = state;
delayFeedback.hidden = !message;
}
function showDhcpMode() {
const enabled = dhcpToggle.checked;
dhcpState.textContent = enabled ? "开启" : "关闭";
manualFields.hidden = enabled;
addressInput.required = !enabled;
gatewayInput.required = !enabled;
}
function splitDns(value) {
return value.split(/[,,\s]+/u).map((item) => item.trim()).filter(Boolean);
}
function showStatus(data) {
const active = data.active || {};
if (active.connected) {
activeStatus.textContent = `当前已连接:${active.ssid || "未知 WiFi"} · ${active.ipv4_address || "等待 IPv4"}`;
} else if (data.available === false) {
activeStatus.textContent = `网络状态不可用:${data.error || "NetworkManager 无响应"}`;
} else {
activeStatus.textContent = "当前未连接 WiFi";
}
}
function formStateFromResponse(data) {
const saved = data.saved || {};
return {
ssid: saved.ssid || "",
password: saved.password || "",
passwordConfigured: Boolean(saved.password_configured),
dhcp: (saved.ipv4_mode || "dhcp") === "dhcp",
address: saved.address || "",
gateway: saved.gateway || "",
prefix: saved.prefix ?? "",
dns: (saved.dns_servers || []).join(", "),
promptDelay: data.prompt_delay_seconds ?? 30,
};
}
function applyFormState(state) {
if (!state) return;
ssidInput.value = state.ssid;
passwordInput.value = state.password;
passwordInput.placeholder = state.passwordConfigured ? "" : "请输入 8..63 字节密码";
dhcpToggle.checked = state.dhcp;
addressInput.value = state.address;
gatewayInput.value = state.gateway;
prefixInput.value = state.prefix;
dnsInput.value = state.dns;
delayInput.value = String(state.promptDelay);
showDhcpMode();
}
function setNetworkMarker(key, modified) {
const field = networkFields.get(key);
field.marker.hidden = !modified;
field.highlight.classList.toggle("wifi-field-modified", modified);
}
function clearNetworkDraft() {
networkDraft.clear();
networkFields.forEach((_field, key) => setNetworkMarker(key, false));
updateNetworkButtons();
}
function clearDelayDraft() {
delayDraft.clear();
updateDelayButton();
}
function updateNetworkButtons() {
if (wifiPending) return;
const ready = settingsLoaded && networkDraft.dirty;
immediateButton.classList.toggle("wifi-action-ready", ready);
nextBootButton.classList.toggle("wifi-action-ready", ready);
if (ready) {
setControlReady(immediateButton);
setControlReady(nextBootButton);
return;
}
const reason = settingsLoaded
? "请先修改 WiFi 或 DHCP 设置,再选择生效方式。"
: "请等待当前 WiFi 设置读取完成。";
setControlUnavailable(immediateButton, reason);
setControlUnavailable(nextBootButton, reason);
}
function updateDelayButton() {
if (delayPending) return;
const ready = settingsLoaded && delayDraft.dirty;
delaySaveButton.classList.toggle("wifi-action-ready", ready);
if (ready) {
setControlReady(delaySaveButton);
return;
}
setControlUnavailable(
delaySaveButton,
settingsLoaded ? "请先修改网络提示等待时间。" : "请等待当前网络提示设置读取完成。",
);
}
function markNetworkField(key) {
if (!settingsLoaded) return;
networkDraft.mark(key);
setNetworkMarker(key, true);
updateNetworkButtons();
}
function markDelayField() {
if (!settingsLoaded) return;
delayDraft.mark("prompt_delay_seconds");
updateDelayButton();
}
function syncForm(data) {
baseline = formStateFromResponse(data);
applyFormState(baseline);
settingsLoaded = true;
clearNetworkDraft();
clearDelayDraft();
showStatus(data);
}
export async function loadWifiSettings() {
const generation = ++loadGeneration;
settingsLoaded = false;
clearNetworkDraft();
clearDelayDraft();
try {
const data = await apiJson("/api/network/wifi");
if (generation !== loadGeneration) return;
syncForm(data);
setFeedback("");
setDelayFeedback("");
} catch (error) {
if (generation !== loadGeneration) return;
setFeedback(`WiFi 设置读取失败:${error.message}`, "error");
announce(`WiFi 设置读取失败:${error.message}`, true);
updateNetworkButtons();
updateDelayButton();
}
}
function buildRequest(activation) {
const ssid = ssidInput.value.trim();
if (!ssid) throw new Error("请输入 WiFi 名称(SSID)");
const password = passwordInput.value;
if (ssid !== baseline.ssid && !password) throw new Error("修改 WiFi 名称时必须填写新密码");
if (!password && !baseline.passwordConfigured) throw new Error("当前没有已保存密码,请填写 WiFi 密码");
const manual = !dhcpToggle.checked;
if (manual && !addressInput.value.trim()) throw new Error("关闭 DHCP 后必须填写静态 IPv4 地址");
if (manual && !gatewayInput.value.trim()) throw new Error("关闭 DHCP 后必须填写网关");
const rawPrefix = prefixInput.value.trim();
const prefix = rawPrefix ? Number(rawPrefix) : null;
if (rawPrefix && (!Number.isInteger(prefix) || prefix < 1 || prefix > 32)) {
throw new Error("子网前缀必须是 1..32 的整数");
}
return {
ssid,
password: password || null,
ipv4_mode: manual ? "manual" : "dhcp",
address: manual ? addressInput.value.trim() : null,
gateway: manual ? gatewayInput.value.trim() : null,
prefix,
dns_servers: manual ? splitDns(dnsInput.value) : [],
activation,
};
}
function captureNetworkBaseline(request) {
baseline = {
...baseline,
ssid: request.ssid,
password: request.password || baseline.password,
passwordConfigured: Boolean(request.password || baseline.passwordConfigured),
dhcp: request.ipv4_mode === "dhcp",
address: request.address || "",
gateway: request.gateway || "",
prefix: request.prefix ?? "",
dns: (request.dns_servers || []).join(", "),
};
}
async function applyWifi(activation) {
if (wifiPending || !networkDraft.dirty) return;
let request;
try {
request = buildRequest(activation);
} catch (error) {
setFeedback(error.message, "error");
announce(error.message, true);
return;
}
wifiPending = true;
setControlBusy(immediateButton, {
label: activation === "immediate" ? "正在保存…" : undefined,
message: "正在安全保存 WiFi 与 IPv4 配置…",
});
setControlBusy(nextBootButton, {
label: activation === "next_boot" ? "正在保存…" : undefined,
});
setFeedback(
activation === "immediate"
? "正在保存;设备随后会切换网络,当前页面可能断开。"
: "正在保存下次断电开机使用的网络配置…",
"pending",
);
try {
const result = await apiJson("/api/network/wifi", {
method: "PUT",
body: JSON.stringify(request),
});
captureNetworkBaseline(request);
clearNetworkDraft();
setFeedback(result.message, "success");
announce(result.message);
if (result.network) showStatus(result.network);
} catch (error) {
setFeedback(`WiFi 设置保存失败:${error.message}`, "error");
announce(`WiFi 设置保存失败:${error.message}`, true);
} finally {
wifiPending = false;
updateNetworkButtons();
}
}
function validatedPromptDelay() {
const delay = Number(delayInput.value);
if (!Number.isInteger(delay) || delay < 1 || delay > 3600) {
throw new Error("网络提示等待时间必须是 1..3600 秒的整数");
}
return delay;
}
async function savePromptDelay() {
if (delayPending || !delayDraft.dirty) return;
let delay;
try {
delay = validatedPromptDelay();
} catch (error) {
setDelayFeedback(error.message, "error");
announce(error.message, true);
return;
}
delayPending = true;
setControlBusy(delaySaveButton, {
label: "正在保存…",
message: "正在保存网络提示等待时间…",
});
setDelayFeedback("正在保存网络提示等待时间…", "pending");
try {
const result = await apiJson("/api/network/wifi/prompt-delay", {
method: "PUT",
body: JSON.stringify({ prompt_delay_seconds: delay }),
});
baseline = { ...baseline, promptDelay: result.prompt_delay_seconds };
delayInput.value = String(result.prompt_delay_seconds);
clearDelayDraft();
setDelayFeedback(result.message, "success");
announce(result.message);
} catch (error) {
setDelayFeedback(`网络提示等待时间保存失败:${error.message}`, "error");
announce(`网络提示等待时间保存失败:${error.message}`, true);
} finally {
delayPending = false;
updateDelayButton();
}
}
export function discardWifiSettingsDrafts() {
loadGeneration += 1;
applyFormState(baseline);
settingsLoaded = false;
clearNetworkDraft();
clearDelayDraft();
setFeedback("");
setDelayFeedback("");
}
for (const [key, field] of networkFields) {
if (key === "dhcp") continue;
field.control.addEventListener("input", () => markNetworkField(key));
}
dhcpToggle.addEventListener("change", () => {
showDhcpMode();
markNetworkField("dhcp");
});
delayInput.addEventListener("input", markDelayField);
immediateButton.addEventListener("click", () => applyWifi("immediate"));
nextBootButton.addEventListener("click", () => applyWifi("next_boot"));
delaySaveButton.addEventListener("click", savePromptDelay);
updateNetworkButtons();
updateDelayButton();