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,351 @@
/* global Component, getCurrentPages, wx, require, console */
const { getSettings, listServers } = require("../../utils/storage");
const { getTerminalSessionSnapshot } = require("../../utils/terminalSession");
const { buildButtonIconThemeMaps, resolveButtonIcon } = require("../../utils/themedIcons");
const { resolvePageNavigationMethod } = require("../../utils/navigationPolicy");
const {
hasActiveTerminalSession,
openTerminalPage,
resolveActiveTerminalServerId
} = require("../../utils/terminalNavigation");
const { buildPageCopy, normalizeUiLanguage, t } = require("../../utils/i18n");
const { subscribeLocaleChange } = require("../../utils/localeBus");
const { subscribeSyncConfigApplied } = require("../../utils/syncConfigBus");
const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback");
const SHELL_BUTTON_ACTION = "open-terminal-shell";
const SHELL_BUTTON_ITEM = Object.freeze({
action: SHELL_BUTTON_ACTION,
path: "/pages/terminal/index",
icon: "/assets/icons/shell.svg",
isTab: false
});
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));
}
function prependShellItem(page, items) {
if (String(page || "").trim() === "terminal") {
return items;
}
return [SHELL_BUTTON_ITEM, ...items];
}
/**
* 全局底部工具条组件:
* 1. 复刻 Web 底栏语义:左侧返回,右侧按页面上下文展示图标按钮;
* 2. 图标顺序按 Figma 页面 annotation 固定,不做自动重排;
* 3. records 页面自身不展示 records 入口,避免重复高亮。
*/
Component({
properties: {
page: {
type: String,
value: ""
}
},
data: {
...buildSvgButtonPressData(),
canGoBack: false,
backIcon: "/assets/icons/back.svg",
backPressedIcon: "/assets/icons/back.svg",
backLabel: t("zh-Hans", "bottomNav.backText"),
textIconMode: false,
items: []
},
lifetimes: {
attached() {
this.syncCanGoBack();
this.syncItems();
this.localeUnsub = subscribeLocaleChange(() => {
this.syncItems();
});
this.syncConfigUnsub = subscribeSyncConfigApplied(() => {
this.syncCanGoBack();
this.syncItems();
});
},
detached() {
if (typeof this.localeUnsub === "function") {
this.localeUnsub();
this.localeUnsub = null;
}
if (typeof this.syncConfigUnsub === "function") {
this.syncConfigUnsub();
this.syncConfigUnsub = null;
}
}
},
pageLifetimes: {
show() {
this.syncCanGoBack();
this.syncItems();
}
},
observers: {
page() {
this.syncItems();
}
},
methods: {
currentPathByPage(page) {
const name = String(page || "").trim();
if (!name) return "";
if (name === "about" || name.indexOf("about-") === 0) {
return "/pages/about/index";
}
return `/pages/${name}/index`;
},
syncCanGoBack() {
const pages = getCurrentPages();
this.setData({ canGoBack: pages.length > 1 });
},
rawItemsByPage(page) {
const aboutItem = { path: "/pages/about/index", icon: "/assets/icons/about.svg", isTab: false };
if (page === "about" || String(page || "").indexOf("about-") === 0) {
return prependShellItem(page, [
{ path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false },
{ path: "/pages/logs/index", icon: "/assets/icons/log.svg", isTab: false },
{ path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false }
]);
}
if (page === "connect") {
return prependShellItem(page, [
{ path: "/pages/logs/index", icon: "/assets/icons/log.svg", isTab: false },
{
path: "/pages/records/index",
icon: "/assets/icons/recordmanager.svg",
isTab: false
},
{ path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false },
aboutItem
]);
}
if (page === "settings") {
return prependShellItem(page, [
{ path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false },
{ path: "/pages/logs/index", icon: "/assets/icons/log.svg", isTab: false },
{ path: "/pages/plugins/index", icon: "/assets/icons/plugins.svg", isTab: false },
{ path: "/pages/records/index", icon: "/assets/icons/recordmanager.svg", isTab: false },
aboutItem
]);
}
if (page === "plugins") {
return prependShellItem(page, [
{ path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false },
{
path: "/pages/records/index",
icon: "/assets/icons/recordmanager.svg",
isTab: false
},
{ path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false },
aboutItem
]);
}
if (page === "terminal") {
return [
{ path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false },
{ path: "/pages/logs/index", icon: "/assets/icons/log.svg", isTab: false },
{
path: "/pages/records/index",
icon: "/assets/icons/recordmanager.svg",
isTab: false
},
{ path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false },
aboutItem
];
}
if (page === "records") {
return prependShellItem(page, [
{ path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false },
{ path: "/pages/logs/index", icon: "/assets/icons/log.svg", isTab: false },
{ path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false },
aboutItem
]);
}
if (page === "logs") {
return prependShellItem(page, [
{ path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false },
{
path: "/pages/records/index",
icon: "/assets/icons/recordmanager.svg",
isTab: false
},
{ path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false },
aboutItem
]);
}
return prependShellItem(page, [
{ path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false },
{ path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false },
aboutItem
]);
},
syncItems() {
const page = this.data.page;
const settings = getSettings();
const language = normalizeUiLanguage(settings.uiLanguage);
const copy = buildPageCopy(language, "bottomNav");
const { icons: iconMap, activeIcons: activeIconMap, accentIcons: accentIconMap } =
buildButtonIconThemeMaps(settings);
const currentPath = this.currentPathByPage(page);
const sessionSnapshot = getTerminalSessionSnapshot();
const shellActive = hasActiveTerminalSession(sessionSnapshot, listServers());
const list = this.rawItemsByPage(page).map((item) => ({
...item,
id: String(item.action || item.path || item.icon || ""),
pressKey: `bottom-nav:${String(item.action || item.path || item.icon || "").trim()}`,
icon:
item.action === SHELL_BUTTON_ACTION && shellActive
? resolveButtonIcon(item.icon, activeIconMap)
: resolveButtonIcon(item.icon, iconMap),
pressedIcon: resolveButtonIcon(
item.icon,
item.action === SHELL_BUTTON_ACTION && shellActive ? activeIconMap : accentIconMap
),
textLabel: this.resolveTextLabel(item.action || item.path, copy),
active: item.action === SHELL_BUTTON_ACTION ? shellActive : item.path === currentPath,
connectionActive: item.action === SHELL_BUTTON_ACTION && shellActive
}));
const payload = {
backIcon: iconMap.back || "/assets/icons/back.svg",
backPressedIcon: accentIconMap.back || iconMap.back || "/assets/icons/back.svg",
backLabel: copy.backText || t(language, "bottomNav.backText"),
textIconMode: shouldUseTextIcons() && Object.keys(iconMap).length === 0,
items: list
};
logRenderProbeOnce("bottom-nav.syncItems", "bottom-nav.syncItems", payload);
this.setData(payload);
},
onBack() {
if (!this.data.canGoBack) return;
wx.navigateBack({
delta: 1,
fail: () => {
wx.redirectTo({ url: "/pages/connect/index" });
}
});
},
onNavTap(event) {
const action = String(event.currentTarget.dataset.action || "").trim();
if (action === SHELL_BUTTON_ACTION) {
this.onShellTap();
return;
}
const path = event.currentTarget.dataset.path;
if (!path) return;
const currentPath = this.currentPathByPage(this.data.page);
const method = resolvePageNavigationMethod(currentPath, path);
if (method === "noop") return;
if (method === "redirectTo") {
wx.redirectTo({
url: path,
fail: () => {
wx.navigateTo({ url: path });
}
});
return;
}
wx.navigateTo({ url: path });
},
onShellTap() {
const sessionSnapshot = getTerminalSessionSnapshot();
const serverId = resolveActiveTerminalServerId(sessionSnapshot, listServers());
const language = normalizeUiLanguage(getSettings().uiLanguage);
if (!serverId) {
wx.showModal({
title: t(language, "bottomNav.modal.noTerminalTitle"),
content: t(language, "bottomNav.modal.noTerminalContent"),
showCancel: false
});
return;
}
openTerminalPage(serverId, true);
},
resolveTextLabel(key, copyInput) {
const copy = copyInput && typeof copyInput === "object" ? copyInput : buildPageCopy("zh-Hans", "bottomNav");
const pageTextLabels = (copy && copy.pageTextLabels) || {};
const normalized = String(key || "").trim();
return pageTextLabels[normalized] || pageTextLabels.default || "页";
},
...createSvgButtonPressMethods()
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"styleIsolation": "apply-shared"
}

View File

@@ -0,0 +1,49 @@
<view class="bottom-bar">
<button
class="icon-btn bottom-nav-btn svg-press-btn {{!canGoBack ? 'is-disabled' : ''}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="bottom-nav:back"
disabled="{{!canGoBack}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onBack"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === 'bottom-nav:back' ? backPressedIcon : backIcon}}"
mode="aspectFit"
style="width:44rpx;height:44rpx;display:block;"
/>
<text wx:else class="bottom-nav-text">{{backLabel}}</text>
</button>
<view class="bottom-right-actions">
<block wx:for="{{items}}" wx:key="id">
<button
class="icon-btn bottom-nav-btn svg-press-btn {{item.active ? 'active' : ''}} {{item.connectionActive ? 'connection-active' : ''}}"
hover-class="svg-press-btn-hover"
hover-start-time="0"
hover-stay-time="80"
data-press-key="{{item.pressKey}}"
data-action="{{item.action}}"
data-path="{{item.path}}"
bindtouchstart="onSvgButtonTouchStart"
bindtouchend="onSvgButtonTouchEnd"
bindtouchcancel="onSvgButtonTouchEnd"
bindtap="onNavTap"
>
<image
wx:if="{{!textIconMode}}"
class="icon-img svg-press-icon"
src="{{pressedSvgButtonKey === item.pressKey ? item.pressedIcon : item.icon}}"
mode="aspectFit"
style="width:44rpx;height:44rpx;display:block;"
/>
<text wx:else class="bottom-nav-text">{{item.textLabel}}</text>
</button>
</block>
</view>
</view>

