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

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "服务器",
"disableScroll": true,
"usingComponents": {
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type ConnectPageOptions = {
data: Record<string, unknown>;
onServerTouchStart?: (event: Record<string, unknown>) => void;
onServerTouchMove?: (event: Record<string, unknown>) => void;
onServerTouchEnd?: (event: Record<string, unknown>) => void;
closeOtherServerRows?: (exceptId: string) => void;
findFilteredServerIndexById?: (id: string) => number;
updateServerSwipeOffset?: (id: string, offset: number) => void;
clampServerSwipeOffset?: (offset: number) => number;
resolveTouchPoint?: (event: Record<string, unknown>) => { x: number; y: number } | null;
};
type ConnectTestHelpers = {
pageOptions: ConnectPageOptions;
resolveServerSwipeRevealPx: (windowWidth: number) => number;
clampServerSwipeOffset: (offset: number, revealPx: number) => number;
shouldOpenServerSwipe: (offset: number, revealPx: number) => boolean;
};
type MiniprogramGlobals = typeof globalThis & {
Page?: (options: ConnectPageOptions) => void;
wx?: {
getWindowInfo?: () => { windowWidth: number; windowHeight: number };
};
};
function createTouchEvent(id: string, x: number, y: number) {
return {
currentTarget: {
dataset: { id }
},
touches: [{ clientX: x, clientY: y }],
changedTouches: [{ clientX: x, clientY: y }]
};
}
/**
* 测试里只需要覆盖当前页面真正会写入的 `setData` 路径:
* 1. 直接字段,如 `dragActive`
* 2. `filteredServers[0].swipeOffsetX` 这类列表项字段。
*/
function applySetData(target: Record<string, unknown>, updates: Record<string, unknown>) {
Object.entries(updates).forEach(([path, value]) => {
const itemMatch = path.match(/^filteredServers\[(\d+)\]\.([a-zA-Z0-9_]+)$/);
if (itemMatch) {
const index = Number(itemMatch[1]);
const key = itemMatch[2];
const rows = target.filteredServers as Array<Record<string, unknown>>;
rows[index][key] = value;
return;
}
target[path] = value;
});
}
function createFakePage(options: ConnectPageOptions) {
const page = {
...options,
data: {
...options.data,
dragActive: false,
filteredServers: [
{
id: "srv-1",
swipeOffsetX: 0
}
]
},
swipeOffsets: { "srv-1": 0 },
swipeRuntime: null as Record<string, unknown> | null,
serverSwipeRevealPx: 120,
setData(updates: Record<string, unknown>) {
applySetData(this.data as Record<string, unknown>, updates);
},
getServerSwipeRevealPx() {
return 120;
}
};
return page;
}
describe("connect page swipe", () => {
const globalState = globalThis as MiniprogramGlobals;
const originalPage = globalState.Page;
const originalWx = globalState.wx;
let testHelpers: ConnectTestHelpers | null = null;
beforeEach(() => {
testHelpers = null;
vi.resetModules();
globalState.Page = vi.fn((options: ConnectPageOptions) => {
return options;
});
globalState.wx = {
getWindowInfo: vi.fn(() => ({ windowWidth: 375, windowHeight: 812 }))
};
testHelpers = require("./index.js").__test__;
});
afterEach(() => {
if (originalPage) {
globalState.Page = originalPage;
} else {
delete globalState.Page;
}
if (originalWx) {
globalState.wx = originalWx;
} else {
delete globalState.wx;
}
});
it("应按窗口宽度把删除动作区从 rpx 换算成 px", () => {
expect(testHelpers).toBeTruthy();
expect(testHelpers?.resolveServerSwipeRevealPx(375)).toBe(120);
expect(testHelpers?.resolveServerSwipeRevealPx(0)).toBeGreaterThan(0);
});
it("横向左滑超过阈值后应展开删除按钮", () => {
expect(testHelpers?.pageOptions).toBeTruthy();
const page = createFakePage(testHelpers?.pageOptions as ConnectPageOptions);
testHelpers?.pageOptions.onServerTouchStart?.call(page, createTouchEvent("srv-1", 220, 100));
testHelpers?.pageOptions.onServerTouchMove?.call(page, createTouchEvent("srv-1", 160, 103));
expect((page.data.filteredServers as Array<Record<string, unknown>>)[0].swipeOffsetX).toBe(-60);
testHelpers?.pageOptions.onServerTouchEnd?.call(page, createTouchEvent("srv-1", 160, 103));
expect((page.data.filteredServers as Array<Record<string, unknown>>)[0].swipeOffsetX).toBe(-120);
});
it("纵向滚动手势不应误展开删除按钮", () => {
expect(testHelpers?.pageOptions).toBeTruthy();
const page = createFakePage(testHelpers?.pageOptions as ConnectPageOptions);
testHelpers?.pageOptions.onServerTouchStart?.call(page, createTouchEvent("srv-1", 220, 100));
testHelpers?.pageOptions.onServerTouchMove?.call(page, createTouchEvent("srv-1", 214, 136));
testHelpers?.pageOptions.onServerTouchEnd?.call(page, createTouchEvent("srv-1", 214, 136));
expect((page.data.filteredServers as Array<Record<string, unknown>>)[0].swipeOffsetX).toBe(0);
});
it("开合阈值应与当前动作区宽度一致", () => {
expect(testHelpers?.clampServerSwipeOffset(-160, 120)).toBe(-120);
expect(testHelpers?.clampServerSwipeOffset(12, 120)).toBe(0);
expect(testHelpers?.shouldOpenServerSwipe(-60, 120)).toBe(true);
expect(testHelpers?.shouldOpenServerSwipe(-40, 120)).toBe(false);
});
});

View File

@@ -0,0 +1,220 @@
<view class="page-root server-manager-page" style="{{themeStyle}}">
<view class="page-toolbar server-manager-toolbar">
<view class="toolbar-left">
<button
class="icon-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="connect:create"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onCreateServer"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'connect:create' ? (accentIcons.create || icons.create || '/assets/icons/create.svg') : (icons.create || '/assets/icons/create.svg')}}"
mode="aspectFit"
/>
<text wx:else class="debug-icon-text">{{copy.textIcons.create}}</text>
</button>
<button
class="icon-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="connect:remove"
disabled="{{selectedServerIds.length === 0}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onRemoveSelected"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'connect:remove' ? (accentIcons.delete || icons.delete || '/assets/icons/delete.svg') : (icons.delete || '/assets/icons/delete.svg')}}"
mode="aspectFit"
/>
<text wx:else class="debug-icon-text">{{copy.textIcons.remove}}</text>
</button>
<button
class="icon-btn toolbar-plain-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="connect:selectall"
disabled="{{servers.length === 0}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onToggleSelectAll"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'connect:selectall' ? (accentIcons.selectall || icons.selectall || '/assets/icons/selectall.svg') : (icons.selectall || '/assets/icons/selectall.svg')}}"
mode="aspectFit"
/>
<text wx:else class="debug-icon-text">{{copy.textIcons.selectAll}}</text>
</button>
</view>
<view class="toolbar-spacer"></view>
<text class="page-title">{{copy.pageTitle}}</text>
</view>
<view class="page-content server-manager-content">
<view class="server-search-wrap">
<view class="server-search-shell">
<input class="server-search-input" type="text" placeholder="{{copy.searchPlaceholder}}" value="{{query}}" bindinput="onQueryInput" />
<button
class="server-search-btn svg-press-btn"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="connect:search"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onSearchTap"
>
<image
wx:if="{{!textIconMode}}"
class="server-search-icon svg-press-icon"
src="{{pressedSvgButtonKey === 'connect:search' ? (accentIcons.search || icons.search || '/assets/icons/search.svg') : (icons.search || '/assets/icons/search.svg')}}"
mode="aspectFit"
/>
<text wx:else class="server-search-text">{{copy.textIcons.search}}</text>
</button>
</view>
</view>
<scroll-view class="surface-scroll server-list-scroll" scroll-y="{{!dragActive}}">
<view
class="server-list-stack {{dragActive ? 'dragging' : ''}}"
bindtap="onListTap"
bindtouchmove="onDragTouchMove"
bindtouchend="onDragTouchEnd"
bindtouchcancel="onDragTouchEnd"
>
<view
wx:for="{{filteredServers}}"
wx:key="id"
class="server-list-row {{activeServerId === item.id ? 'active' : ''}} {{item.dragging ? 'is-dragging' : ''}}"
style="transform: translateY({{item.dragOffsetY || 0}}px); z-index: {{item.dragging ? 20 : 1}};"
>
<view class="server-row-check-wrap">
<view class="server-row-check-hitbox" data-id="{{item.id}}" catchtap="onToggleServerSelect">
<view class="server-row-check">
<view class="server-check-input {{item.selected ? 'checked' : ''}}"></view>
</view>
</view>
</view>
<view
class="server-row-content-shell"
data-id="{{item.id}}"
bindtouchstart="onServerTouchStart"
bindtouchmove="onServerTouchMove"
bindtouchend="onServerTouchEnd"
bindtouchcancel="onServerTouchEnd"
>
<view class="server-row-swipe-actions {{(item.swipeOffsetX || 0) < 0 ? 'opened' : ''}}">
<button
class="server-swipe-copy-btn"
data-id="{{item.id}}"
catchtap="onCopyServer"
>
<text class="server-swipe-btn-text">{{copy.swipeCopy}}</text>
</button>
<button
class="server-swipe-delete-btn"
data-id="{{item.id}}"
data-name="{{item.name || copy.unnamedServer}}"
catchtap="onSwipeDeleteServer"
>
<text class="server-swipe-btn-text">{{copy.swipeDelete}}</text>
</button>
</view>
<view class="server-row-track" style="transform: translateX({{item.swipeOffsetX || 0}}px);">
<view
class="server-info server-info-clickable"
data-id="{{item.id}}"
bindtap="onOpenSettings"
catchlongpress="onStartDrag"
>
<view class="server-info-top">
<text class="server-name">{{item.name || copy.unnamedServer}}</text>
<view class="server-row-actions">
<button
class="server-ai-btn svg-press-btn {{item.isAiConnected ? 'is-connected' : ''}}"
data-id="{{item.id}}"
data-press-key="{{item.aiPressKey}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
catchtap="onAiTap"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img server-ai-icon svg-press-icon"
src="{{pressedSvgButtonKey === item.aiPressKey ? item.aiPressedIcon : item.aiIcon}}"
mode="aspectFit"
/>
<text wx:else class="debug-icon-text debug-icon-text-small">AI</text>
</button>
<button
class="connect-icon-btn svg-press-btn {{item.isConnected ? 'is-connected' : ''}} {{item.isConnecting ? 'is-connecting' : ''}}"
data-id="{{item.id}}"
data-press-key="{{item.connectPressKey}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
catchtap="onConnect"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === item.connectPressKey ? item.connectPressedIcon : item.connectIcon}}"
mode="aspectFit"
/>
<text wx:else class="debug-icon-text debug-icon-text-small">{{copy.textIcons.connect}}</text>
</button>
</view>
</view>
<view class="server-info-meta">
<text class="server-main">{{item.username || '-'}}@{{item.host || '-'}}:{{item.port || 22}}</text>
<text class="server-auth">{{item.authTypeLabel || '-'}}</text>
</view>
<text class="server-recent">{{copy.recentConnectionPrefix}}: {{item.lastConnectedText}}</text>
<view wx:if="{{item.displayTags.length > 0}}" class="server-tags">
<text
wx:for="{{item.displayTags}}"
wx:key="label"
class="server-tag {{item.type === 'project' ? 'server-tag-project' : ''}}"
>{{item.label}}</text>
</view>
</view>
</view>
</view>
</view>
<text wx:if="{{filteredServers.length === 0}}" class="server-empty-tip">{{copy.emptyTip}}</text>
</view>
</scroll-view>
</view>
<bottom-nav page="connect" />
</view>

