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