View File

@@ -0,0 +1,94 @@
.bottom-bar {
flex: 0 0 104rpx;
height: 104rpx;
background: var(--bg);
border-top: 1rpx solid var(--accent-divider);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 64rpx 0 32rpx;
}
.bottom-right-actions {
display: inline-flex;
align-items: center;
gap: 24rpx;
}
.icon-btn {
width: 48rpx !important;
height: 48rpx !important;
min-width: 0 !important;
margin: 0 !important;
border: 0 !important;
border-radius: 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;
opacity: 0.95;
}
.bottom-nav-btn {
border-radius: 999rpx !important;
--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.9;
--svg-press-icon-opacity: 0.96;
--svg-press-icon-active-opacity: 0.68;
--svg-press-icon-active-scale: 0.88;
}
.icon-btn::after {
border: none;
}
.icon-btn.is-disabled {
opacity: 0.45 !important;
}
.icon-btn.wx-button-disabled {
opacity: 0.45 !important;
}
.icon-img {
width: 44rpx;
height: 44rpx;
display: block;
}
.bottom-nav-text {
font-size: 20rpx;
line-height: 1;
font-weight: 600;
color: var(--btn-text);
}
.bottom-nav-btn.active {
background: var(--icon-btn-bg) !important;
background-color: var(--icon-btn-bg) !important;
box-shadow: inset 0 0 0 1rpx var(--accent-border);
}
.bottom-nav-btn.connection-active {
background: var(--accent) !important;
background-color: var(--accent) !important;
box-shadow: 0 10rpx 24rpx var(--accent-shadow) !important;
--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;
}
.bottom-nav-btn.connection-active .bottom-nav-text {
color: var(--text);
}