Files
2026-03-21 18:57:10 +08:00

926 lines
30 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.

/* global Page, wx, require, console, module */
const {
createServerSeed,
listServers,
saveServers,
upsertServer,
removeServer,
markServerConnected,
appendLog,
getSettings
} = require("../../utils/storage");
const { getTerminalSessionSnapshot } = require("../../utils/terminalSession");
const {
isTerminalSessionAiHighlighted,
isTerminalSessionConnecting,
isTerminalSessionHighlighted
} = require("../../utils/terminalSessionState");
const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle");
const { buildButtonIconThemeMaps, resolveButtonIcon } = require("../../utils/themedIcons");
const { openTerminalPage: navigateTerminalPage } = require("../../utils/terminalNavigation");
const { buildPageCopy, formatTemplate, normalizeUiLanguage } = require("../../utils/i18n");
const { subscribeSyncConfigApplied } = require("../../utils/syncConfigBus");
const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback");
const { getWindowMetrics } = require("../../utils/systemInfoCompat");
const SWIPE_AXIS_LOCK_THRESHOLD_PX = 8;
const SERVER_SWIPE_ACTION_WIDTH_RPX = 240;
const SERVER_SWIPE_FALLBACK_WIDTH_PX = 120;
function resolveTouchClientPoint(event) {
const point =
(event && event.touches && event.touches[0]) ||
(event && event.changedTouches && event.changedTouches[0]) ||
null;
if (!point) return null;
const x = Number(point.clientX);
const y = Number(point.clientY);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
}
function resolveTouchClientY(event) {
const point = resolveTouchClientPoint(event);
return point ? point.y : null;
}
/**
* 服务器左滑动作区露出“复制 + 删除”两个按钮,整体宽度按设计稿 `240rpx` 换算:
* 1. 统一在 JS 内转成 px便于与 touch `clientX` 直接比较;
* 2. 老环境拿不到窗口宽度时回退到保守值,避免手势完全失效。
*/
function resolveServerSwipeRevealPx(windowWidth) {
const width = Number(windowWidth);
if (!Number.isFinite(width) || width <= 0) {
return SERVER_SWIPE_FALLBACK_WIDTH_PX;
}
return Math.round((width * SERVER_SWIPE_ACTION_WIDTH_RPX) / 750);
}
function clampServerSwipeOffset(offset, revealPx) {
const numeric = Number(offset);
if (!Number.isFinite(numeric)) return 0;
if (numeric < -revealPx) return -revealPx;
if (numeric > 0) return 0;
return numeric;
}
function shouldOpenServerSwipe(offset, revealPx) {
return clampServerSwipeOffset(offset, revealPx) <= -revealPx * 0.45;
}
const loggedRenderProbeKeys = new Set();
function shouldUseTextIcons() {
if (!wx || typeof wx.getAppBaseInfo !== "function") return false;
try {
const info = wx.getAppBaseInfo() || {};
return (
String(info.platform || "")
.trim()
.toLowerCase() === "devtools"
);
} catch {
return false;
}
}
function inspectRenderPayload(payload) {
const stats = {
stringCount: 0,
dataImageCount: 0,
svgPathCount: 0,
urlCount: 0,
maxLength: 0,
samples: []
};
const walk = (value, path, depth) => {
if (depth > 5) return;
if (typeof value === "string") {
stats.stringCount += 1;
if (value.includes("data:image")) stats.dataImageCount += 1;
if (value.includes(".svg")) stats.svgPathCount += 1;
if (value.includes("url(")) stats.urlCount += 1;
if (value.length > stats.maxLength) stats.maxLength = value.length;
if (
stats.samples.length < 6 &&
(value.includes("data:image") ||
value.includes(".svg") ||
value.includes("url(") ||
value.length >= 120)
) {
stats.samples.push({
path,
length: value.length,
preview: value.slice(0, 120)
});
}
return;
}
if (!value || typeof value !== "object") return;
if (Array.isArray(value)) {
value.forEach((item, index) => walk(item, `${path}[${index}]`, depth + 1));
return;
}
Object.keys(value).forEach((key) => walk(value[key], path ? `${path}.${key}` : key, depth + 1));
};
walk(payload, "", 0);
return stats;
}
function logRenderProbeOnce(key, label, payload) {
const normalizedKey = String(key || label || "");
if (!normalizedKey || loggedRenderProbeKeys.has(normalizedKey)) return;
loggedRenderProbeKeys.add(normalizedKey);
console.warn(`[render_probe] ${label}`, inspectRenderPayload(payload));
}
/**
* 服务器列表页(对齐 Web ConnectView
* 1. 顶部三图标:新增、删除已选、全选/取消全选;
* 2. 搜索框 + 单层列表;
* 3. 每行保留 ai/connect 图标位,排序改为长按拖拽。
*/
const connectPageOptions = {
data: {
...buildSvgButtonPressData(),
themeStyle: "",
icons: {},
activeIcons: {},
accentIcons: {},
copy: buildPageCopy("zh-Hans", "connect"),
textIconMode: false,
query: "",
servers: [],
filteredServers: [],
selectedServerIds: [],
isAllSelected: false,
activeServerId: "",
connectingServerId: "",
dragActive: false,
dragServerId: ""
},
dragRuntime: null,
dragTapLockUntil: 0,
syncConfigUnsub: null,
swipeRuntime: null,
swipeOffsets: null,
serverSwipeRevealPx: 0,
onLoad() {
/**
* 首次启动时,云端 bootstrap 可能晚于首页首帧完成:
* 1. 首页先按旧本地快照渲染是正常的;
* 2. 一旦 bootstrap 合并回 storage需要立刻重读服务器列表和主题
* 3. 否则用户会误以为同步没生效,必须手动重进页面才看到更新。
*/
this.syncConfigUnsub = subscribeSyncConfigApplied(() => {
this.applyThemeStyle();
this.reloadServers();
});
},
onShow() {
this.applyThemeStyle();
this.reloadServers();
},
onHide() {
this.swipeRuntime = null;
this.closeAllServerRows();
if (this.data.dragActive) {
this.clearDragState();
}
},
onUnload() {
if (typeof this.syncConfigUnsub === "function") {
this.syncConfigUnsub();
this.syncConfigUnsub = null;
}
},
applyThemeStyle() {
const settings = getSettings();
const language = normalizeUiLanguage(settings.uiLanguage);
const copy = buildPageCopy(language, "connect");
const { icons, activeIcons, accentIcons } = buildButtonIconThemeMaps(settings);
applyNavigationBarTheme(settings);
wx.setNavigationBarTitle({ title: copy.navTitle || "服务器" });
const payload = {
themeStyle: buildThemeStyle(settings),
icons,
activeIcons,
accentIcons,
copy,
textIconMode: shouldUseTextIcons() && Object.keys(icons).length === 0
};
logRenderProbeOnce("connect.applyThemeStyle", "connect.applyThemeStyle", payload);
this.setData(payload);
},
reloadServers() {
const rows = listServers();
this.reconcileServerSwipeState(rows);
this.setData({ servers: rows }, () => {
this.applyFilter(this.data.query);
this.syncSelectState();
});
},
applyFilter(query) {
const text = String(query || "")
.trim()
.toLowerCase();
const selected = new Set(this.data.selectedServerIds);
const sessionSnapshot = getTerminalSessionSnapshot();
const fallbackThemeMaps = buildButtonIconThemeMaps(getSettings());
const iconMap =
this.data.icons && Object.keys(this.data.icons).length ? this.data.icons : fallbackThemeMaps.icons;
const activeIconMap =
this.data.activeIcons && Object.keys(this.data.activeIcons).length
? this.data.activeIcons
: fallbackThemeMaps.activeIcons;
const accentIconMap =
this.data.accentIcons && Object.keys(this.data.accentIcons).length
? this.data.accentIcons
: fallbackThemeMaps.accentIcons;
this.reconcileServerSwipeState(this.data.servers);
const next = this.data.servers
.filter((item) => {
if (!text) return true;
return [
item.name,
item.host,
item.username,
String(item.port),
item.authType,
this.resolveDisplayTags(item)
.map((tag) => tag.label)
.join(" ")
]
.join(" ")
.toLowerCase()
.includes(text);
})
.map((item) => {
const tags = this.resolveTags(item);
const displayTags = this.resolveDisplayTags(item);
const isConnected = isTerminalSessionHighlighted(sessionSnapshot, item.id);
const isAiConnected = isTerminalSessionAiHighlighted(sessionSnapshot, item.id);
return {
...item,
selected: selected.has(item.id),
swipeOffsetX:
this.swipeOffsets && Number.isFinite(Number(this.swipeOffsets[item.id]))
? this.clampServerSwipeOffset(this.swipeOffsets[item.id])
: 0,
tags,
displayTags,
lastConnectedText: this.formatLastConnected(item.lastConnectedAt),
authTypeLabel:
(this.data.copy &&
this.data.copy.authTypeLabels &&
this.data.copy.authTypeLabels[item.authType]) ||
item.authType ||
"-",
isConnected,
isAiConnected,
isConnecting:
this.data.connectingServerId === item.id || isTerminalSessionConnecting(sessionSnapshot, item.id),
aiPressKey: `connect-ai:${item.id}`,
connectPressKey: `connect-open:${item.id}`,
aiIcon: resolveButtonIcon("/assets/icons/ai.svg", isAiConnected ? activeIconMap : iconMap),
aiPressedIcon: resolveButtonIcon(
"/assets/icons/ai.svg",
isAiConnected ? activeIconMap : accentIconMap
),
connectIcon: resolveButtonIcon("/assets/icons/connect.svg", isConnected ? activeIconMap : iconMap),
connectPressedIcon: resolveButtonIcon(
"/assets/icons/connect.svg",
isConnected ? activeIconMap : accentIconMap
),
dragOffsetY: 0,
dragging: false
};
});
const payload = { query, filteredServers: next };
logRenderProbeOnce("connect.applyFilter", "connect.applyFilter", payload);
this.setData(payload, () => this.refreshDragVisual());
},
syncSelectState() {
const ids = this.data.servers.map((item) => item.id);
const selected = this.data.selectedServerIds.filter((id) => ids.includes(id));
const isAllSelected = ids.length > 0 && selected.length === ids.length;
this.setData({ selectedServerIds: selected, isAllSelected }, () => this.applyFilter(this.data.query));
},
onQueryInput(event) {
this.closeAllServerRows();
this.applyFilter(event.detail.value || "");
},
onSearchTap() {
this.closeAllServerRows();
this.applyFilter(this.data.query);
},
onCreateServer() {
this.closeAllServerRows();
const seed = createServerSeed();
const prefix =
(this.data.copy && this.data.copy.fallback && this.data.copy.fallback.newServerPrefix) || "server";
upsertServer({ ...seed, name: `${prefix}-${this.data.servers.length + 1}` });
this.reloadServers();
wx.navigateTo({ url: `/pages/server-settings/index?id=${seed.id}` });
},
onToggleServerSelect(event) {
this.closeAllServerRows();
const id = event.currentTarget.dataset.id;
if (!id) return;
const selected = new Set(this.data.selectedServerIds);
if (selected.has(id)) {
selected.delete(id);
} else {
selected.add(id);
}
this.setData({ selectedServerIds: [...selected] }, () => this.syncSelectState());
},
onToggleSelectAll() {
this.closeAllServerRows();
if (this.data.isAllSelected) {
this.setData({ selectedServerIds: [] }, () => this.syncSelectState());
return;
}
const all = this.data.servers.map((item) => item.id);
this.setData({ selectedServerIds: all }, () => this.syncSelectState());
},
onRemoveSelected() {
this.closeAllServerRows();
const targets = this.data.selectedServerIds;
if (!targets.length) return;
const copy = this.data.copy || {};
wx.showModal({
title: copy?.modal?.removeTitle || "删除服务器",
content: formatTemplate(copy?.modal?.removeContent, { count: targets.length }),
success: (res) => {
if (!res.confirm) return;
targets.forEach((id) => removeServer(id));
this.setData({ selectedServerIds: [] });
this.reloadServers();
}
});
},
onOpenSettings(event) {
if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return;
this.closeAllServerRows();
const serverId = event.currentTarget.dataset.id;
this.setData({ activeServerId: serverId || "" });
wx.navigateTo({ url: `/pages/server-settings/index?id=${serverId}` });
},
onConnect(event) {
if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return;
this.closeAllServerRows();
const serverId = event.currentTarget.dataset.id;
if (!serverId) return;
const sessionSnapshot = getTerminalSessionSnapshot();
if (
isTerminalSessionHighlighted(sessionSnapshot, serverId) ||
isTerminalSessionConnecting(sessionSnapshot, serverId)
) {
this.setData({ activeServerId: serverId }, () => this.applyFilter(this.data.query));
this.openTerminalPage(serverId, true);
return;
}
this.setData({ activeServerId: serverId, connectingServerId: serverId }, () =>
this.applyFilter(this.data.query)
);
markServerConnected(serverId);
appendLog({
serverId,
status: "connecting",
summary: (this.data.copy && this.data.copy.summary && this.data.copy.summary.connectFromList) || ""
});
this.setData({ connectingServerId: "" });
this.openTerminalPage(serverId, false);
},
/**
* 若当前页下方已经是终端页,优先直接返回,避免重复压入新的终端实例。
*/
openTerminalPage(serverId, reuseExisting, options) {
navigateTerminalPage(serverId, reuseExisting, options);
},
onAiTap(event) {
if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return;
this.closeAllServerRows();
const serverId = event.currentTarget.dataset.id;
if (!serverId) return;
const server = this.data.servers.find((item) => item.id === serverId);
if (!server) {
wx.showToast({ title: this.data.copy?.toast?.serverNotFound || "服务器不存在", icon: "none" });
return;
}
const sessionSnapshot = getTerminalSessionSnapshot();
this.setData({ activeServerId: serverId }, () => this.applyFilter(this.data.query));
if (
isTerminalSessionHighlighted(sessionSnapshot, serverId) ||
isTerminalSessionConnecting(sessionSnapshot, serverId)
) {
this.openTerminalPage(serverId, true, { openCodex: true });
return;
}
this.setData({ connectingServerId: serverId }, () => this.applyFilter(this.data.query));
markServerConnected(serverId);
appendLog({
serverId,
status: "connecting",
summary: (this.data.copy && this.data.copy.summary && this.data.copy.summary.aiFromList) || ""
});
this.setData({ connectingServerId: "" });
this.openTerminalPage(serverId, false, { openCodex: true });
},
/**
* 复制服务器配置(含认证信息):
* 1. 基于当前服务器快照复制全部字段;
* 2. 重新生成唯一 ID避免覆盖原记录
* 3. 名称按“原服务器名+copy”落库便于用户二次编辑。
*/
onCopyServer(event) {
if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return;
this.closeAllServerRows();
const serverId = event.currentTarget.dataset.id;
if (!serverId) return;
const source = this.data.servers.find((item) => item.id === serverId);
if (!source) {
wx.showToast({
title: this.data.copy?.toast?.serverToCopyNotFound || "未找到待复制服务器",
icon: "none"
});
return;
}
const seed = createServerSeed();
const copySuffix = String(this.data.copy?.labels?.copyNameSuffix || " copy");
const copied = {
...source,
id: seed.id,
name: `${String(source.name || this.data.copy?.unnamedServer || "未命名服务器")}${copySuffix}`,
sortOrder: Date.now(),
lastConnectedAt: ""
};
upsertServer(copied);
this.setData({ activeServerId: copied.id }, () => this.reloadServers());
wx.showToast({ title: this.data.copy?.toast?.serverCopied || "服务器已复制", icon: "none" });
},
onStartDrag(event) {
this.closeAllServerRows();
this.swipeRuntime = null;
const serverId = event.currentTarget.dataset.id;
if (!serverId) return;
if (this.data.dragActive) return;
if (String(this.data.query || "").trim()) {
wx.showToast({
title: this.data.copy?.toast?.clearSearchBeforeSort || "请清空搜索后再调整顺序",
icon: "none"
});
return;
}
if (this.data.filteredServers.length <= 1) return;
const fromIndex = this.data.filteredServers.findIndex((item) => item.id === serverId);
if (fromIndex < 0) return;
const query = wx.createSelectorQuery().in(this);
query.selectAll(".server-list-row").boundingClientRect((rects) => {
if (!Array.isArray(rects) || rects.length !== this.data.filteredServers.length) return;
const startRect = rects[fromIndex];
if (!startRect) return;
const startY = resolveTouchClientY(event) || startRect.top + startRect.height / 2;
this.dragRuntime = {
serverId,
fromIndex,
toIndex: fromIndex,
startY,
offsetY: 0,
orderIds: this.data.filteredServers.map((item) => item.id),
rects: rects.map((item) => ({
top: Number(item.top) || 0,
height: Number(item.height) || 0,
center: (Number(item.top) || 0) + (Number(item.height) || 0) / 2
}))
};
this.setData(
{
dragActive: true,
dragServerId: serverId
},
() => this.refreshDragVisual()
);
});
query.exec();
},
onDragTouchMove(event) {
if (!this.data.dragActive || !this.dragRuntime) return;
const touchY = resolveTouchClientY(event);
if (touchY == null) return;
const runtime = this.dragRuntime;
runtime.offsetY = touchY - runtime.startY;
const sourceRect = runtime.rects[runtime.fromIndex];
if (!sourceRect) return;
const center = sourceRect.center + runtime.offsetY;
let targetIndex = runtime.fromIndex;
let minDistance = Number.POSITIVE_INFINITY;
for (let i = 0; i < runtime.rects.length; i += 1) {
const distance = Math.abs(center - runtime.rects[i].center);
if (distance < minDistance) {
minDistance = distance;
targetIndex = i;
}
}
runtime.toIndex = targetIndex;
this.refreshDragVisual();
},
onDragTouchEnd() {
if (!this.data.dragActive || !this.dragRuntime) return;
const runtime = this.dragRuntime;
const moved = runtime.toIndex !== runtime.fromIndex;
const dragServerId = runtime.serverId;
this.clearDragState();
if (!moved || !dragServerId) return;
const rows = this.data.servers.slice();
const fromIndex = rows.findIndex((item) => item.id === dragServerId);
if (fromIndex < 0) return;
const [current] = rows.splice(fromIndex, 1);
if (!current) return;
const targetIndex = Math.max(0, Math.min(rows.length, runtime.toIndex));
rows.splice(targetIndex, 0, current);
this.persistServerOrder(rows, dragServerId);
},
clearDragState() {
this.dragRuntime = null;
this.dragTapLockUntil = Date.now() + 240;
const next = this.data.filteredServers.map((item) => {
if (!item.dragOffsetY && !item.dragging) return item;
return {
...item,
dragOffsetY: 0,
dragging: false
};
});
this.setData({
dragActive: false,
dragServerId: "",
filteredServers: next
});
},
buildDragOffsetMap() {
if (!this.data.dragActive || !this.dragRuntime) return {};
const runtime = this.dragRuntime;
const from = runtime.fromIndex;
const to = runtime.toIndex;
const offsets = {
[runtime.serverId]: runtime.offsetY
};
if (to > from) {
for (let i = from + 1; i <= to; i += 1) {
const id = runtime.orderIds[i];
if (!id) continue;
const prev = runtime.rects[i - 1];
const current = runtime.rects[i];
offsets[id] = (prev ? prev.top : 0) - (current ? current.top : 0);
}
} else if (to < from) {
for (let i = to; i < from; i += 1) {
const id = runtime.orderIds[i];
if (!id) continue;
const current = runtime.rects[i];
const next = runtime.rects[i + 1];
offsets[id] = (next ? next.top : 0) - (current ? current.top : 0);
}
}
return offsets;
},
refreshDragVisual() {
if (!this.data.dragActive || !this.dragRuntime) return;
const offsets = this.buildDragOffsetMap();
const dragId = this.data.dragServerId;
const next = this.data.filteredServers.map((item) => {
const dragOffsetY = Number(offsets[item.id] || 0);
const dragging = item.id === dragId;
if (item.dragOffsetY === dragOffsetY && item.dragging === dragging) {
return item;
}
return {
...item,
dragOffsetY,
dragging
};
});
this.setData({ filteredServers: next });
},
persistServerOrder(rows, activeServerId) {
const base = Date.now();
const next = rows.map((item, index) => ({
...item,
sortOrder: base + index
}));
saveServers(next);
this.setData(
{
servers: next,
activeServerId: activeServerId || this.data.activeServerId
},
() => {
this.applyFilter(this.data.query);
this.syncSelectState();
}
);
},
resolveTouchPoint(event) {
return resolveTouchClientPoint(event);
},
getServerSwipeRevealPx() {
if (Number.isFinite(this.serverSwipeRevealPx) && this.serverSwipeRevealPx > 0) {
return this.serverSwipeRevealPx;
}
const metrics = getWindowMetrics(wx);
this.serverSwipeRevealPx = resolveServerSwipeRevealPx(metrics.windowWidth);
return this.serverSwipeRevealPx;
},
clampServerSwipeOffset(offset) {
return clampServerSwipeOffset(offset, this.getServerSwipeRevealPx());
},
reconcileServerSwipeState(rows) {
const list = Array.isArray(rows) ? rows : [];
const validIds = new Set(list.map((item) => item.id));
const nextOffsets = {};
Object.keys(this.swipeOffsets || {}).forEach((id) => {
if (!validIds.has(id)) return;
nextOffsets[id] = this.clampServerSwipeOffset(this.swipeOffsets[id]);
});
this.swipeOffsets = nextOffsets;
if (this.swipeRuntime && !validIds.has(this.swipeRuntime.id)) {
this.swipeRuntime = null;
}
},
findFilteredServerIndexById(id) {
return this.data.filteredServers.findIndex((item) => item.id === id);
},
updateServerSwipeOffset(id, offset) {
const index = this.findFilteredServerIndexById(id);
if (index < 0) return;
const normalized = this.clampServerSwipeOffset(offset);
this.swipeOffsets = this.swipeOffsets || {};
this.swipeOffsets[id] = normalized;
this.setData({ [`filteredServers[${index}].swipeOffsetX`]: normalized });
},
closeOtherServerRows(exceptId) {
this.swipeOffsets = this.swipeOffsets || {};
const updates = {};
this.data.filteredServers.forEach((item, index) => {
if (item.id === exceptId) return;
if (item.swipeOffsetX === 0) return;
this.swipeOffsets[item.id] = 0;
updates[`filteredServers[${index}].swipeOffsetX`] = 0;
});
if (Object.keys(updates).length > 0) {
this.setData(updates);
}
},
closeAllServerRows() {
this.swipeOffsets = this.swipeOffsets || {};
const updates = {};
this.data.filteredServers.forEach((item, index) => {
if (item.swipeOffsetX === 0) return;
this.swipeOffsets[item.id] = 0;
updates[`filteredServers[${index}].swipeOffsetX`] = 0;
});
if (Object.keys(updates).length > 0) {
this.setData(updates);
}
},
onListTap() {
this.closeAllServerRows();
},
/**
* 服务器列表沿用闪念页的横向手势模型:
* 1. `touchstart` 仅记录起点和当前开合态;
* 2. `touchmove` 再做横纵轴锁定,避免和列表纵向滚动打架;
* 3. 只允许向左露出删除按钮,不支持向右拖出正偏移。
*/
onServerTouchStart(event) {
if (this.data.dragActive) return;
const id = String(event.currentTarget.dataset.id || "");
if (!id) return;
const point = this.resolveTouchPoint(event);
if (!point) return;
this.closeOtherServerRows(id);
this.swipeOffsets = this.swipeOffsets || {};
this.swipeRuntime = {
id,
startX: point.x,
startY: point.y,
startOffsetX: this.clampServerSwipeOffset(this.swipeOffsets[id] || 0),
dragging: false,
blocked: false
};
},
onServerTouchMove(event) {
if (this.data.dragActive) return;
const runtime = this.swipeRuntime;
if (!runtime || runtime.blocked) return;
const id = String(event.currentTarget.dataset.id || "");
if (!id || id !== runtime.id) return;
const point = this.resolveTouchPoint(event);
if (!point) return;
const deltaX = point.x - runtime.startX;
const deltaY = point.y - runtime.startY;
if (!runtime.dragging) {
if (
Math.abs(deltaX) < SWIPE_AXIS_LOCK_THRESHOLD_PX &&
Math.abs(deltaY) < SWIPE_AXIS_LOCK_THRESHOLD_PX
) {
return;
}
if (Math.abs(deltaY) > Math.abs(deltaX)) {
runtime.blocked = true;
return;
}
runtime.dragging = true;
}
this.updateServerSwipeOffset(id, runtime.startOffsetX + deltaX);
},
onServerTouchEnd(event) {
if (this.data.dragActive) return;
const runtime = this.swipeRuntime;
this.swipeRuntime = null;
if (!runtime || runtime.blocked) return;
const id = String(event.currentTarget.dataset.id || "");
if (!id || id !== runtime.id) return;
const current = this.clampServerSwipeOffset((this.swipeOffsets && this.swipeOffsets[id]) || 0);
const shouldOpen = shouldOpenServerSwipe(current, this.getServerSwipeRevealPx());
this.updateServerSwipeOffset(id, shouldOpen ? -this.getServerSwipeRevealPx() : 0);
},
onSwipeDeleteServer(event) {
const serverId = String(event.currentTarget.dataset.id || "");
if (!serverId) return;
const server = this.data.servers.find((item) => item.id === serverId);
const serverName = String(
(server && server.name) || this.data.copy?.unnamedServer || event.currentTarget.dataset.name || ""
).trim();
const copy = this.data.copy || {};
wx.showModal({
title: copy?.modal?.removeTitle || "删除服务器",
content: formatTemplate(copy?.modal?.removeSingleContent, {
name: serverName || copy?.unnamedServer || "未命名服务器"
}),
success: (res) => {
if (!res.confirm) return;
removeServer(serverId);
if (this.swipeOffsets) {
delete this.swipeOffsets[serverId];
}
this.setData(
{
activeServerId: this.data.activeServerId === serverId ? "" : this.data.activeServerId,
selectedServerIds: this.data.selectedServerIds.filter((id) => id !== serverId)
},
() => {
this.reloadServers();
wx.showToast({
title: copy?.toast?.serverDeleted || "服务器已删除",
icon: "success"
});
}
);
}
});
},
/**
* 服务器标签对齐 Web 语义:
* 1. 只展示用户明确配置的 tags
* 2. 不再按服务器名称猜测标签,避免与真实标签混淆。
*/
resolveTags(server) {
return Array.isArray(server && server.tags)
? server.tags.map((item) => String(item || "").trim()).filter((item) => !!item)
: [];
},
/**
* 提取项目目录最后一级名称:
* 1. 先清理空白与尾部斜杠;
* 2. 同时兼容 Unix/Windows 路径;
* 3. 列表里只展示短目录名,避免胶囊过长。
*/
resolveProjectDirectoryName(projectPath) {
const normalized = String(projectPath || "")
.trim()
.replace(/[\\/]+$/g, "");
if (!normalized) return "";
const segments = normalized.split(/[\\/]+/).filter(Boolean);
if (!segments.length) {
return normalized === "~" ? "~" : "";
}
return segments[segments.length - 1] || "";
},
/**
* 卡片底部标签组装:
* 1. project 胶囊固定排在最前面;
* 2. 原始 tags 继续跟在后面;
* 3. 返回对象数组,便于模板按类型切换底色。
*/
resolveDisplayTags(server) {
const displayTags = [];
const projectDirectoryName = this.resolveProjectDirectoryName(server && server.projectPath);
if (projectDirectoryName) {
displayTags.push({
type: "project",
label: `${(this.data.copy && this.data.copy.display && this.data.copy.display.projectPrefix) || "pro"}:${projectDirectoryName}`
});
}
this.resolveTags(server).forEach((tag) => {
displayTags.push({
type: "tag",
label: tag
});
});
return displayTags;
},
/**
* 最近连接时间统一格式:
* - 空值显示“无连接”;
* - 非法时间显示“无连接”;
* - 合法时间输出 YYYY-MM-DD HH:mm:ss。
*/
formatLastConnected(input) {
if (!input) return this.data.copy?.fallback?.noConnection || "无连接";
const date = new Date(input);
if (Number.isNaN(+date)) return this.data.copy?.fallback?.noConnection || "无连接";
const pad = (n) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
},
...createSvgButtonPressMethods()
};
Page(connectPageOptions);
module.exports = {
__test__: {
pageOptions: connectPageOptions,
SWIPE_AXIS_LOCK_THRESHOLD_PX,
SERVER_SWIPE_ACTION_WIDTH_RPX,
SERVER_SWIPE_FALLBACK_WIDTH_PX,
resolveServerSwipeRevealPx,
clampServerSwipeOffset,
shouldOpenServerSwipe
}
};