View File

@@ -0,0 +1,499 @@
.server-manager-page {
-webkit-user-select: none;
user-select: none;
}
.server-manager-content {
flex: 1;
min-height: 0;
padding: 16rpx 16rpx 32rpx;
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.server-search-wrap {
flex: 0 0 auto;
padding: 0 0 16rpx;
}
.server-manager-toolbar .icon-btn {
border-radius: 999rpx !important;
background: var(--icon-btn-bg) !important;
background-color: var(--icon-btn-bg) !important;
box-shadow: inset 0 0 0 1rpx var(--btn-border);
}
.server-manager-toolbar .svg-press-btn {
--svg-press-active-radius: 999rpx;
--svg-press-active-bg: var(--icon-btn-bg-strong);
--svg-press-active-shadow: inset 0 0 0 1rpx var(--accent-border), 0 0 0 8rpx var(--accent-ring);
--svg-press-active-scale: 0.92;
--svg-press-icon-opacity: 0.96;
--svg-press-icon-active-opacity: 0.68;
--svg-press-icon-active-scale: 0.88;
}
.server-manager-toolbar .icon-btn:active {
background: var(--icon-btn-bg-strong) !important;
background-color: var(--icon-btn-bg-strong) !important;
}
.server-manager-toolbar .toolbar-plain-btn,
.server-manager-toolbar .toolbar-plain-btn:active {
border-radius: 0 !important;
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
}
.server-search-shell {
display: flex;
align-items: center;
width: 100%;
height: 64rpx;
border: 1rpx solid var(--btn-border);
border-radius: 54rpx;
overflow: hidden;
}
.server-search-input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
border-radius: 0;
background: transparent;
color: var(--text);
font-size: 22rpx;
line-height: normal;
padding: 0 16rpx;
}
.server-search-btn {
width: 68rpx !important;
min-width: 68rpx !important;
height: 100% !important;
margin: 0 !important;
border: 0 !important;
border-left: 1rpx solid var(--btn-border-strong);
border-radius: 0 54rpx 54rpx 0;
background: var(--btn-bg-strong) !important;
background-color: var(--btn-bg-strong) !important;
color: inherit !important;
padding: 0 !important;
line-height: 1 !important;
font-size: 0 !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.server-search-btn.svg-press-btn {
--svg-press-active-radius: 54rpx;
--svg-press-active-bg: var(--btn-bg-active);
--svg-press-active-shadow: none;
--svg-press-active-scale: 1;
--svg-press-icon-opacity: 0.96;
--svg-press-icon-active-opacity: 0.72;
--svg-press-icon-active-scale: 0.92;
}
.server-search-icon {
width: 26rpx;
height: 26rpx;
}
.server-search-text,
.debug-icon-text {
font-size: 20rpx;
line-height: 1;
font-weight: 600;
color: var(--btn-text);
}
.debug-icon-text-small {
font-size: 18rpx;
}
.server-list-scroll {
flex: 1;
min-height: 0;
}
.server-list-stack {
display: flex;
flex-direction: column;
gap: 32rpx;
padding-bottom: 16rpx;
position: relative;
}
.server-list-stack.dragging {
overflow: visible;
}
.server-list-row {
display: flex;
align-items: flex-start;
padding-bottom: 32rpx;
border-bottom: 1rpx solid rgba(141, 187, 255, 0.35);
position: relative;
transition:
transform 180ms ease,
box-shadow 180ms ease,
opacity 180ms ease;
will-change: transform;
isolation: isolate;
}
.server-list-row::before {
content: "";
position: absolute;
top: -8rpx;
left: -10rpx;
right: -10rpx;
bottom: 10rpx;
border-radius: 18rpx;
background: transparent;
box-shadow: none;
transition:
background 180ms ease,
box-shadow 180ms ease;
z-index: 0;
}
.server-list-row.is-dragging {
transition: none;
opacity: 0.5;
}
.server-list-row.is-dragging::before {
background: rgba(103, 209, 255, 0.22);
box-shadow: 0 18rpx 34rpx rgba(0, 0, 0, 0.26);
}
.server-list-row.active {
border-bottom-color: rgba(103, 209, 255, 0.75);
}
.server-row-check-wrap {
flex: 0 0 24rpx;
width: 24rpx;
height: 44rpx;
margin-right: 16rpx;
position: relative;
z-index: 2;
overflow: visible;
}
.server-row-content-shell {
flex: 1;
min-width: 0;
position: relative;
overflow: hidden;
border-radius: 18rpx;
z-index: 1;
}
.server-row-swipe-actions {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 240rpx;
display: flex;
align-items: stretch;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
transition: opacity 160ms ease;
z-index: 0;
}
.server-row-swipe-actions.opened {
opacity: 1;
pointer-events: auto;
}
.server-swipe-copy-btn,
.server-swipe-delete-btn {
width: 50% !important;
min-width: 0 !important;
height: 100% !important;
margin: 0 !important;
border: 0 !important;
color: #f7fbff !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
line-height: 1 !important;
font-size: 0 !important;
}
.server-swipe-copy-btn {
border-radius: 18rpx 0 0 18rpx !important;
background: rgba(101, 130, 149, 0.84) !important;
}
.server-swipe-delete-btn {
border-radius: 0 18rpx 18rpx 0 !important;
background: rgba(164, 118, 118, 0.86) !important;
}
.server-swipe-btn-text {
font-size: 24rpx;
line-height: 1;
font-weight: 600;
letter-spacing: 2rpx;
}
.server-row-track {
position: relative;
z-index: 1;
transition: transform 160ms ease;
will-change: transform;
}
.server-row-check-hitbox {
/* 透明热区浮在勾选框上方,并向右覆盖一部分卡片左边缘。 */
position: absolute;
left: -20rpx;
top: 50%;
width: 64rpx;
height: 64rpx;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.server-row-check {
width: 24rpx;
height: 44rpx;
display: inline-flex;
align-items: center;
justify-content: center;
}
.server-check-input {
width: 24rpx;
height: 24rpx;
border: 1rpx solid var(--btn-border);
border-radius: 8rpx;
background: var(--icon-btn-bg);
position: relative;
}
.server-check-input.checked {
border-color: var(--accent-border);
background: var(--accent);
}
.server-check-input.checked::after {
content: "";
position: absolute;
left: 9rpx;
top: 3rpx;
width: 5rpx;
height: 12rpx;
border: solid #ffffff;
border-width: 0 2rpx 2rpx 0;
transform: rotate(45deg);
}
.server-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
min-width: 0;
position: relative;
z-index: 1;
}
.server-info-top {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16rpx;
padding-right: 0;
}
.server-row-actions {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 12rpx;
margin-left: auto;
}
.server-copy-btn,
.server-ai-btn,
.connect-icon-btn {
width: 44rpx !important;
height: 44rpx !important;
min-width: 0 !important;
margin: 0 !important;
border: 0 !important;
background: transparent !important;
background-color: transparent !important;
color: inherit !important;
padding: 0 !important;
line-height: 1 !important;
font-size: 0 !important;
display: inline-flex !important;
overflow: visible !important;
align-items: center;
justify-content: center;
}
.server-copy-btn {
border-radius: 999rpx !important;
background: var(--btn-bg) !important;
background-color: var(--btn-bg) !important;
box-shadow: inset 0 0 0 2rpx var(--btn-border-strong);
}
.server-copy-icon {
width: 24rpx;
height: 24rpx;
}
.server-ai-btn,
.server-ai-icon {
width: 44rpx;
height: 44rpx;
}
.connect-icon-btn {
border-radius: 999rpx !important;
background: var(--icon-btn-bg) !important;
background-color: var(--icon-btn-bg) !important;
box-shadow: inset 0 0 0 2rpx var(--btn-border);
}
.server-ai-btn {
border-radius: 999rpx !important;
background: var(--icon-btn-bg) !important;
background-color: var(--icon-btn-bg) !important;
box-shadow: inset 0 0 0 2rpx var(--btn-border);
}
.server-copy-btn.svg-press-btn,
.server-ai-btn.svg-press-btn,
.connect-icon-btn.svg-press-btn {
--svg-press-active-radius: 999rpx;
--svg-press-active-bg: var(--icon-btn-bg-strong);
--svg-press-active-shadow: 0 0 0 8rpx var(--accent-ring);
--svg-press-active-scale: 0.92;
--svg-press-icon-opacity: 0.96;
--svg-press-icon-active-opacity: 0.68;
--svg-press-icon-active-scale: 0.88;
}
.server-copy-btn.svg-press-btn {
--svg-press-active-bg: var(--btn-bg-active);
}
.server-ai-btn.is-connected,
.connect-icon-btn.is-connected {
background: var(--accent) !important;
background-color: var(--accent) !important;
box-shadow: 0 10rpx 24rpx var(--accent-shadow) !important;
}
.server-ai-btn.is-connected.svg-press-btn,
.connect-icon-btn.is-connected.svg-press-btn {
--svg-press-active-bg: var(--accent);
--svg-press-active-shadow: 0 0 0 8rpx var(--accent-ring), 0 10rpx 24rpx var(--accent-shadow);
--svg-press-icon-active-opacity: 0.92;
--svg-press-icon-active-scale: 0.94;
}
.server-ai-btn.is-connected .debug-icon-text,
.connect-icon-btn.is-connected .debug-icon-text {
color: var(--text);
}
.connect-icon-btn.is-connecting {
opacity: 0.45;
}
.server-copy-btn.wx-button-disabled,
.server-ai-btn.wx-button-disabled,
.connect-icon-btn.wx-button-disabled {
opacity: 0.45 !important;
}
.server-info-meta {
display: flex;
align-items: center;
gap: 15rpx;
color: var(--text);
}
.server-name {
flex: 1;
min-width: 0;
margin-right: 8rpx;
font-size: 32rpx;
font-weight: 600;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-main,
.server-auth,
.server-recent {
font-size: 28rpx;
color: var(--text);
line-height: 1;
}
.server-main {
max-width: 360rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-auth {
font-size: 24rpx;
opacity: 0.95;
}
.server-tags {
display: flex;
align-items: center;
gap: 16rpx;
overflow: hidden;
}
.server-tag {
height: 32rpx;
padding: 0 12rpx;
border-radius: 16rpx;
background: rgba(91, 210, 255, 0.6);
color: var(--text);
font-size: 20rpx;
line-height: 32rpx;
white-space: nowrap;
}
.server-tag-project {
background: rgba(103, 209, 255, 1);
}
.server-empty-tip {
margin: 0;
font-size: 28rpx;
color: var(--muted);
text-align: center;
padding: 24rpx 0;
}