/* 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 } };