Files
remoteconn-gitea/apps/miniprogram/pages/server-settings/index.js
2026-03-21 18:57:10 +08:00

1008 lines
33 KiB
JavaScript
Raw 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.

/* global Page, wx, require, getCurrentPages, console, setTimeout, clearTimeout */
const {
createServerSeed,
listServers,
upsertServer,
markServerConnected,
appendLog,
getSettings
} = require("../../utils/storage");
const { getOpsConfig, isOpsConfigReady } = require("../../utils/opsConfig");
const {
listRemoteDirectories,
normalizeHomePath,
validateServerForConnect,
resolveSocketDomainHint
} = require("../../utils/remoteDirectory");
const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle");
const { buildButtonIconThemeMaps } = require("../../utils/themedIcons");
const {
buildPageCopy,
buildServerAuthTypeOptions,
formatTemplate,
localizeServerValidationMessage,
normalizeUiLanguage
} = require("../../utils/i18n");
const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback");
const AUTH_TYPE_OPTIONS = [
{ value: "password" },
{ value: "privateKey" },
{ value: "certificate" }
];
const DIRECTORY_USAGE_STORAGE_KEY = "remoteconn.directoryUsage.v1";
const DIRECTORY_USAGE_MAX_ENTRIES = 500;
const ROOT_DIR_PREFETCH_CACHE_TTL_MS = 45000;
const ROOT_DIR_PREFETCH_CACHE_MAX_ENTRIES = 32;
const rootDirPrefetchCache = new Map();
function normalizeAuthType(value) {
const authType = String(value || "").trim();
if (authType === "privateKey" || authType === "certificate") return authType;
return "password";
}
function normalizeJumpHost(input, seedInput) {
const seed = seedInput && typeof seedInput === "object" ? seedInput : createServerSeed().jumpHost;
const source = input && typeof input === "object" ? input : {};
return {
enabled: source.enabled === true,
host: String(source.host || "").trim(),
port:
source.port === ""
? ""
: normalizePortForForm(source.port == null ? seed.port : source.port, String(seed.port || 22)),
username: String(source.username || "").trim(),
authType: normalizeAuthType(source.authType || seed.authType)
};
}
function parseServerTags(value) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter((item) => !!item);
}
function normalizePortForForm(value, fallback) {
const raw = String(value == null ? "" : value).trim();
if (!raw) return "";
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
const port = Math.round(parsed);
if (port < 1) return 1;
if (port > 65535) return 65535;
return String(port);
}
function hashStringFNV1a(input) {
const text = String(input || "");
let hash = 2166136261;
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return (hash >>> 0).toString(16);
}
function buildCredentialFingerprint(form) {
const source = form && typeof form === "object" ? form : {};
const authType = normalizeAuthType(source.authType);
if (authType === "privateKey") {
return `pk:${hashStringFNV1a(`${source.privateKey || ""}|${source.passphrase || ""}`)}`;
}
if (authType === "certificate") {
return `cert:${hashStringFNV1a(`${source.privateKey || ""}|${source.passphrase || ""}|${source.certificate || ""}`)}`;
}
return `pwd:${hashStringFNV1a(String(source.password || ""))}`;
}
/**
* 根目录预取缓存按“服务器身份+认证内容”分桶,避免串线。
*/
function buildRootPrefetchKey(formInput) {
const form = normalizeServerForm(formInput || createServerSeed());
const username = String(form.username || "").trim() || "-";
const host = String(form.host || "").trim() || "-";
const port = Number(form.port) || 22;
const authType = normalizeAuthType(form.authType);
const credential = buildCredentialFingerprint(form);
const jumpHost = form.jumpHost && form.jumpHost.enabled ? form.jumpHost : null;
const jumpPart = jumpHost
? `|jump:${String(jumpHost.username || "-").trim()}@${String(jumpHost.host || "-").trim()}:${Number(jumpHost.port) || 22}|${normalizeAuthType(jumpHost.authType)}|${buildCredentialFingerprint(
{
authType: jumpHost.authType,
password: form.jumpPassword,
privateKey: form.jumpPrivateKey,
passphrase: form.jumpPassphrase,
certificate: form.jumpCertificate
}
)}`
: "";
return `${username}@${host}:${port}|${authType}|${credential}${jumpPart}`;
}
function pruneRootPrefetchCache() {
if (rootDirPrefetchCache.size <= ROOT_DIR_PREFETCH_CACHE_MAX_ENTRIES) return;
const entries = Array.from(rootDirPrefetchCache.entries())
.map(([key, entry]) => ({ key, updatedAt: Number(entry && entry.updatedAt) || 0 }))
.sort((a, b) => a.updatedAt - b.updatedAt);
const removeCount = Math.max(0, entries.length - ROOT_DIR_PREFETCH_CACHE_MAX_ENTRIES);
for (let i = 0; i < removeCount; i += 1) {
rootDirPrefetchCache.delete(entries[i].key);
}
}
function readFreshRootPrefetchNames(formInput) {
const key = buildRootPrefetchKey(formInput);
const entry = rootDirPrefetchCache.get(key);
if (!entry || !Array.isArray(entry.names)) return null;
const updatedAt = Number(entry.updatedAt) || 0;
if (!updatedAt || Date.now() - updatedAt > ROOT_DIR_PREFETCH_CACHE_TTL_MS) return null;
return entry.names.slice();
}
function readRootPrefetchInFlight(formInput) {
const key = buildRootPrefetchKey(formInput);
const entry = rootDirPrefetchCache.get(key);
if (!entry || !entry.inFlight) return null;
return entry.inFlight;
}
function writeRootPrefetchResult(formInput, names) {
const key = buildRootPrefetchKey(formInput);
const normalized = Array.isArray(names)
? names.map((name) => String(name || "").trim()).filter((name) => !!name)
: [];
const prev = rootDirPrefetchCache.get(key) || {};
rootDirPrefetchCache.set(key, {
...prev,
names: normalized,
updatedAt: Date.now(),
inFlight: null
});
pruneRootPrefetchCache();
}
function writeRootPrefetchInFlight(formInput, inFlight) {
const key = buildRootPrefetchKey(formInput);
const prev = rootDirPrefetchCache.get(key) || {};
rootDirPrefetchCache.set(key, {
...prev,
inFlight: inFlight || null
});
}
function buildDirectoryChildrenFromNames(parentNode, names, form, usageMap) {
const safeNames = Array.isArray(names) ? names : [];
const remoteOrderMap = {};
safeNames.forEach((name, index) => {
remoteOrderMap[String(name)] = index;
});
return safeNames
.map((name) => String(name || "").trim())
.filter((name) => !!name)
.filter((name) => !/__RC_/i.test(name))
.filter((name) => !/^~\/__RC_/i.test(name))
.map((name) => buildDirectoryNode(composeChildPath(parentNode.path, name), parentNode.depth + 1))
.sort((a, b) => {
const sourceUsage = usageMap && typeof usageMap === "object" ? usageMap : {};
const aUsage = Number(sourceUsage[buildDirectoryUsageKey(form, a.path)] || 0);
const bUsage = Number(sourceUsage[buildDirectoryUsageKey(form, b.path)] || 0);
if (aUsage !== bUsage) return bUsage - aUsage;
const aOrder = Number(remoteOrderMap[a.name]);
const bOrder = Number(remoteOrderMap[b.name]);
const safeAOrder = Number.isFinite(aOrder) ? aOrder : Number.MAX_SAFE_INTEGER;
const safeBOrder = Number.isFinite(bOrder) ? bOrder : Number.MAX_SAFE_INTEGER;
if (safeAOrder !== safeBOrder) return safeAOrder - safeBOrder;
return a.name.localeCompare(b.name, "zh-Hans-CN");
});
}
/**
* 目录使用记录按“服务器+目录”维度存储:
* 1. 同一路径在不同服务器可独立排序;
* 2. 仅保留最近一批记录,避免存储无限增长。
*/
function readDirectoryUsageMap() {
try {
const raw = wx.getStorageSync(DIRECTORY_USAGE_STORAGE_KEY);
if (!raw || typeof raw !== "object") return {};
return raw;
} catch {
return {};
}
}
function writeDirectoryUsageMap(input) {
const source = input && typeof input === "object" ? input : {};
const pairs = Object.keys(source)
.map((key) => {
const value = Number(source[key]);
if (!Number.isFinite(value) || value <= 0) return null;
return { key, value };
})
.filter((item) => !!item)
.sort((a, b) => b.value - a.value)
.slice(0, DIRECTORY_USAGE_MAX_ENTRIES);
const next = {};
pairs.forEach((item) => {
next[item.key] = item.value;
});
try {
wx.setStorageSync(DIRECTORY_USAGE_STORAGE_KEY, next);
} catch {
// ignore
}
return next;
}
function buildDirectoryUsageKey(form, path) {
const source = form && typeof form === "object" ? form : {};
const username = String(source.username || "").trim() || "-";
const host = String(source.host || "").trim() || "-";
const port = Number(source.port) || 22;
const normalizedPath = normalizeHomePath(path);
return `${username}@${host}:${port}|${normalizedPath}`;
}
function sanitizeProjectPath(value) {
const raw = String(value || "").trim();
if (!raw) return raw;
// 兜底清理历史异常值:内部标记路径不应进入用户可见配置。
if (/(?:^|\/)__RC_/i.test(raw)) return "~";
return raw;
}
function normalizeServerForm(input) {
const seed = createServerSeed();
const source = input && typeof input === "object" ? input : {};
return {
...seed,
...source,
name: String(source.name || "").trim(),
tags: Array.isArray(source.tags)
? source.tags.map((item) => String(item || "").trim()).filter((item) => !!item)
: [],
host: String(source.host || "").trim(),
port:
source.port === ""
? ""
: normalizePortForForm(source.port == null ? seed.port : source.port, String(seed.port || 22)),
username: String(source.username || "").trim(),
authType: normalizeAuthType(source.authType || seed.authType),
password: String(source.password || ""),
privateKey: String(source.privateKey || ""),
passphrase: String(source.passphrase || ""),
certificate: String(source.certificate || ""),
projectPath: sanitizeProjectPath(String(source.projectPath || seed.projectPath || "")),
transportMode: String(source.transportMode || seed.transportMode || "gateway"),
jumpHost: normalizeJumpHost(source.jumpHost, seed.jumpHost),
jumpPassword: String(source.jumpPassword || ""),
jumpPrivateKey: String(source.jumpPrivateKey || ""),
jumpPassphrase: String(source.jumpPassphrase || ""),
jumpCertificate: String(source.jumpCertificate || "")
};
}
function resolveEventFieldKey(event) {
return event?.currentTarget?.dataset?.key || event?.target?.dataset?.key || "";
}
function updateFieldValue(base, key, value) {
const source = base && typeof base === "object" ? base : createServerSeed();
if (!key.includes(".")) {
return {
...source,
// 编辑阶段保留原始输入,端口归一化延后到保存时执行。
[key]: value
};
}
const [rootKey, childKey] = key.split(".", 2);
if (rootKey !== "jumpHost" || !childKey) {
return source;
}
const nextJumpHost = {
...(source.jumpHost || normalizeJumpHost()),
[childKey]: childKey === "enabled" ? value === true : value
};
return {
...source,
// jumpHost 同样保留草稿,避免输入中被即时裁剪/补默认值。
jumpHost: nextJumpHost
};
}
function buildDirectoryNode(path, depth) {
const normalized = normalizeHomePath(path);
const name = normalized === "~" ? "~" : normalized.split("/").pop() || "~";
return {
path: normalized,
name,
depth: Number(depth) || 0,
expanded: false,
loading: false,
loaded: false,
children: []
};
}
function composeChildPath(parentPath, childName) {
const parent = normalizeHomePath(parentPath);
const child = String(childName || "")
.trim()
.replace(/^\/+|\/+$/g, "");
if (!child) return parent;
return parent === "~" ? `~/${child}` : `${parent}/${child}`;
}
function findDirectoryNode(root, targetPath) {
const target = normalizeHomePath(targetPath);
if (!root) return null;
if (root.path === target) return root;
const children = Array.isArray(root.children) ? root.children : [];
for (let i = 0; i < children.length; i += 1) {
const found = findDirectoryNode(children[i], target);
if (found) return found;
}
return null;
}
function flattenDirectoryNodes(root, selectedPath) {
if (!root) return [];
const selected = normalizeHomePath(selectedPath || "~");
const rows = [];
const walk = (node) => {
const children = Array.isArray(node.children) ? node.children : [];
const canExpand = !node.loaded || children.length > 0;
rows.push({
path: node.path,
name: node.name,
depth: node.depth,
paddingLeft: node.depth * 26,
expanded: !!node.expanded,
loading: !!node.loading,
loaded: !!node.loaded,
canExpand,
childrenCount: children.length,
isSelected: node.path === selected
});
if (!node.expanded || children.length === 0) return;
children.forEach((child) => walk(child));
};
walk(root);
return rows;
}
function toErrorMessage(error, fallback) {
if (!error) return fallback;
if (typeof error === "string") return error;
if (typeof error.message === "string" && error.message.trim()) return error.message.trim();
if (typeof error.errMsg === "string" && error.errMsg.trim()) return error.errMsg.trim();
return fallback;
}
function normalizeDirectoryPickerSeqValue(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return Math.floor(parsed);
}
/**
* 服务器配置页(对齐 Web ServerSettingsView
* 1. 顶部仅保留返回+标题;
* 2. 底部动作栏固定为返回/连接/保存;
* 3. 字段按双列网格布局。
*/
Page({
data: {
...buildSvgButtonPressData(),
themeStyle: "",
icons: {},
accentIcons: {},
canGoBack: false,
serverId: "",
copy: buildPageCopy("zh-Hans", "serverSettings"),
authTypeOptions: buildServerAuthTypeOptions("zh-Hans"),
authTypeIndex: 0,
form: createServerSeed(),
tagText: "",
dirPickerVisible: false,
dirPickerLoading: false,
dirPickerError: "",
dirPickerSelectedPath: "~",
directoryRows: []
},
autoSaveTimer: null,
formDraft: null,
opsConfig: null,
directoryRoot: null,
directoryPickerSeq: 0,
directoryUsageMap: {},
getCurrentLanguage(settingsInput) {
const source = settingsInput && typeof settingsInput === "object" ? settingsInput : getSettings();
return normalizeUiLanguage(source.uiLanguage);
},
applyLocale(settingsInput, formInput) {
const settings = settingsInput && typeof settingsInput === "object" ? settingsInput : getSettings();
const form = normalizeServerForm(formInput || this.formDraft || this.data.form || createServerSeed());
const language = this.getCurrentLanguage(settings);
const copy = buildPageCopy(language, "serverSettings");
const authTypeOptions = buildServerAuthTypeOptions(language);
wx.setNavigationBarTitle({ title: copy.navTitle || "服务器配置" });
return {
copy,
authTypeOptions,
authTypeIndex: Math.max(0, authTypeOptions.findIndex((item) => item.value === form.authType))
};
},
onLoad(options) {
const serverId = options.id || "";
const rows = listServers();
const found = rows.find((item) => item.id === serverId) || createServerSeed();
const form = normalizeServerForm(found);
this.formDraft = { ...form };
this.opsConfig = getOpsConfig();
this.directoryUsageMap = readDirectoryUsageMap();
this.directoryPickerSeq = normalizeDirectoryPickerSeqValue(this.directoryPickerSeq);
const settings = getSettings();
const { icons, accentIcons } = buildButtonIconThemeMaps(settings);
applyNavigationBarTheme(settings);
const localePatch = this.applyLocale(settings, form);
this.setData({
serverId: form.id,
form,
tagText: Array.isArray(form.tags) ? form.tags.join(",") : "",
themeStyle: buildThemeStyle(settings),
icons,
accentIcons,
...localePatch
});
this.prefetchRootDirectory("onLoad");
},
onShow() {
const pages = getCurrentPages();
this.opsConfig = getOpsConfig();
this.directoryUsageMap = readDirectoryUsageMap();
const settings = getSettings();
const { icons, accentIcons } = buildButtonIconThemeMaps(settings);
applyNavigationBarTheme(settings);
const localePatch = this.applyLocale(settings);
this.setData({
canGoBack: pages.length > 1,
themeStyle: buildThemeStyle(settings),
icons,
accentIcons,
...localePatch
});
this.prefetchRootDirectory("onShow");
},
onUnload() {
this.clearAutoSaveTimer();
this.bumpDirectoryPickerSeq("onUnload");
},
onFieldInput(event) {
const key = resolveEventFieldKey(event);
if (!key) return;
const value = event.detail.value;
const base = this.formDraft || this.data.form || createServerSeed();
if (key === "tagsText") {
const next = {
...base,
tags: parseServerTags(value)
};
this.formDraft = next;
this.setData({
form: next,
tagText: String(value || "")
});
this.scheduleAutoSave(next);
return;
}
const next = updateFieldValue(base, key, value);
this.formDraft = next;
this.setData({ form: next });
this.scheduleAutoSave(next);
},
applyAuthTypeByIndex(indexInput) {
const index = Number(indexInput);
if (!Number.isFinite(index) || index < 0) return;
const option = AUTH_TYPE_OPTIONS[index] || AUTH_TYPE_OPTIONS[0];
if (!option) return;
const base = this.formDraft || this.data.form || createServerSeed();
if (base.authType === option.value && this.data.authTypeIndex === index) return;
const nextForm = {
...base,
authType: option.value
};
this.formDraft = nextForm;
this.setData({
authTypeIndex: index,
form: nextForm
});
this.scheduleAutoSave(nextForm);
},
onAuthTypeTap(event) {
const index = Number(event?.currentTarget?.dataset?.index);
this.applyAuthTypeByIndex(index);
},
onJumpAuthTypeTap(event) {
const index = Number(event?.currentTarget?.dataset?.index);
if (!Number.isFinite(index) || index < 0) return;
const option = AUTH_TYPE_OPTIONS[index] || AUTH_TYPE_OPTIONS[0];
if (!option) return;
const base = this.formDraft || this.data.form || createServerSeed();
if (base.jumpHost.authType === option.value) return;
const nextForm = {
...base,
jumpHost: {
...base.jumpHost,
authType: option.value
}
};
this.formDraft = nextForm;
this.setData({ form: nextForm });
this.scheduleAutoSave(nextForm);
},
onJumpEnabledTap(event) {
const enabled = String(event?.currentTarget?.dataset?.enabled || "") === "true";
const base = this.formDraft || this.data.form || createServerSeed();
if (base.jumpHost.enabled === enabled) return;
const nextForm = {
...base,
jumpHost: {
...base.jumpHost,
enabled
}
};
this.formDraft = nextForm;
this.setData({ form: nextForm });
this.scheduleAutoSave(nextForm);
},
onJumpSwitchChange(event) {
const enabled = event && event.detail ? event.detail.value === true : false;
const base = this.formDraft || this.data.form || createServerSeed();
if (base.jumpHost.enabled === enabled) return;
const nextForm = {
...base,
jumpHost: {
...base.jumpHost,
enabled
}
};
this.formDraft = nextForm;
this.setData({ form: nextForm });
this.scheduleAutoSave(nextForm);
},
bumpDirectoryPickerSeq(reason) {
const current = normalizeDirectoryPickerSeqValue(this.directoryPickerSeq);
if (current !== Number(this.directoryPickerSeq)) {
console.warn(
`[目录选择] 序号异常,自动重置 current=${String(this.directoryPickerSeq)} normalized=${current}`
);
}
const next = current + 1;
this.directoryPickerSeq = next;
if (reason) {
console.info(`[目录选择] 序号推进 reason=${reason} seq=${next}`);
}
return next;
},
isDirectoryPickerActive(seq) {
if (!Number.isFinite(Number(seq))) return false;
const requestSeq = normalizeDirectoryPickerSeqValue(seq);
const currentSeq = normalizeDirectoryPickerSeqValue(this.directoryPickerSeq);
this.directoryPickerSeq = currentSeq;
return requestSeq === currentSeq && !!this.data.dirPickerVisible;
},
refreshDirectoryRows(extraData) {
const selectedPathFromExtra =
extraData && typeof extraData.dirPickerSelectedPath === "string"
? extraData.dirPickerSelectedPath
: this.data.dirPickerSelectedPath;
const next = {
directoryRows: flattenDirectoryNodes(this.directoryRoot, selectedPathFromExtra)
};
if (extraData && typeof extraData === "object") {
Object.assign(next, extraData);
}
this.setData(next);
},
prefetchRootDirectory(reason) {
const form = normalizeServerForm(this.formDraft || this.data.form || createServerSeed());
const profileError = validateServerForConnect(form);
if (profileError) return;
this.opsConfig = this.opsConfig || getOpsConfig();
if (!isOpsConfigReady(this.opsConfig)) return;
const cached = readFreshRootPrefetchNames(form);
if (cached) return;
const pending = readRootPrefetchInFlight(form);
if (pending) return;
const startedAt = Date.now();
const request = listRemoteDirectories({
server: form,
opsConfig: this.opsConfig,
path: "~"
})
.then((names) => {
writeRootPrefetchResult(form, names);
console.info(
`[目录预取] 完成 reason=${reason || "-"} count=${Array.isArray(names) ? names.length : 0} ` +
`elapsed=${Date.now() - startedAt}ms`
);
return names;
})
.catch((error) => {
console.warn(
`[目录预取] 失败 reason=${reason || "-"} elapsed=${Date.now() - startedAt}ms ` +
`reason=${toErrorMessage(error, "unknown")}`
);
return null;
})
.finally(() => {
writeRootPrefetchInFlight(form, null);
});
writeRootPrefetchInFlight(form, request);
console.info(`[目录预取] 启动 reason=${reason || "-"}`);
},
async readRootDirectoryFromCacheOrPrefetch(form) {
const cached = readFreshRootPrefetchNames(form);
if (cached) {
return { names: cached, source: "cache" };
}
const inFlight = readRootPrefetchInFlight(form);
if (!inFlight) return { names: null, source: "none" };
try {
const names = await inFlight;
if (!Array.isArray(names)) return { names: null, source: "none" };
return {
names: names.slice(),
source: "inflight"
};
} catch {
return { names: null, source: "none" };
}
},
async ensureDirectoryNodeChildren(path, seq) {
if (!this.isDirectoryPickerActive(seq)) return;
const node = findDirectoryNode(this.directoryRoot, path);
if (!node || node.loading || node.loaded) return;
const loadStartedAt = Date.now();
console.info(`[目录选择] 加载开始 seq=${seq} path=${node.path}`);
node.loading = true;
this.refreshDirectoryRows();
try {
const form = normalizeServerForm(this.formDraft || this.data.form || createServerSeed());
const names = await listRemoteDirectories({
server: form,
opsConfig: this.opsConfig || getOpsConfig(),
path: node.path
});
if (!this.isDirectoryPickerActive(seq)) return;
const children = buildDirectoryChildrenFromNames(node, names, form, this.directoryUsageMap || {});
node.children = children;
node.loaded = true;
node.loading = false;
console.info(
`[目录选择] 加载完成 seq=${seq} path=${node.path} children=${children.length} ` +
`elapsed=${Date.now() - loadStartedAt}ms`
);
this.refreshDirectoryRows({ dirPickerError: "" });
} catch (error) {
if (!this.isDirectoryPickerActive(seq)) return;
node.loading = false;
console.warn(
`[目录选择] 加载失败 seq=${seq} path=${node.path} elapsed=${Date.now() - loadStartedAt}ms ` +
`reason=${toErrorMessage(error, "unknown")}`
);
this.refreshDirectoryRows();
throw error;
}
},
async expandToDirectoryPath(targetPath, seq) {
const normalizedTarget = normalizeHomePath(targetPath);
if (normalizedTarget === "~") return;
const segments = normalizedTarget
.slice(2)
.split("/")
.filter((part) => !!part);
let currentPath = "~";
for (let i = 0; i < segments.length; i += 1) {
if (!this.isDirectoryPickerActive(seq)) return;
await this.ensureDirectoryNodeChildren(currentPath, seq);
const parentNode = findDirectoryNode(this.directoryRoot, currentPath);
if (!parentNode) return;
parentNode.expanded = true;
const childPath = composeChildPath(currentPath, segments[i]);
const childNode = findDirectoryNode(this.directoryRoot, childPath);
if (!childNode) return;
childNode.expanded = true;
currentPath = childPath;
this.refreshDirectoryRows();
}
},
showDirectoryFetchError(error) {
const copy = this.data.copy || {};
const fallbackTitle = copy?.modal?.connectFailedTitle || "无法连接服务器";
const rawMessage = toErrorMessage(error, fallbackTitle);
const message = `${fallbackTitle}${rawMessage}`;
this.setData({
dirPickerError: message,
dirPickerLoading: false
});
if (/url not in domain list/i.test(rawMessage)) {
const domainHint = resolveSocketDomainHint(this.opsConfig && this.opsConfig.gatewayUrl);
wx.showModal({
title: copy?.modal?.socketDomainTitle || "Socket 域名未配置",
content: domainHint
? formatTemplate(copy?.modal?.socketDomainContent, { domainHint })
: copy?.modal?.socketDomainContentNoHint || "当前网关地址不在小程序 socket 合法域名列表",
showCancel: false
});
return;
}
wx.showModal({
title: fallbackTitle,
content: formatTemplate(copy?.modal?.connectFailedContent, { message: rawMessage }),
showCancel: false
});
},
async onOpenDirectoryPicker() {
// 再次点击“选择目录”时直接收起,行为等同取消。
if (this.data.dirPickerVisible) {
console.info("[目录选择] 二次点击选择目录,执行收起(等同取消)");
this.onDirectoryPickerCancel();
return;
}
const openStartedAt = Date.now();
const language = this.getCurrentLanguage();
const form = normalizeServerForm(this.formDraft || this.data.form || createServerSeed());
this.formDraft = { ...form };
this.setData({ form });
const profileError = validateServerForConnect(form);
if (profileError) {
wx.showToast({
title: localizeServerValidationMessage(language, profileError),
icon: "none"
});
return;
}
this.opsConfig = getOpsConfig();
if (!isOpsConfigReady(this.opsConfig)) {
wx.showToast({ title: this.data.copy?.toast?.opsConfigMissing || "运维配置缺失,请联系管理员", icon: "none" });
return;
}
const targetPath = normalizeHomePath(form.projectPath || "~");
const seq = this.bumpDirectoryPickerSeq("openPicker");
console.info(`[目录选择] 打开开始 seq=${seq} target=${targetPath}`);
this.directoryRoot = buildDirectoryNode("~", 0);
this.directoryRoot.expanded = true;
this.setData(
{
dirPickerVisible: true,
dirPickerLoading: true,
dirPickerError: "",
dirPickerSelectedPath: targetPath,
directoryRows: []
},
() => {
console.info(`[目录选择] 面板渲染完成 seq=${seq} elapsed=${Date.now() - openStartedAt}ms`);
}
);
this.refreshDirectoryRows({ dirPickerSelectedPath: targetPath });
try {
const rootNode = findDirectoryNode(this.directoryRoot, "~");
const prefetched = await this.readRootDirectoryFromCacheOrPrefetch(form);
if (!this.isDirectoryPickerActive(seq)) return;
if (rootNode && Array.isArray(prefetched.names)) {
rootNode.children = buildDirectoryChildrenFromNames(
rootNode,
prefetched.names,
form,
this.directoryUsageMap || {}
);
rootNode.loaded = true;
rootNode.loading = false;
console.info(
`[目录选择] 根目录命中预取 source=${prefetched.source} count=${prefetched.names.length} ` +
`elapsed=${Date.now() - openStartedAt}ms`
);
this.refreshDirectoryRows({ dirPickerError: "" });
} else {
await this.ensureDirectoryNodeChildren("~", seq);
}
await this.expandToDirectoryPath(targetPath, seq);
if (!this.isDirectoryPickerActive(seq)) return;
console.info(`[目录选择] 打开完成 seq=${seq} elapsed=${Date.now() - openStartedAt}ms`);
this.refreshDirectoryRows({ dirPickerLoading: false });
} catch (error) {
if (!this.isDirectoryPickerActive(seq)) return;
console.warn(
`[目录选择] 打开失败 seq=${seq} elapsed=${Date.now() - openStartedAt}ms ` +
`reason=${toErrorMessage(error, "unknown")}`
);
this.showDirectoryFetchError(error);
}
},
onDirectoryPickerCancel() {
console.info("[目录选择] 面板取消/收起");
this.bumpDirectoryPickerSeq("cancelPicker");
this.setData({
dirPickerVisible: false,
dirPickerLoading: false,
dirPickerError: "",
directoryRows: []
});
this.directoryRoot = null;
},
onDirectorySelectTap(event) {
const path = normalizeHomePath(event?.currentTarget?.dataset?.path || "~");
this.setData({ dirPickerSelectedPath: path }, () => this.refreshDirectoryRows());
},
async onDirectoryExpandTap(event) {
const path = normalizeHomePath(event?.currentTarget?.dataset?.path || "~");
const node = findDirectoryNode(this.directoryRoot, path);
if (!node) return;
const expandStartedAt = Date.now();
if (node.expanded) {
node.expanded = false;
this.refreshDirectoryRows();
console.info(`[目录选择] 收起目录 path=${path} elapsed=${Date.now() - expandStartedAt}ms`);
return;
}
node.expanded = true;
this.refreshDirectoryRows();
if (node.loaded) {
console.info(`[目录选择] 展开目录(已缓存) path=${path} elapsed=${Date.now() - expandStartedAt}ms`);
return;
}
const seq = this.directoryPickerSeq;
try {
await this.ensureDirectoryNodeChildren(path, seq);
console.info(`[目录选择] 展开目录完成 path=${path} elapsed=${Date.now() - expandStartedAt}ms`);
} catch (error) {
if (!this.isDirectoryPickerActive(seq)) return;
console.warn(
`[目录选择] 展开目录失败 path=${path} elapsed=${Date.now() - expandStartedAt}ms ` +
`reason=${toErrorMessage(error, "unknown")}`
);
this.showDirectoryFetchError(error);
}
},
onDirectoryPickerConfirm() {
const selectedPath = normalizeHomePath(this.data.dirPickerSelectedPath || "~");
const base = this.formDraft || this.data.form || createServerSeed();
const next = {
...base,
projectPath: selectedPath
};
const usageKey = buildDirectoryUsageKey(next, selectedPath);
this.directoryUsageMap = writeDirectoryUsageMap({
...(this.directoryUsageMap || {}),
[usageKey]: Date.now()
});
this.formDraft = next;
this.bumpDirectoryPickerSeq("confirmPicker");
this.directoryRoot = null;
this.setData({
form: next,
dirPickerVisible: false,
dirPickerLoading: false,
dirPickerError: "",
directoryRows: []
});
this.scheduleAutoSave(next);
},
goBack() {
if (!this.data.canGoBack) return;
wx.navigateBack({ delta: 1 });
},
persistForm(formInput) {
const form = normalizeServerForm(formInput || this.formDraft || this.data.form);
upsertServer(form);
this.formDraft = { ...form };
this.setData({
form,
authTypeIndex: Math.max(
0,
this.data.authTypeOptions.findIndex((item) => item.value === form.authType)
)
});
return form;
},
scheduleAutoSave(form) {
this.clearAutoSaveTimer();
this.autoSaveTimer = setTimeout(() => {
this.persistForm(form || this.data.form);
this.clearAutoSaveTimer();
}, 280);
},
clearAutoSaveTimer() {
if (!this.autoSaveTimer) return;
clearTimeout(this.autoSaveTimer);
this.autoSaveTimer = null;
},
onSave() {
this.clearAutoSaveTimer();
const saved = this.persistForm(this.formDraft || this.data.form);
if (!saved) return;
wx.showToast({ title: this.data.copy?.toast?.saved || "已保存", icon: "success" });
},
onConnect() {
this.clearAutoSaveTimer();
const saved = this.persistForm(this.formDraft || this.data.form);
if (!saved) return;
const error = validateServerForConnect(saved);
if (error) {
wx.showToast({
title: localizeServerValidationMessage(this.getCurrentLanguage(), error),
icon: "none"
});
return;
}
markServerConnected(saved.id);
appendLog({
serverId: saved.id,
status: "connecting",
summary: this.data.copy?.toast?.connectFromSettings || "从服务器配置页发起连接"
});
wx.navigateTo({ url: `/pages/terminal/index?serverId=${saved.id}` });
},
onOpenRecords() {
wx.navigateTo({ url: "/pages/records/index" });
},
// 配置页沿用独立底栏,在这里直接补关于入口,保证全页面可达。
onOpenAbout() {
wx.navigateTo({ url: "/pages/about/index" });
},
...createSvgButtonPressMethods()
});