Files
d14594f14d feat(web): add local management dashboard (#718)
* feat(web): add local management dashboard

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(web): address review and Python 2 compatibility

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* chore: remove design session artifacts

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(web): harden packaged resources and HTTP semantics

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* test(web): support Python 2 handler mocks

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(web): detect all external schedulers

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-11 18:23:17 +00:00

1927 lines
65 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
"use strict";
function parseFragment() {
var raw = window.location.hash.replace(/^#/, "");
var values = {};
if (raw === "overview" || raw === "config") {
values.view = raw;
return values;
}
raw.split("&").forEach(function (part) {
var pieces = part.split("=");
if (!pieces[0]) {
return;
}
try {
values[decodeURIComponent(pieces[0])] = decodeURIComponent(pieces.slice(1).join("="));
} catch (error) {
return;
}
});
return values;
}
function readSessionToken() {
try {
return window.sessionStorage.getItem("ddns-dashboard-token") || "";
} catch (error) {
return "";
}
}
function storeSessionToken(value) {
try {
window.sessionStorage.setItem("ddns-dashboard-token", value);
return true;
} catch (error) {
return false;
}
}
function fragmentForView(view, accessToken) {
var encodedView = encodeURIComponent(view);
return accessToken
? "#token=" + encodeURIComponent(accessToken) + "&view=" + encodedView
: "#" + encodedView;
}
var launch = parseFragment();
var token = launch.token || readSessionToken();
var initialView = launch.view === "config" ? "config" : "overview";
if (launch.token) {
storeSessionToken(launch.token);
} else if (token) {
window.history.replaceState(null, "", fragmentForView(initialView, token));
}
var app = document.getElementById("app");
var state = {
dashboard: null,
configModel: null,
config: null,
originalConfig: null,
configPath: "",
configExists: false,
backupAvailable: false,
profileIndex: 0,
dirty: false,
jsonDirty: false,
jsonBaseline: "",
repairRequired: false,
invalidRawConfig: null,
saving: false,
activityFilter: "all",
recordQuery: "",
toastTimer: null,
toastExitTimer: null,
restoreTimer: null,
restoreArmed: false,
deleteTimer: null,
deleteArmed: false,
setupOpened: false,
sectionFingerprints: null,
dashboardRefreshTimer: null,
schedulerIntervalDirty: false,
};
function byId(id) {
return document.getElementById(id);
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function make(tag, className, text) {
var node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text !== undefined && text !== null) {
node.textContent = text;
}
return node;
}
function makeIcon(name) {
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
var use = document.createElementNS("http://www.w3.org/2000/svg", "use");
svg.setAttribute("aria-hidden", "true");
use.setAttribute("href", "#icon-" + name);
svg.appendChild(use);
return svg;
}
function clear(node) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
function setText(id, value) {
byId(id).textContent = value;
}
function errorMessage(error) {
if (error && error.code === "invalid_token") {
return "访问凭据无效,请使用 ddns web 输出的新链接重新打开。";
}
return error && error.message ? error.message : "请求失败,请检查控制台进程后重试。";
}
function api(path, options) {
var request = options || {};
var method = request.method || "GET";
var headers = { Accept: "application/json", "X-DDNS-Token": token };
var init = {
method: method,
headers: headers,
credentials: "same-origin",
};
if (method !== "GET" && method !== "HEAD") {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(request.body || {});
}
return fetch(path, init).then(function (response) {
return response.text().then(function (body) {
var payload = {};
if (body) {
try {
payload = JSON.parse(body);
} catch (parseError) {
throw new Error("控制台返回了无法解析的数据。");
}
}
if (!response.ok) {
var detail = payload.error;
var requestError = new Error(
detail && detail.message ? detail.message : "请求失败(" + response.status + ")。",
);
requestError.code = detail && detail.code ? detail.code : "request_failed";
throw requestError;
}
return payload;
});
});
}
function hideToast() {
var toast = byId("toast");
clearTimeout(state.toastTimer);
clearTimeout(state.toastExitTimer);
toast.classList.remove("visible");
state.toastExitTimer = setTimeout(function () {
toast.hidden = true;
}, 180);
}
function showToast(message, tone) {
var toast = byId("toast");
clearTimeout(state.toastTimer);
clearTimeout(state.toastExitTimer);
toast.className = "toast" + (tone ? " " + tone : "");
setText("toast-message", message);
toast.hidden = false;
void toast.offsetWidth;
toast.classList.add("visible");
state.toastTimer = setTimeout(function () {
hideToast();
}, 3600);
}
function setNotice(message, tone) {
var notice = byId("config-notice");
notice.className = "config-notice" + (tone ? " " + tone : "");
notice.textContent = message || "";
notice.hidden = !message;
}
function hasPendingChanges() {
return state.dirty || state.jsonDirty;
}
function setJsonEditorValue(value) {
var editor = byId("json-editor");
editor.value = value;
state.jsonBaseline = value;
state.jsonDirty = false;
}
function updateJsonDirtyState() {
state.jsonDirty = byId("json-editor").value !== state.jsonBaseline;
updateDirtyState();
}
function updateStructuredControls() {
var disabled = state.repairRequired || state.jsonDirty || state.saving;
var selector = [
"#profile-select",
"#add-profile-button",
"#delete-profile-button",
"#provider-pane input",
"#provider-pane select",
"#provider-pane button",
"#domains-pane input",
"#domains-pane button",
"#network-pane input",
"#network-pane select",
"#network-pane textarea",
"#network-pane button",
"#scheduler-interval",
"#automation-button",
].join(",");
document.querySelectorAll(selector).forEach(function (control) {
control.disabled = disabled;
});
}
function setBusy(isBusy) {
app.classList.toggle("busy", isBusy);
}
function getStoredTheme() {
try {
return window.localStorage.getItem("ddns-dashboard-theme");
} catch (error) {
return null;
}
}
function applyTheme(theme) {
var dark = theme === "dark";
app.classList.toggle("dark", dark);
document.body.classList.toggle("dark-theme", dark);
byId("theme-button").setAttribute("aria-label", dark ? "切换浅色模式" : "切换深色模式");
try {
window.localStorage.setItem("ddns-dashboard-theme", dark ? "dark" : "light");
} catch (error) {
return;
}
}
function initialTheme() {
var stored = getStoredTheme();
if (stored) {
return stored;
}
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function setActiveNavigation(name) {
["overview", "config"].forEach(function (viewName) {
var button = byId("nav-" + viewName);
var active = viewName === name;
button.classList.toggle("active", active);
if (active) {
button.setAttribute("aria-current", "page");
} else {
button.removeAttribute("aria-current");
}
});
document.querySelector(".command-nav").classList.toggle("config-active", name === "config");
}
function setView(name, updateHash, shouldScroll) {
var target = byId(name);
if (!target) {
return;
}
if (name === "config") {
byId("config-shell").open = true;
}
setActiveNavigation(name);
var nextFragment = fragmentForView(name, token);
if (updateHash && window.location.hash !== nextFragment) {
window.history.replaceState(null, "", nextFragment);
}
if (shouldScroll !== false) {
var reduceMotion =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
target.scrollIntoView({
behavior: updateHash && !reduceMotion ? "smooth" : "auto",
block: "start",
});
}
}
function isHealthyProductionConfiguration() {
var dashboard = state.dashboard || {};
var productionProviders = (dashboard.providers || []).filter(function (provider) {
return provider.id !== "debug" && Number(provider.records || 0) > 0;
});
return (
dashboard.state === "synced" &&
productionProviders.length > 0 &&
productionProviders.every(function (provider) {
return provider.status === "synced";
})
);
}
function renderConfigSummary() {
var dashboard = state.dashboard || {};
var providers = dashboard.providers || [];
var productionProviders = providers.filter(function (provider) {
return provider.id !== "debug";
});
var summaryProviders = productionProviders.length ? productionProviders : providers;
var recordCount = summaryProviders.reduce(function (sum, provider) {
return sum + Number(provider.records || 0);
}, 0);
var providerText =
summaryProviders.length === 1
? summaryProviders[0].label || summaryProviders[0].id
: summaryProviders.length + " 个 DNS 服务商";
var title = summaryProviders.length
? providerText + " · " + recordCount + " 条解析"
: "尚未配置 DNS 服务商";
var configPath = state.configPath || dashboard.config_path || "";
var configName = configPath ? configPath.split(/[\\/]/).pop() : "本机配置";
var scheduler = dashboard.scheduler || {};
var schedulerText = "";
if (scheduler.conflict) {
schedulerText = "自动同步存在冲突";
} else if (scheduler.enabled) {
schedulerText = "每 " + scheduler.interval + " 分钟自动同步";
} else if (scheduler.scheduler) {
schedulerText = "自动同步已暂停";
}
setText("config-summary-title", title);
setText("config-summary-detail", [configName, schedulerText].filter(Boolean).join(" · "));
var status = byId("config-summary-status");
var statusText = "读取中";
var tone = "";
if (state.repairRequired) {
statusText = "需要修复";
tone = "error";
} else if (hasPendingChanges()) {
statusText = "有未保存修改";
tone = "attention";
} else if (dashboard.state === "error") {
statusText = "同步异常";
tone = "error";
} else if (isHealthyProductionConfiguration()) {
statusText = "运行正常";
tone = "success";
} else if (
providers.length &&
providers.every(function (provider) {
return provider.id === "debug";
})
) {
statusText = "调试配置";
tone = "attention";
} else if (dashboard.state === "ready") {
statusText = "等待首次同步";
tone = "attention";
} else if (dashboard.state === "unconfigured") {
statusText = "尚未配置";
tone = "attention";
}
status.className = "config-summary-status" + (tone ? " " + tone : "");
status.querySelector("span").textContent = statusText;
}
function initializeConfigSections() {
if (!window.matchMedia) {
return;
}
var media = window.matchMedia("(max-width: 720px)");
var applyLayout = function (compact) {
document.querySelectorAll(".config-pane").forEach(function (pane, index) {
pane.open = compact ? index === 0 : true;
});
};
applyLayout(media.matches);
var handleChange = function (event) {
applyLayout(event.matches);
};
if (media.addEventListener) {
media.addEventListener("change", handleChange);
} else if (media.addListener) {
media.addListener(handleChange);
}
document.querySelectorAll(".config-pane > summary").forEach(function (summary) {
summary.addEventListener("click", function (event) {
if (!media.matches) {
event.preventDefault();
}
});
});
}
function initializeConfigShell() {
var requestedConfigView = parseFragment().view === "config";
byId("config-shell").open =
requestedConfigView || state.repairRequired || !isHealthyProductionConfiguration();
}
function formatTime(value) {
if (!value) {
return "—";
}
var date = typeof value === "number" ? new Date(value * 1000) : new Date(value);
if (isNaN(date.getTime())) {
return "—";
}
var today = new Date();
var sameDay =
today.getFullYear() === date.getFullYear() &&
today.getMonth() === date.getMonth() &&
today.getDate() === date.getDate();
var time = date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
if (sameDay) {
return time;
}
return (
String(date.getMonth() + 1).padStart(2, "0") +
"-" +
String(date.getDate()).padStart(2, "0") +
" " +
time
);
}
function dashboardStateCopy(dashboard) {
if (dashboard.state === "error") {
return {
title: "最近同步失败",
summary: dashboard.message || "检查运行记录后重试同步。",
tone: "error",
header: "同步失败",
};
}
if (dashboard.state === "synced") {
return {
title: "解析状态稳定",
summary: dashboard.last_sync
? "最近同步于 " + formatTime(dashboard.last_sync) + ",本机缓存可用。"
: "解析记录与本机配置保持一致。",
tone: "",
header: "运行正常",
};
}
if (dashboard.state === "ready") {
return {
title: "配置已就绪",
summary: "运行一次同步后,这里会显示地址与解析结果。",
tone: "attention",
header: "等待同步",
};
}
return {
title: "配置这台设备",
summary: "选择 DNS 服务商并添加域名;保存后再主动运行首次同步。",
tone: "attention",
header: "尚未配置",
};
}
function isFirstRun() {
return Boolean(state.dashboard && state.dashboard.state === "unconfigured");
}
function renderSetupMode() {
var firstRun = isFirstRun();
var syncButton = byId("sync-button");
var overviewNav = byId("nav-overview");
app.classList.toggle("first-run", firstRun);
byId("overview").hidden = firstRun;
overviewNav.disabled = firstRun;
overviewNav.setAttribute("aria-disabled", String(firstRun));
overviewNav.title = firstRun ? "保存配置后可查看运行状态" : "";
document.querySelector(".brand").disabled = firstRun;
byId("runtime-overview").hidden = firstRun;
byId("runtime-ledger").hidden = firstRun;
byId("scheduler-button").hidden = firstRun;
byId("setup-brief").hidden = !firstRun;
setText("topology-address-label", firstRun ? "步骤 1" : "本机地址");
setText("topology-provider-label", firstRun ? "步骤 2" : "DNS 服务商");
setText("topology-record-label", firstRun ? "步骤 3" : "解析记录");
setText("config-title", firstRun ? (state.configExists ? "完成配置" : "首次配置") : "配置管理");
setText(
"config-description",
firstRun
? "完成服务商与域名设置;保存前所有修改只保留在当前页面。"
: "维护本机正在使用的配置;未填写的字段继续继承环境变量或默认值。",
);
setText("setup-title", state.configExists ? "完成这份配置" : "配置这台设备");
setText("setup-path", state.configPath || (state.dashboard && state.dashboard.config_path) || "config.json");
syncButton.querySelector("span").textContent = firstRun ? "开始配置" : "立即同步";
syncButton.querySelector("use").setAttribute("href", firstRun ? "#icon-plus" : "#icon-sync");
}
function makeSettingsRow(options) {
var row = make("div", "settings-row");
var badge = make("span", "setting-icon " + (options.tone || "blue"));
if (options.icon) {
badge.appendChild(makeIcon(options.icon));
} else {
badge.textContent = options.badge || "DNS";
}
var copyNode = make("span", "setting-copy");
copyNode.appendChild(make("strong", "", options.title));
copyNode.appendChild(make("small", "", options.subtitle));
row.appendChild(badge);
row.appendChild(copyNode);
row.appendChild(make("code", "", options.value || "—"));
var status = make("span", "connected-state" + (options.statusTone ? " " + options.statusTone : ""));
status.appendChild(make("i"));
status.appendChild(document.createTextNode(options.status || "可用"));
row.appendChild(status);
return row;
}
function renderAddresses(dashboard) {
var list = byId("address-list");
var addresses = dashboard.addresses || [];
clear(list);
setText("address-count", addresses.length + " 个可用");
if (!addresses.length) {
var empty = make("div", "native-empty");
empty.appendChild(make("strong", "", "尚无地址缓存"));
empty.appendChild(make("span", "", "完成一次同步后显示实际 IPv4 / IPv6 地址"));
list.appendChild(empty);
return;
}
addresses.forEach(function (address) {
var isV6 = address.family === "IPv6";
list.appendChild(
makeSettingsRow({
icon: "globe",
tone: isV6 ? "violet" : "blue",
title: address.family,
subtitle: "最近同步缓存",
value: address.value,
status: "可用",
}),
);
});
}
function cacheDescription() {
if (!state.config) {
return "读取中";
}
if (state.config.cache === false) {
return "已关闭";
}
if (typeof state.config.cache === "string") {
return "自定义路径";
}
return "已启用";
}
function renderRuntime(dashboard) {
var list = byId("runtime-list");
var providers = dashboard.providers || [];
clear(list);
setText("runtime-count", providers.length ? providers.length + " 个服务商" : "本机");
providers.forEach(function (provider) {
var failed = provider.status === "error";
var synced = provider.status === "synced";
list.appendChild(
makeSettingsRow({
badge: String(provider.label || provider.id).slice(0, 3).toUpperCase(),
title: provider.label || provider.id,
subtitle: provider.records + " 条已配置记录",
value: provider.id,
status: failed ? "同步失败" : synced ? "已同步" : "已配置",
statusTone: failed ? "error" : synced ? "" : "attention",
}),
);
});
list.appendChild(
makeSettingsRow({
icon: "shield",
tone: "blue",
title: "本地缓存",
subtitle: "避免地址未变化时重复请求",
value: state.config && state.config.cache === false ? "OFF" : "ON",
status: cacheDescription(),
statusTone: state.config && state.config.cache === false ? "muted" : "",
}),
);
}
function makeRecordRow(record) {
var row = make("div", "record-row");
row.appendChild(make("span", "record-type", record.type || "A"));
var copyNode = make("span", "setting-copy");
copyNode.appendChild(make("strong", "", record.domain));
copyNode.appendChild(make("small", "", record.provider));
row.appendChild(copyNode);
row.appendChild(make("code", "", record.value));
var status = make("span", "connected-state");
status.appendChild(make("i"));
status.appendChild(document.createTextNode("已同步"));
row.appendChild(status);
return row;
}
function renderRecords() {
var list = byId("record-list");
var records = (state.dashboard && state.dashboard.records) || [];
var search = byId("record-search");
var searchable = records.length > 5;
byId("record-search-field").hidden = !searchable;
if (!searchable) {
state.recordQuery = "";
search.value = "";
}
var query = state.recordQuery.toLowerCase();
var filtered = records.filter(function (record) {
return !query || (record.domain + " " + record.value + " " + record.provider).toLowerCase().indexOf(query) >= 0;
});
clear(list);
setText("record-count", records.length + " 条");
if (!filtered.length) {
var empty = make("div", "native-empty");
empty.appendChild(make("strong", "", query ? "没有匹配的解析记录" : "暂无解析记录"));
empty.appendChild(
make("span", "", query ? "尝试更短的域名或地址关键词" : "完成一次同步后会显示本机缓存结果"),
);
list.appendChild(empty);
return;
}
filtered.forEach(function (record) {
list.appendChild(makeRecordRow(record));
});
}
function isAttentionActivity(activity) {
var level = String(activity.level || "").toUpperCase();
return level === "WARN" || level === "WARNING" || level === "ERROR" || level === "CRITICAL";
}
function renderActivities() {
var list = byId("activity-list");
var activities = (state.dashboard && state.dashboard.activities) || [];
var filter = byId("activity-filter");
filter.hidden = activities.length <= 5;
if (filter.hidden && state.activityFilter !== "all") {
state.activityFilter = "all";
filter.querySelectorAll("button").forEach(function (button) {
var active = button.getAttribute("data-activity-filter") === "all";
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
}
var filtered = activities.filter(function (activity) {
return state.activityFilter === "all" || isAttentionActivity(activity);
});
clear(list);
setText("activity-count", activities.length + " 项");
if (!filtered.length) {
var empty = make("li", "native-empty");
var copyNode = make("div");
copyNode.appendChild(make("strong", "", state.activityFilter === "all" ? "暂无活动" : "没有需要关注的活动"));
copyNode.appendChild(make("small", "", "同步与调度结果会记录在这里"));
empty.appendChild(copyNode);
list.appendChild(empty);
return;
}
filtered.slice(0, 20).forEach(function (activity) {
var attention = isAttentionActivity(activity);
var item = make("li");
item.appendChild(make("time", "", formatTime(activity.timestamp)));
item.appendChild(make("span", "event-badge " + (attention ? "warn" : "info"), attention ? "需关注" : "完成"));
item.appendChild(make("span", "event-source", activity.source || "DDNS"));
var copyNode = make("div");
copyNode.appendChild(make("strong", "", activity.message || "操作完成"));
copyNode.appendChild(make("small", "", activity.detail || "本机控制台"));
item.appendChild(copyNode);
list.appendChild(item);
});
}
function renderScheduler(dashboard) {
var scheduler = dashboard.scheduler || {};
var enabled = Boolean(scheduler.enabled);
var conflict = Boolean(scheduler.conflict);
var interval = scheduler.interval ? " · 每 " + scheduler.interval + " 分钟" : "";
var nextRun = scheduler.next_run ? " · 下次 " + formatTime(scheduler.next_run) : "";
byId("scheduler-button").classList.toggle("attention", conflict);
byId("automation-button").closest(".automation-card").classList.toggle("attention", conflict);
if (conflict) {
setText("scheduler-action", "接管自动同步");
setText("scheduler-title", (scheduler.external_scheduler || "系统") + " 外部任务仍在运行");
setText("automation-title", "检测到外部调度冲突");
setText("automation-detail", "停用外部任务后由当前 Web 进程接管,避免重复更新。");
setText("automation-button", "接管");
} else if (enabled) {
setText("scheduler-action", "暂停自动同步");
setText("scheduler-title", "Web 内置调度运行中" + interval);
setText("automation-title", scheduler.running ? "正在执行自动同步" : "Web 自动同步已启用");
setText(
"automation-detail",
scheduler.last_error ? "最近一次失败:" + scheduler.last_error : "当前进程负责周期同步" + nextRun,
);
setText("automation-button", "暂停");
} else {
setText("scheduler-action", "恢复自动同步");
setText("scheduler-title", "Web 内置调度已暂停" + interval);
setText("automation-title", "Web 自动同步已暂停");
setText("automation-detail", "仅暂停当前进程;重启后按启动间隔恢复。");
setText("automation-button", "恢复");
}
var input = byId("scheduler-interval");
if (scheduler.interval && document.activeElement !== input && !state.schedulerIntervalDirty) {
input.value = scheduler.interval;
}
updateSchedulerButton();
}
function updateSchedulerButton() {
var scheduler = (state.dashboard && state.dashboard.scheduler) || {};
var interval = Number(byId("scheduler-interval").value || 0);
if (scheduler.conflict) {
setText("automation-button", "接管");
} else if (state.schedulerIntervalDirty) {
setText("automation-button", "保存后生效");
} else if (interval && interval !== Number(scheduler.interval)) {
setText("automation-button", "应用");
} else {
setText("automation-button", scheduler.enabled ? "暂停" : "恢复");
}
}
function dashboardFingerprints(dashboard) {
var scheduler = dashboard.scheduler || {};
return {
addresses: JSON.stringify(dashboard.addresses || []),
runtime: JSON.stringify([
dashboard.providers || [],
{
enabled: scheduler.enabled,
interval: scheduler.interval,
conflict: scheduler.conflict,
running: scheduler.running,
last_run: scheduler.last_run,
last_error: scheduler.last_error,
},
]),
records: JSON.stringify(dashboard.records || []),
activities: JSON.stringify((dashboard.activities || []).slice(0, 8)),
};
}
function replayCue(element, className, duration) {
if (!element) {
return;
}
var timerName = "__" + className + "Timer";
clearTimeout(element[timerName]);
element.classList.remove(className);
void element.offsetWidth;
element.classList.add(className);
element[timerName] = setTimeout(function () {
element.classList.remove(className);
}, duration);
}
function cueDashboardChanges(previous, current) {
if (!previous) {
return;
}
var targets = {
addresses: document.querySelector("#runtime-overview .settings-section:first-child"),
runtime: document.querySelector("#runtime-overview .settings-section:last-child"),
records: document.querySelector(".records-section"),
activities: document.querySelector(".activity-section"),
};
Object.keys(current).forEach(function (name) {
if (current[name] !== previous[name]) {
replayCue(targets[name], "data-updated", 640);
}
});
}
function cueSyncOutcome(tone) {
var hero = byId("status-hero");
hero.classList.remove("syncing");
replayCue(hero, tone === "error" ? "sync-failed" : "sync-complete", 760);
}
function cuePersistedConfig() {
replayCue(byId("save-state"), "confirmed", 560);
}
function setSaveButtonLabels(label) {
byId("save-button").textContent = label;
byId("footer-save-button").textContent = label;
}
function renderDashboard() {
var dashboard = state.dashboard;
if (!dashboard) {
return;
}
var fingerprints = dashboardFingerprints(dashboard);
var previousFingerprints = state.sectionFingerprints;
var copy = dashboardStateCopy(dashboard);
var providers = dashboard.providers || [];
var addresses = dashboard.addresses || [];
var configuredRecords = providers.reduce(function (sum, provider) {
return sum + Number(provider.records || 0);
}, 0);
var header = byId("header-health");
var hero = byId("status-hero");
var firstRun = dashboard.state === "unconfigured";
header.className = "header-health" + (copy.tone ? " " + copy.tone : "");
header.removeAttribute("title");
hero.className = "status-hero" + (copy.tone ? " " + copy.tone : "");
header.querySelector("span").textContent = copy.header;
setText("status-title", copy.title);
setText("status-summary", copy.summary);
setText("topology-addresses", firstRun ? "选择服务商" : addresses.length);
setText("topology-providers", firstRun ? "添加域名" : providers.length);
setText("topology-records", firstRun ? "保存后同步" : configuredRecords);
var latest = (dashboard.activities || [])[0];
setText("latest-time", firstRun ? "未写入" : latest ? formatTime(latest.timestamp) : "—");
setText("latest-title", firstRun ? "从下方的真实配置表单开始" : latest ? latest.message : "等待首次同步");
setText(
"latest-detail",
firstRun
? (state.configPath || dashboard.config_path || "config.json") + " · 保存前不会修改磁盘"
: latest
? latest.detail || latest.source
: "运行结果会自动汇集在这里",
);
renderSetupMode();
renderAddresses(dashboard);
renderRuntime(dashboard);
renderRecords();
renderActivities();
renderScheduler(dashboard);
renderConfigSummary();
state.sectionFingerprints = fingerprints;
cueDashboardChanges(previousFingerprints, fingerprints);
}
function providerCatalog() {
return configModel().providers;
}
function configModel() {
if (!state.configModel) {
throw new Error("配置字段模型尚未加载。");
}
return state.configModel;
}
function providerMetadata(providerId) {
return providerCatalog().filter(function (provider) {
return provider.id === providerId;
})[0];
}
function providerLabel(providerId) {
var found = providerMetadata(providerId);
return found ? found.name : providerId || "未命名服务商";
}
function newProvider() {
var defaults = configModel().defaults;
return {
provider: defaults.provider,
index4: clone(defaults.index),
index6: clone(defaults.index),
};
}
function currentProfile() {
if (!state.config || !state.config.providers) {
return null;
}
return state.config.providers[state.profileIndex] || null;
}
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
function effectiveProfileValue(profile, key, fallback) {
if (profile && hasOwn(profile, key)) {
return profile[key];
}
if (state.config && hasOwn(state.config, key)) {
return state.config[key];
}
return fallback;
}
function ensureConfigShape() {
var model = configModel();
var defaults = model.defaults;
if (!state.config || typeof state.config !== "object" || Array.isArray(state.config)) {
state.config = {
"$schema": model.schema.url,
ssl: defaults.ssl,
proxy: defaults.proxy.slice(),
cache: defaults.cache,
cache_max_age: defaults.cacheMaxAge,
log: { level: defaults.logLevel },
providers: [],
};
}
if (!Array.isArray(state.config.providers)) {
state.config.providers = [];
}
if (!state.config.providers.length && !state.repairRequired) {
state.config.providers.push(newProvider());
state.profileIndex = 0;
setNotice("当前文件尚无 DNS 服务商。已创建一份未保存的初始配置,请按需修改。");
}
}
function sourceText(value) {
if (value === false) {
return "false";
}
if (Array.isArray(value)) {
return value.join("\n");
}
return value === undefined || value === null ? "" : String(value);
}
function parseSources(value) {
var trimmed = value.trim();
var rules = configModel().rules;
if (!trimmed) {
return null;
}
if (rules.falseAliases.indexOf(trimmed.toLowerCase()) >= 0) {
return false;
}
var result = [];
trimmed.split(/\r?\n/).forEach(function (line) {
var separator = line.indexOf(",") >= 0 ? "," : line.indexOf(";") >= 0 ? ";" : null;
var parts = separator ? line.split(separator) : [line];
for (var index = 0; index < parts.length; index += 1) {
var part = parts[index].trim();
if (!part) {
continue;
}
if (
separator &&
rules.addressSourcePrefixes.some(function (prefix) {
return part.indexOf(prefix) === 0;
})
) {
result.push(parts.slice(index).join(separator).trim());
break;
}
result.push(/^\d+$/.test(part) ? Number(part) : part);
}
});
return result;
}
function proxyText(value) {
if (Array.isArray(value)) {
return value.join(", ");
}
return value || "";
}
function parseProxy(value) {
if (!value.trim()) {
return null;
}
return value
.split(/[;,]/)
.map(function (item) {
return item.trim();
})
.filter(Boolean);
}
function updateProviderSelect(selectedId) {
var select = byId("provider-name");
clear(select);
var catalog = providerCatalog().slice();
if (
selectedId &&
!catalog.some(function (provider) {
return provider.id === selectedId;
})
) {
catalog.push({ id: selectedId, name: selectedId });
}
catalog.forEach(function (provider) {
var option = make("option", "", provider.name + (provider.testOnly ? " · 测试" : ""));
option.value = provider.id;
option.selected = provider.id === selectedId;
select.appendChild(option);
});
}
function profileOptionText(profile) {
var domainCount =
effectiveProfileValue(profile, "ipv4", []).length + effectiveProfileValue(profile, "ipv6", []).length;
return providerLabel(profile.provider) + " · " + domainCount + " 个域名";
}
function renderProfileSelect() {
var select = byId("profile-select");
clear(select);
state.config.providers.forEach(function (profile, index) {
var option = make("option", "", profileOptionText(profile));
option.value = String(index);
option.selected = index === state.profileIndex;
select.appendChild(option);
});
}
function renderDomains(family) {
var profile = currentProfile();
var domains = effectiveProfileValue(profile, family, []);
var list = byId(family + "-domain-list");
clear(list);
if (!domains.length) {
list.appendChild(make("small", "domain-empty", "未配置域名"));
return;
}
domains.forEach(function (domain, index) {
var item = make("span");
item.appendChild(document.createTextNode(domain));
var button = make("button", "", "×");
button.type = "button";
button.setAttribute("aria-label", "移除域名 " + domain);
button.setAttribute("data-remove-domain", family);
button.setAttribute("data-domain-index", index);
item.appendChild(button);
list.appendChild(item);
});
}
function setInput(id, value) {
byId(id).value = value === undefined || value === null ? "" : value;
}
function renderProviderMetadata(providerId) {
var metadata = providerMetadata(providerId) || {};
var auth = metadata.auth || "id-token";
var showId = auth !== "none" && auth !== "token";
var showToken = auth !== "none";
setText(
"provider-description",
(metadata.description && metadata.description.zh) || "凭据只保存在指定的本机配置文件中。",
);
setText("provider-id-label", (metadata.idLabel && metadata.idLabel.zh) || "账号或 ID");
setText("provider-token-label", (metadata.tokenLabel && metadata.tokenLabel.zh) || "Token 或密钥");
byId("provider-id-field").hidden = !showId;
byId("provider-token-field").hidden = !showToken;
}
function renderProfile() {
var profile = currentProfile();
var defaults = configModel().defaults;
if (!profile) {
return;
}
updateProviderSelect(profile.provider);
renderProviderMetadata(profile.provider);
setInput("provider-id", effectiveProfileValue(profile, "id", ""));
setInput("provider-token", effectiveProfileValue(profile, "token", ""));
setInput("record-line", effectiveProfileValue(profile, "line", null));
setInput("index4", sourceText(effectiveProfileValue(profile, "index4", ["default"])));
setInput("index6", sourceText(effectiveProfileValue(profile, "index6", ["default"])));
setInput("ttl", effectiveProfileValue(profile, "ttl", null));
var proxy = hasOwn(state.config, "proxy") ? state.config.proxy : defaults.proxy;
var ssl = hasOwn(state.config, "ssl") ? state.config.ssl : defaults.ssl;
var cache = hasOwn(state.config, "cache") ? state.config.cache : defaults.cache;
var log = state.config.log && typeof state.config.log === "object" ? state.config.log : {};
setInput("proxy", proxyText(proxy));
byId("ssl-enabled").checked = ssl !== false;
byId("cache-enabled").checked = cache !== false;
setText("ssl-label", ssl === "auto" ? "自动" : ssl === false ? "关闭" : "开启");
setText(
"cache-label",
typeof cache === "string" ? "自定义路径" : cache === false ? "关闭" : "开启",
);
setInput("log-level", log.level || defaults.logLevel);
setInput("log-file", log.file);
setText("profile-badge", String(providerLabel(profile.provider)).slice(0, 3).toUpperCase());
renderDomains("ipv4");
renderDomains("ipv6");
byId("delete-profile-button").disabled = state.config.providers.length <= 1;
if (!state.repairRequired && byId("json-details").open && !state.jsonDirty) {
setJsonEditorValue(JSON.stringify(state.config, null, 2));
}
}
function configsMatch() {
return JSON.stringify(state.config) === JSON.stringify(state.originalConfig);
}
function updateDirtyState(skipStructuredControls) {
state.dirty = !configsMatch() || (state.invalidRawConfig !== null && !state.repairRequired);
var pendingChanges = hasPendingChanges();
var stateNode = byId("save-state");
stateNode.className = "save-state" + (pendingChanges || state.repairRequired ? " dirty" : "");
stateNode.querySelector("span").textContent = state.repairRequired
? "配置需要修复"
: state.jsonDirty
? "JSON 尚未应用"
: state.dirty
? "有未保存修改"
: "已与磁盘同步";
var editorState = byId("editor-state");
editorState.className = pendingChanges || state.repairRequired ? "dirty" : "";
editorState.textContent = state.repairRequired
? "请在完整 JSON 中修复配置,验证通过后才能保存。"
: state.jsonDirty
? "完整 JSON 有未应用的修改;应用或放弃后才能保存。"
: state.dirty
? "更改尚未写入 " + (state.configPath || "配置文件")
: "修改会先保留在当前页面,保存后才写入磁盘。";
var saveDisabled = !state.dirty || state.jsonDirty || state.repairRequired || state.saving;
byId("save-button").disabled = saveDisabled;
byId("footer-save-button").disabled = saveDisabled;
byId("discard-button").disabled = !pendingChanges || state.saving;
byId("restore-button").disabled = state.saving || !state.backupAvailable;
byId("json-editor").disabled = state.saving;
byId("format-json-button").disabled = state.saving;
byId("apply-json-button").disabled = state.saving;
if (!skipStructuredControls) {
updateStructuredControls();
}
renderConfigSummary();
}
function markDirty() {
updateDirtyState(true);
if (byId("json-details").open && !state.jsonDirty) {
setJsonEditorValue(JSON.stringify(state.config, null, 2));
}
}
function renderConfig() {
ensureConfigShape();
if (state.profileIndex >= state.config.providers.length) {
state.profileIndex = Math.max(0, state.config.providers.length - 1);
}
renderProfileSelect();
renderProfile();
var pathLabel = state.configPath ? state.configPath.split(/[\\/]/).pop() + " · 本机" : "当前配置";
setText("config-path-label", pathLabel);
byId("config-path-label").title = state.configPath;
renderSetupMode();
updateDirtyState();
}
function loadDashboard() {
return api("/api/dashboard")
.then(function (dashboard) {
state.dashboard = dashboard;
renderDashboard();
if (state.config) {
renderProfileSelect();
renderProfile();
}
return dashboard;
})
.catch(function (error) {
if (error.code !== "invalid_config") {
throw error;
}
state.dashboard = {
state: "error",
message: error.message,
addresses: [],
providers: [],
records: [],
activities: [],
scheduler: {},
};
renderDashboard();
return state.dashboard;
});
}
function refreshDashboard() {
if (document.hidden || state.saving || app.classList.contains("busy")) {
return Promise.resolve();
}
return api("/api/dashboard")
.then(function (dashboard) {
state.dashboard = dashboard;
renderDashboard();
})
.catch(function (error) {
var header = byId("header-health");
header.className = "header-health error";
header.querySelector("span").textContent = "状态异常";
header.title = errorMessage(error);
});
}
function startDashboardRefresh() {
if (state.dashboardRefreshTimer) {
return;
}
state.dashboardRefreshTimer = setInterval(refreshDashboard, 15000);
document.addEventListener("visibilitychange", function () {
if (!document.hidden) {
refreshDashboard();
}
});
}
function loadConfig() {
return api("/api/config").then(function (result) {
state.configModel = result.model;
state.config = clone(result.config);
state.originalConfig = clone(result.config);
state.configPath = result.path || "";
state.configExists = Boolean(result.exists);
state.backupAvailable = Boolean(result.backup_available);
state.profileIndex = 0;
state.repairRequired = Boolean(result.validation_error);
state.invalidRawConfig = state.repairRequired ? result.raw || "" : null;
state.jsonDirty = false;
state.jsonBaseline = "";
state.schedulerIntervalDirty = false;
setNotice("");
renderConfig();
if (result.validation_error) {
setNotice(
"当前配置无法验证:" + result.validation_error + "。请修复完整 JSON,或恢复上一份备份。",
"error",
);
byId("automation-pane").open = true;
byId("json-details").open = true;
setJsonEditorValue(state.invalidRawConfig);
}
renderRuntime(state.dashboard || { providers: [] });
return result;
});
}
function loadAll() {
return Promise.all([loadDashboard(), loadConfig()])
.then(function () {
initializeConfigSections();
initializeConfigShell();
if (state.repairRequired) {
setView("config", false, false);
window.history.replaceState(null, "", fragmentForView("config", token));
} else if (isFirstRun() && !state.setupOpened) {
state.setupOpened = true;
setView("config", false, false);
window.history.replaceState(null, "", fragmentForView("config", token));
} else if (parseFragment().view === "config") {
setView("config", false);
}
})
.catch(function (error) {
var header = byId("header-health");
header.className = "header-health error";
header.querySelector("span").textContent = "连接失败";
byId("status-hero").className = "status-hero error";
setText("status-title", "无法读取本机控制台");
setText("status-summary", errorMessage(error));
showToast(errorMessage(error), "error");
});
}
function acceptPersistedConfig(result) {
state.config = clone(result.config);
state.originalConfig = clone(result.config);
state.configPath = result.path || state.configPath;
state.configExists = Boolean(result.exists);
state.backupAvailable = Boolean(result.backup_available);
state.invalidRawConfig = null;
state.repairRequired = false;
state.jsonDirty = false;
state.schedulerIntervalDirty = false;
setNotice("");
renderConfig();
}
function saveConfig() {
if (state.saving) {
return Promise.resolve();
}
if (state.repairRequired || state.jsonDirty) {
byId("automation-pane").open = true;
byId("json-details").open = true;
setNotice("请先应用完整 JSON 中的修改,验证通过后再保存。", "error");
showToast("完整 JSON 尚未应用", "warning");
return Promise.resolve();
}
if (!state.dirty) {
return Promise.resolve();
}
var completingSetup = isFirstRun();
state.saving = true;
setSaveButtonLabels("保存中");
updateDirtyState();
setBusy(true);
setNotice("");
return api("/api/config", { method: "PUT", body: { config: state.config } })
.then(function (result) {
acceptPersistedConfig(result);
return loadDashboard().then(function () {
cuePersistedConfig();
if (completingSetup && !isFirstRun()) {
setView("overview", true);
showToast("配置已保存,现在可以运行首次同步");
} else if (completingSetup) {
setView("config", false, false);
showToast("配置已保存;请至少添加一个需要更新的域名", "warning");
} else {
showToast("配置已安全写入本机");
}
});
})
.catch(function (error) {
setNotice(errorMessage(error), "error");
showToast(errorMessage(error), "error");
})
.finally(function () {
state.saving = false;
setSaveButtonLabels("保存更改");
setBusy(false);
updateDirtyState();
});
}
function discardChanges() {
state.config = clone(state.originalConfig);
state.profileIndex = 0;
state.jsonDirty = false;
state.schedulerIntervalDirty = false;
if (state.invalidRawConfig !== null) {
state.repairRequired = true;
setNotice("当前配置仍需通过完整 JSON 修复,或恢复上一份备份。", "error");
} else {
state.repairRequired = false;
setNotice("");
}
renderConfig();
if (state.dashboard) {
renderScheduler(state.dashboard);
}
if (state.invalidRawConfig !== null) {
byId("automation-pane").open = true;
byId("json-details").open = true;
setJsonEditorValue(state.invalidRawConfig);
} else if (byId("json-details").open) {
setJsonEditorValue(JSON.stringify(state.config, null, 2));
}
showToast("未保存的更改已放弃", "warning");
}
function armRestore() {
if (state.saving) {
return;
}
var button = byId("restore-button");
if (!state.restoreArmed) {
state.restoreArmed = true;
button.textContent = "再次点击确认";
showToast("再次点击将切换到上一份配置", "warning");
clearTimeout(state.restoreTimer);
state.restoreTimer = setTimeout(function () {
state.restoreArmed = false;
button.textContent = "恢复备份";
}, 4200);
return;
}
state.restoreArmed = false;
clearTimeout(state.restoreTimer);
state.saving = true;
button.textContent = "恢复中";
setBusy(true);
updateDirtyState();
api("/api/config/restore", { method: "POST", body: {} })
.then(function (result) {
state.profileIndex = 0;
acceptPersistedConfig(result);
showToast("已恢复上一份配置");
return loadDashboard();
})
.then(function () {
cuePersistedConfig();
})
.catch(function (error) {
setNotice(errorMessage(error), "error");
showToast(errorMessage(error), "error");
})
.finally(function () {
state.saving = false;
setBusy(false);
button.textContent = "恢复备份";
updateDirtyState();
});
}
function addProfile() {
var providerLimit = configModel().limits.providers;
if (state.config.providers.length >= providerLimit) {
showToast("服务商配置最多为 " + providerLimit + " 组", "warning");
return;
}
state.config.providers.push(newProvider());
state.profileIndex = state.config.providers.length - 1;
byId("provider-pane").open = true;
setNotice("");
renderConfig();
markDirty();
byId("provider-name").focus();
}
function armDeleteProfile() {
var button = byId("delete-profile-button");
if (state.config.providers.length <= 1) {
return;
}
if (!state.deleteArmed) {
state.deleteArmed = true;
button.textContent = "确认删除";
showToast("再次点击将从草稿中移除此配置", "warning");
clearTimeout(state.deleteTimer);
state.deleteTimer = setTimeout(function () {
state.deleteArmed = false;
button.textContent = "删除";
}, 4200);
return;
}
state.deleteArmed = false;
clearTimeout(state.deleteTimer);
state.config.providers.splice(state.profileIndex, 1);
state.profileIndex = Math.max(0, state.profileIndex - 1);
button.textContent = "删除";
renderConfig();
markDirty();
}
function addDomain(family) {
var input = byId(family + "-domain-input");
var domain = input.value.trim().toLowerCase();
var profile = currentProfile();
if (!domain) {
input.focus();
return;
}
if (domain.length > 253 || /\s/.test(domain)) {
showToast("请输入不含空格的有效域名", "error");
input.focus();
return;
}
if (!hasOwn(profile, family)) {
profile[family] = effectiveProfileValue(profile, family, []).slice();
}
if (profile[family].indexOf(domain) >= 0) {
showToast("这个域名已经在当前列表中", "warning");
input.select();
return;
}
profile[family].push(domain);
input.value = "";
renderDomains(family);
renderProfileSelect();
markDirty();
input.focus();
}
function removeDomain(family, index) {
var profile = currentProfile();
if (!profile) {
return;
}
if (!hasOwn(profile, family)) {
profile[family] = effectiveProfileValue(profile, family, []).slice();
}
profile[family].splice(index, 1);
renderDomains(family);
renderProfileSelect();
markDirty();
}
function runSync() {
if (isFirstRun()) {
setView("config", true);
showToast("先选择 DNS 服务商并添加需要更新的域名", "warning");
return;
}
if (hasPendingChanges() || state.repairRequired) {
setView("config", true);
showToast("请先保存配置,再运行同步", "warning");
return;
}
if (!state.dashboard || !(state.dashboard.providers || []).length) {
setView("config", true);
showToast("请先添加 DNS 服务商与域名", "warning");
return;
}
var button = byId("sync-button");
var label = button.querySelector("span");
var hero = byId("status-hero");
var header = byId("header-health");
button.disabled = true;
label.textContent = "同步中";
button.querySelector("svg").classList.add("spinning");
header.className = "header-health syncing";
header.querySelector("span").textContent = "同步中";
setText("status-title", "正在同步解析");
setText("status-summary", "正在检查本机地址、DNS 服务商与解析记录。");
hero.classList.remove("sync-complete", "sync-failed");
hero.classList.add("syncing");
hero.setAttribute("aria-busy", "true");
setBusy(true);
var syncResult = api("/api/sync", { method: "POST", body: {} }).then(
function (dashboard) {
return { dashboard: dashboard };
},
function (error) {
return { error: error };
},
);
Promise.all([
syncResult,
new Promise(function (resolve) {
setTimeout(resolve, 680);
}),
])
.then(function (results) {
if (results[0].error) {
throw results[0].error;
}
var dashboard = results[0].dashboard;
state.dashboard = dashboard;
renderDashboard();
var failed = dashboard.state === "error";
cueSyncOutcome(failed ? "error" : "success");
showToast(
failed ? dashboard.message || "同步完成,但部分记录更新失败" : "同步已完成",
failed ? "error" : "",
);
})
.catch(function (error) {
showToast(errorMessage(error), "error");
return loadDashboard()
.catch(function (dashboardError) {
setNotice(errorMessage(dashboardError), "error");
renderDashboard();
})
.then(function () {
cueSyncOutcome("error");
});
})
.finally(function () {
setBusy(false);
button.disabled = false;
label.textContent = "立即同步";
button.querySelector("svg").classList.remove("spinning");
hero.classList.remove("syncing");
hero.removeAttribute("aria-busy");
});
}
function schedulerAction(fromEditor) {
if (hasPendingChanges() || state.repairRequired) {
setView("config", true);
showToast("请先保存配置,再启用自动同步", "warning");
return;
}
var scheduler = (state.dashboard && state.dashboard.scheduler) || {};
var interval = Number(byId("scheduler-interval").value || 5);
var intervalChanged = interval !== Number(scheduler.interval);
var action = scheduler.conflict
? "takeover"
: fromEditor && intervalChanged
? "configure"
: scheduler.enabled
? "disable"
: "enable";
var buttons = [byId("scheduler-button"), byId("automation-button")];
buttons.forEach(function (button) {
button.disabled = true;
});
setText("scheduler-action", "正在更新");
setText("automation-button", "处理中");
byId("scheduler-button").setAttribute("aria-busy", "true");
setBusy(true);
api("/api/scheduler", {
method: "POST",
body: { action: action, scheduler: "web", interval: interval },
})
.then(function () {
showToast(
{
configure: "自动同步间隔已更新",
disable: "Web 自动同步已暂停",
enable: "Web 自动同步已启用",
takeover: "Web 进程已接管自动同步",
}[action],
);
return loadDashboard();
})
.catch(function (error) {
showToast(errorMessage(error), "error");
setNotice(errorMessage(error), "error");
})
.finally(function () {
setBusy(false);
buttons.forEach(function (button) {
button.disabled = false;
});
byId("scheduler-button").removeAttribute("aria-busy");
if (state.dashboard) {
renderScheduler(state.dashboard);
}
});
}
function formatJson() {
var editor = byId("json-editor");
try {
editor.value = JSON.stringify(JSON.parse(editor.value), null, 2);
updateJsonDirtyState();
setNotice("");
showToast("JSON 格式已整理");
} catch (error) {
setNotice("JSON 无法解析:" + error.message, "error");
showToast("JSON 格式有误", "error");
}
}
function applyJson() {
if (state.saving) {
return;
}
var editor = byId("json-editor");
var parsed;
try {
parsed = JSON.parse(editor.value);
} catch (error) {
setNotice("JSON 无法解析:" + error.message, "error");
showToast("JSON 格式有误", "error");
return;
}
state.saving = true;
setBusy(true);
updateDirtyState();
api("/api/config/validate", { method: "POST", body: { config: parsed } })
.then(function (result) {
state.config = clone(result.config);
state.profileIndex = 0;
state.repairRequired = false;
state.jsonDirty = false;
state.schedulerIntervalDirty = state.config.interval !== state.originalConfig.interval;
if (state.schedulerIntervalDirty) {
setInput("scheduler-interval", hasOwn(state.config, "interval") ? state.config.interval : "");
}
setNotice("");
renderConfig();
showToast("JSON 已应用到当前草稿");
})
.catch(function (error) {
setNotice(errorMessage(error), "error");
showToast(errorMessage(error), "error");
})
.finally(function () {
state.saving = false;
setBusy(false);
updateDirtyState();
});
}
function setDraftValue(target, key, value, sparse) {
if (sparse && (value === null || value === undefined || value === "")) {
delete target[key];
} else {
target[key] = value;
}
}
function bindProfileField(id, key, parser, eventName, sparse) {
byId(id).addEventListener(eventName || "input", function (event) {
var profile = currentProfile();
var value = parser ? parser(event.target.value) : event.target.value;
setDraftValue(profile, key, value, sparse);
markDirty();
});
}
function bindGlobalField(id, key, parser, eventName, sparse) {
byId(id).addEventListener(eventName || "input", function (event) {
var value = parser ? parser(event.target.value) : event.target.value;
setDraftValue(state.config, key, value, sparse);
markDirty();
});
}
function switchProvider(providerName) {
var profile = currentProfile();
var replacement = { provider: providerName };
["ipv4", "ipv6", "index4", "index6", "ttl"].forEach(function (key) {
if (hasOwn(profile, key)) {
replacement[key] = clone(profile[key]);
}
});
state.config.providers[state.profileIndex] = replacement;
renderProfile();
renderProfileSelect();
markDirty();
showToast("已清除旧服务商的凭据、端点与专用设置");
}
function bindEvents() {
document.querySelectorAll("[data-view]").forEach(function (button) {
button.addEventListener("click", function () {
setView(button.getAttribute("data-view"), true);
});
});
document.querySelector(".command-nav").addEventListener("keydown", function (event) {
if (["ArrowLeft", "ArrowRight", "Home", "End"].indexOf(event.key) < 0) {
return;
}
event.preventDefault();
var buttons = Array.prototype.slice
.call(document.querySelectorAll(".command-nav [data-view]"))
.filter(function (button) {
return !button.disabled;
});
var current = buttons.indexOf(document.activeElement);
var next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : current;
if (event.key === "ArrowLeft") {
next = (current - 1 + buttons.length) % buttons.length;
} else if (event.key === "ArrowRight") {
next = (current + 1) % buttons.length;
}
setView(buttons[next].getAttribute("data-view"), true);
buttons[next].focus();
});
byId("theme-button").addEventListener("click", function () {
applyTheme(app.classList.contains("dark") ? "light" : "dark");
});
byId("sync-button").addEventListener("click", runSync);
byId("scheduler-button").addEventListener("click", function () {
schedulerAction(false);
});
byId("automation-button").addEventListener("click", function () {
schedulerAction(true);
});
byId("scheduler-interval").addEventListener("input", function (event) {
if (state.config) {
if (event.target.value === "") {
delete state.config.interval;
} else {
state.config.interval = Number(event.target.value);
}
state.schedulerIntervalDirty =
hasOwn(state.config, "interval") !== hasOwn(state.originalConfig, "interval") ||
state.config.interval !== state.originalConfig.interval;
markDirty();
}
updateSchedulerButton();
});
byId("save-button").addEventListener("click", saveConfig);
byId("footer-save-button").addEventListener("click", saveConfig);
byId("discard-button").addEventListener("click", discardChanges);
byId("restore-button").addEventListener("click", armRestore);
byId("add-profile-button").addEventListener("click", addProfile);
byId("delete-profile-button").addEventListener("click", armDeleteProfile);
byId("format-json-button").addEventListener("click", formatJson);
byId("apply-json-button").addEventListener("click", applyJson);
byId("json-editor").addEventListener("input", updateJsonDirtyState);
byId("profile-select").addEventListener("change", function (event) {
state.profileIndex = Number(event.target.value);
state.deleteArmed = false;
byId("delete-profile-button").textContent = "删除";
renderProfile();
});
byId("provider-name").addEventListener("change", function (event) {
switchProvider(event.target.value);
});
bindProfileField("record-line", "line", function (value) {
return value.trim() || null;
}, "input", true);
bindProfileField("provider-id", "id", function (value) {
return value.trim() || null;
}, "input", true);
bindProfileField("provider-token", "token", function (value) {
return value.trim() || null;
}, "input", true);
bindProfileField("index4", "index4", parseSources, "input", true);
bindProfileField("index6", "index6", parseSources, "input", true);
bindProfileField("ttl", "ttl", function (value) {
return value === "" ? null : Number(value);
}, "input", true);
bindGlobalField("proxy", "proxy", parseProxy, "input", true);
byId("ssl-enabled").addEventListener("change", function (event) {
state.config.ssl = event.target.checked;
setText("ssl-label", event.target.checked ? "开启" : "关闭");
markDirty();
});
byId("cache-enabled").addEventListener("change", function (event) {
state.config.cache = event.target.checked;
setText("cache-label", event.target.checked ? "开启" : "关闭");
markDirty();
renderRuntime(state.dashboard || { providers: [] });
});
byId("log-level").addEventListener("change", function (event) {
if (!state.config.log || typeof state.config.log !== "object") {
state.config.log = {};
}
state.config.log.level = event.target.value;
markDirty();
});
byId("log-file").addEventListener("input", function (event) {
if (!state.config.log || typeof state.config.log !== "object") {
state.config.log = {};
}
setDraftValue(state.config.log, "file", event.target.value.trim() || null, true);
markDirty();
});
document.querySelectorAll("[data-add-domain]").forEach(function (button) {
button.addEventListener("click", function () {
addDomain(button.getAttribute("data-add-domain"));
});
});
["ipv4", "ipv6"].forEach(function (family) {
byId(family + "-domain-input").addEventListener("keydown", function (event) {
if (event.key === "Enter") {
event.preventDefault();
addDomain(family);
}
});
byId(family + "-domain-list").addEventListener("click", function (event) {
var button = event.target.closest("[data-remove-domain]");
if (button) {
removeDomain(button.getAttribute("data-remove-domain"), Number(button.getAttribute("data-domain-index")));
}
});
});
byId("record-search").addEventListener("input", function (event) {
state.recordQuery = event.target.value.trim();
renderRecords();
});
document.querySelectorAll("[data-activity-filter]").forEach(function (button) {
button.addEventListener("click", function () {
state.activityFilter = button.getAttribute("data-activity-filter");
document.querySelectorAll("[data-activity-filter]").forEach(function (other) {
var active = other === button;
other.classList.toggle("active", active);
other.setAttribute("aria-pressed", String(active));
});
renderActivities();
});
});
byId("json-details").addEventListener("toggle", function () {
if (byId("json-details").open && !state.jsonDirty) {
setJsonEditorValue(
state.repairRequired ? state.invalidRawConfig : JSON.stringify(state.config, null, 2),
);
}
});
window.addEventListener("hashchange", function () {
var fragment = parseFragment();
setView(fragment.view === "config" ? "config" : "overview", false);
});
window.addEventListener("beforeunload", function (event) {
if (hasPendingChanges()) {
event.preventDefault();
event.returnValue = "";
}
});
document.addEventListener("keydown", function (event) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
saveConfig();
}
});
}
applyTheme(initialTheme());
applyTheme(initialTheme());
bindEvents();
setView(initialView, false);
loadAll().then(startDashboardRefresh);
})();