初始化奇妙小屏幕控制器项目
This commit is contained in:
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();
|
||||
Reference in New Issue
Block a user