first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

View File

@@ -0,0 +1,925 @@
/* 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
}
};