1008 lines
33 KiB
JavaScript
1008 lines
33 KiB
JavaScript
/* 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()
|
||
});
|