first commit
This commit is contained in:
925
apps/miniprogram/pages/connect/index.js
Normal file
925
apps/miniprogram/pages/connect/index.js
Normal 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
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user