first commit

This commit is contained in:
douboer@gmail.com
2026-03-03 13:23:14 +08:00
commit 3b7c1d558a
161 changed files with 28120 additions and 0 deletions

View File

@@ -0,0 +1,369 @@
/**
* terminal-core-view/index.js
*
* 微信小程序终端渲染组件。
* 消费 @remoteconn/terminal-core 构建产物dist-miniprogram/index.js
* 通过 setData({rows, cursor}) 驱动 rich-text 渲染。
*
* 外部属性properties
* - gatewayUrl {string} 网关 WebSocket 地址
* - gatewayToken {string} 网关鉴权 Token
*
* 外部方法(通过组件引用调用):
* - connect(params) 开始连接params: { host, port, username, password? }
* - disconnect(reason?) 断开连接
* - sendInput(data) 发送输入
*/
const path = '../../../../packages/terminal-core/dist-miniprogram/index';
let TerminalCore, InputBridge, OutputBuffer, SessionMachine, sanitizeTerminalOutput, calcSize;
try {
const core = require(path);
TerminalCore = core.TerminalCore;
InputBridge = core.InputBridge;
OutputBuffer = core.OutputBuffer;
SessionMachine = core.SessionMachine;
sanitizeTerminalOutput = core.sanitizeTerminalOutput;
calcSize = core.calcSize;
} catch(e) {
console.error('[terminal-core-view] 无法加载 terminal-core请先执行 build:miniprogram', e);
}
const { WxTransport } = require('../../utils/wxTransport');
const { createWxInputBridge } = require('../../utils/wxInputBridge');
const { createWxMeasureAdapter } = require('../../utils/wxMeasureAdapter');
// ── 颜色渲染 ──────────────────────────────────────────────────────────────────
const ANSI16 = [
'#000000','#cc0000','#00aa00','#aaaa00',
'#0000ee','#cc00cc','#00aaaa','#aaaaaa',
'#555555','#ff5555','#55ff55','#ffff55',
'#5555ff','#ff55ff','#55ffff','#ffffff',
];
function p256ToCss(idx) {
if (idx < 16) return ANSI16[idx] || '#000';
if (idx >= 232) {
const v = 8 + (idx - 232) * 10;
return 'rgb(' + v + ',' + v + ',' + v + ')';
}
const i = idx - 16;
const b = i % 6, g = Math.floor(i / 6) % 6, r = Math.floor(i / 36);
const ch = function(x) { return x === 0 ? 0 : 55 + x * 40; };
return 'rgb(' + ch(r) + ',' + ch(g) + ',' + ch(b) + ')';
}
function colorToCss(c, isFg) {
if (!c) return isFg ? 'inherit' : 'transparent';
switch (c.mode) {
case 'default': return isFg ? 'inherit' : 'transparent';
case 'p16': return ANSI16[c.value] || 'inherit';
case 'p256': return p256ToCss(c.value);
case 'rgb': return '#' + ((c.value & 0xffffff) | 0x1000000).toString(16).slice(1);
default: return 'inherit';
}
}
const FLAG_BOLD = 1;
const FLAG_DIM = 2;
const FLAG_ITALIC = 4;
const FLAG_UNDERLINE = 8;
const FLAG_INVERSE = 16;
const FLAG_INVISIBLE = 32;
const FLAG_STRIKETHROUGH = 64;
const FLAG_OVERLINE = 128;
/** 将一行 cells 转换为 rich-text 节点数组 */
function lineToNodes(cells) {
const nodes = [];
let i = 0;
while (i < cells.length) {
const cell = cells[i];
if (!cell) { i++; continue; }
const flags = cell.flags || 0;
const inverse = !!(flags & FLAG_INVERSE);
let fg = cell.fg, bg = cell.bg;
if (inverse) { const tmp = fg; fg = bg; bg = tmp; }
let style = '';
const fgCss = colorToCss(fg, true);
const bgCss = colorToCss(bg, false);
if (fgCss !== 'inherit') style += 'color:' + fgCss + ';';
if (bgCss !== 'transparent') style += 'background:' + bgCss + ';';
if (flags & FLAG_BOLD) style += 'font-weight:bold;';
if (flags & FLAG_DIM) style += 'opacity:0.6;';
if (flags & FLAG_ITALIC) style += 'font-style:italic;';
if (flags & FLAG_UNDERLINE) style += 'text-decoration:underline;';
if (flags & FLAG_STRIKETHROUGH) style += 'text-decoration:line-through;';
if (flags & FLAG_INVISIBLE) style += 'color:transparent;';
// 合并相邻相同 style 的 cells
let text = cell.char || ' ';
i++;
while (i < cells.length) {
const next = cells[i];
if (!next) break;
const nFlags = next.flags || 0;
// 简单比较:只有标志和颜色模式全相同才合并
if (nFlags !== flags ||
JSON.stringify(next.fg) !== JSON.stringify(fg) ||
JSON.stringify(next.bg) !== JSON.stringify(bg)) break;
text += next.char || ' ';
i++;
}
nodes.push({
type: 'node',
name: 'span',
attrs: style ? { style: style } : {},
children: [{ type: 'text', text: text }],
});
}
return nodes;
}
// ── 组件定义 ──────────────────────────────────────────────────────────────────
Component({
properties: {
gatewayUrl: { type: String, value: '' },
gatewayToken: { type: String, value: '' },
},
data: {
rows: [], // Array<{ html: Array }>
cursor: { row: -1, left: 0, visible: false },
scrollTop: 9999999, // 滚到底
state: 'idle',
latencyMs: 0,
title: '',
inputValue: '',
},
lifetimes: {
attached: function() {
this._init();
},
detached: function() {
this._dispose();
},
},
methods: {
// ── 初始化 ───────────────────────────────────────────────────────────────
_init: function() {
const self = this;
// Core
self._core = TerminalCore ? new TerminalCore(80, 24) : null;
self._buffer = OutputBuffer ? new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 }) : null;
self._machine = SessionMachine ? new SessionMachine() : null;
self._inputBridge = InputBridge ? new InputBridge() : null;
self._wxBridge = createWxInputBridge();
self._transport = null;
self._offTransport = null;
self._lastRevision = -1;
self._charW = 7.8; // 默认字符宽度 px (monospace 13px 近似)
self._lineH = 16.25; // 行高 px (13px * 1.25)
// 订阅 core 事件
if (self._core) {
self._core.on('bell', function() { wx.vibrateShort({ type: 'light' }); });
self._core.on('titleChange', function(t) {
self.setData({ title: t });
});
}
// 测量适配器
self._measure = createWxMeasureAdapter(self, '#tc-viewport', self._charW, self._lineH);
self._measure.onResize(function() {
self._doFit();
});
// 输入桥接事件订阅
self._wxBridge.on('key', function(p) {
if (!self._inputBridge) return;
const seq = self._inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey);
if (seq !== null) self.sendInput(seq);
});
self._wxBridge.on('input', function(p) {
if (p.isComposing) return;
if (p.data) self.sendInput(p.data);
self.setData({ inputValue: '' });
});
self._wxBridge.on('paste', function(p) {
if (!self._inputBridge) return;
const seq = self._inputBridge.mapPaste(p.text);
self.sendInput(seq);
});
self._wxBridge.on('compositionend', function(p) {
if (p.data) self.sendInput(p.data);
self.setData({ inputValue: '' });
});
// 启动渲染循环50ms ≈ 20fpssetData 有限流)
self._renderTimer = setInterval(function() { self._renderFrame(); }, 50);
// 启动测量轮询
self._measure.startPoll(600);
},
_dispose: function() {
if (this._renderTimer) { clearInterval(this._renderTimer); this._renderTimer = null; }
this._measure && this._measure.stopPoll();
this._offTransport && this._offTransport();
this._transport && this._transport._cleanup && this._transport._cleanup();
},
// ── 尺寸适配 ─────────────────────────────────────────────────────────────
_doFit: function() {
if (!calcSize || !this._core) return;
const { widthPx: contW, heightPx: contH } = this._measure.measureContainer();
if (contW < 10 || contH < 10) return;
const { widthPx: charW, heightPx: lineH } = this._measure.measureChar();
const { cols, rows } = calcSize(contW, contH, charW, lineH);
this._core.resize(cols, rows);
this._charW = charW;
if (this._transport && this.data.state === 'connected') {
this._transport.resize(cols, rows);
}
},
// ── 渲染帧 ───────────────────────────────────────────────────────────────
_renderFrame: function() {
if (!this._core) return;
const snap = this._core.snapshot();
if (snap.revision === this._lastRevision) return;
this._lastRevision = snap.revision;
const rows = snap.lines.map(function(line) {
return { html: lineToNodes(line.cells) };
});
// 光标位置(像素)
const cursorLeft = snap.cursor.x * this._charW;
const cursorRow = snap.cursor.y; // 相对可视区行号
this.setData({
rows: rows,
cursor: { row: cursorRow, left: cursorLeft, visible: snap.cursor.visible },
title: snap.title || this.data.title,
});
},
// ── 公开 API ──────────────────────────────────────────────────────────────
connect: function(params) {
const self = this;
if (!WxTransport) { console.error('[tc-view] WxTransport 未加载'); return; }
self._transport = new WxTransport({
gatewayUrl: self.properties.gatewayUrl,
gatewayToken: self.properties.gatewayToken,
});
self._offTransport = self._transport.on(function(event) {
if (event.type === 'stdout' || event.type === 'stderr') {
const sanitized = sanitizeTerminalOutput ? sanitizeTerminalOutput(event.data) : event.data;
if (sanitized) {
self._buffer && self._buffer.push(sanitized);
self._core && self._core.write(sanitized);
}
} else if (event.type === 'control') {
if (event.action === 'connected') {
self._machine && self._machine.tryTransition('connected');
self.setData({ state: 'connected', latencyMs: 0 });
self._doFit();
} else if (event.action === 'pong') {
// latency 由 WxTransport 内部 _pingAt 计算
} else if (event.action === 'disconnect') {
self._machine && self._machine.tryTransition('disconnected');
self.setData({ state: 'disconnected' });
}
} else if (event.type === 'error') {
self._machine && self._machine.tryTransition('error');
self.setData({ state: 'error' });
}
});
self._machine && self._machine.tryTransition('connecting');
self.setData({ state: 'connecting' });
self._transport.connect(params).catch(function(err) {
console.error('[tc-view] connect error', err);
self.setData({ state: 'error' });
});
},
disconnect: function(reason) {
if (this._transport) {
this._transport.disconnect(reason || 'manual');
}
this._machine && this._machine.tryTransition('disconnected');
this.setData({ state: 'disconnected' });
},
sendInput: function(data) {
if (this.data.state !== 'connected' || !this._transport) return;
this._transport.send(data);
},
// ── WXML 事件处理 ─────────────────────────────────────────────────────────
onInput: function(e) {
const value = e.detail.value || '';
const prev = this._prevInputValue || '';
// 计算增量字符
if (value.length > prev.length) {
const delta = value.slice(prev.length);
this._wxBridge.triggerInput(delta, e.detail.isComposing);
}
this._prevInputValue = value;
},
onConfirm: function() {
this._prevInputValue = '';
this._wxBridge.triggerConfirm();
},
onSend: function() {
const val = this.data.inputValue;
if (val) {
this._wxBridge.triggerInput(val, false);
this._wxBridge.triggerConfirm();
this.setData({ inputValue: '' });
this._prevInputValue = '';
}
},
onInputBlur: function() { /* no-op */ },
onKeyboardChange: function() { /* no-op */ },
// ── 滚动事件 ──────────────────────────────────────────────────────────────
onScrollToLower: function() {
this._autoScroll = true;
},
onScrollToUpper: function() {
this._autoScroll = false;
},
onScroll: function() { /* handled by upper/lower events */ },
// ── 触控工具栏 ────────────────────────────────────────────────────────────
onTapUp: function() { this._wxBridge.triggerKey('ArrowUp', 'ArrowUp'); },
onTapDown: function() { this._wxBridge.triggerKey('ArrowDown', 'ArrowDown'); },
onTapLeft: function() { this._wxBridge.triggerKey('ArrowLeft', 'ArrowLeft'); },
onTapRight: function() { this._wxBridge.triggerKey('ArrowRight', 'ArrowRight'); },
onTapTab: function() { this._wxBridge.triggerKey('Tab', 'Tab'); },
onTapEnter: function() { this._wxBridge.triggerConfirm(); },
onTapCtrlC: function() { this.sendInput('\u0003'); },
onTapCtrlD: function() { this.sendInput('\u0004'); },
onTapEsc: function() { this.sendInput('\x1b'); },
onTapPaste: function() {
const self = this;
wx.getClipboardData({
success: function(res) {
if (res.data) self._wxBridge.triggerPaste(res.data);
},
});
},
},
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,69 @@
<!--
terminal-core-view — 微信小程序终端渲染组件。
消费 TerminalCore.snapshot() 通过 setData 驱动 rich-text 渲染。
-->
<view class="tc-root" id="tc-root">
<!-- 终端输出区 -->
<scroll-view
class="tc-viewport"
id="tc-viewport"
scroll-y
scroll-top="{{scrollTop}}"
bindscrolltoupper="onScrollToUpper"
bindscrolltolower="onScrollToLower"
bindscroll="onScroll"
>
<view class="tc-lines">
<block wx:for="{{rows}}" wx:key="index">
<view class="tc-row" style="position: relative;">
<!-- 光标行叠加 -->
<view
wx:if="{{cursor.visible && cursor.row === index}}"
class="tc-cursor"
style="left: {{cursor.left}}px;"
></view>
<rich-text class="tc-rich-row" nodes="{{item.html}}"></rich-text>
</view>
</block>
</view>
</scroll-view>
<!-- 触控辅助工具栏 -->
<view class="tc-touch-bar">
<button class="tc-key" bindtap="onTapUp" size="mini">▲</button>
<button class="tc-key" bindtap="onTapDown" size="mini">▼</button>
<button class="tc-key" bindtap="onTapLeft" size="mini">◀</button>
<button class="tc-key" bindtap="onTapRight" size="mini">▶</button>
<button class="tc-key" bindtap="onTapTab" size="mini">Tab</button>
<button class="tc-key tc-key-enter" bindtap="onTapEnter" size="mini">↵</button>
<button class="tc-key" bindtap="onTapCtrlC" size="mini">^C</button>
<button class="tc-key" bindtap="onTapCtrlD" size="mini">^D</button>
<button class="tc-key" bindtap="onTapEsc" size="mini">Esc</button>
<button class="tc-key" bindtap="onTapPaste" size="mini">粘贴</button>
</view>
<!-- 输入栏 -->
<view class="tc-input-bar">
<input
class="tc-input"
type="text"
confirm-type="send"
placeholder="输入命令…"
value="{{inputValue}}"
bindinput="onInput"
bindconfirm="onConfirm"
bindblur="onInputBlur"
bindkeyboardheightchange="onKeyboardChange"
cursor-spacing="20"
adjust-position
/>
<button class="tc-send-btn" size="mini" bindtap="onSend">发</button>
</view>
<!-- 状态栏 -->
<view class="tc-status-bar">
<text class="tc-state-chip tc-state-{{state}}">{{state}}</text>
<text wx:if="{{latencyMs > 0}}" class="tc-state-chip">{{latencyMs}}ms</text>
<text wx:if="{{title}}" class="tc-title">{{title}}</text>
</view>
</view>

View File

@@ -0,0 +1,154 @@
/* terminal-core-view/index.wxss */
.tc-root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: #1a1a1a;
color: #d4d4d4;
box-sizing: border-box;
overflow: hidden;
}
/* 终端输出区 */
.tc-viewport {
flex: 1;
min-height: 0;
overflow: hidden;
}
.tc-lines {
padding: 2px 4px;
}
.tc-row {
position: relative;
white-space: pre;
}
.tc-rich-row {
display: block;
font-family: "Courier New", "Menlo", monospace;
font-size: 13px;
line-height: 1.25;
white-space: pre;
}
/* 光标 */
.tc-cursor {
position: absolute;
top: 0;
width: 8px;
height: 1.25em;
background: rgba(255, 255, 255, 0.75);
pointer-events: none;
z-index: 1;
}
/* 触控工具栏 */
.tc-touch-bar {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow-x: scroll;
padding: 4px 6px;
background: #252525;
border-top: 1rpx solid #333;
gap: 4px;
-webkit-overflow-scrolling: touch;
}
.tc-key {
flex-shrink: 0;
min-width: 44px;
font-size: 12px;
font-family: "Courier New", monospace;
background: #2e2e2e;
color: #ccc;
border: 1rpx solid #444;
border-radius: 4px;
padding: 0 8px;
height: 32px;
line-height: 32px;
}
.tc-key-enter {
background: #1a3a5c;
border-color: #4a9eff;
color: #4a9eff;
}
/* 输入栏 */
.tc-input-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 5px 8px;
background: #1e1e1e;
border-top: 1rpx solid #333;
}
.tc-input {
flex: 1;
min-width: 0;
height: 36px;
border-radius: 6px;
border: 1rpx solid #444;
background: #2a2a2a;
color: #d4d4d4;
font-size: 13px;
font-family: "Courier New", monospace;
padding: 0 10px;
}
.tc-send-btn {
flex-shrink: 0;
width: 44px;
height: 36px;
background: #4a9eff;
color: #fff;
font-size: 13px;
font-weight: 600;
border-radius: 6px;
border: none;
line-height: 36px;
text-align: center;
padding: 0;
}
/* 状态栏 */
.tc-status-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 3px 8px;
background: #141414;
border-top: 1rpx solid #2a2a2a;
font-size: 11px;
}
.tc-state-chip {
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
background: #2a2a2a;
color: #888;
}
.tc-state-chip.tc-state-connected { background: #1a3a1a; color: #4caf50; }
.tc-state-chip.tc-state-connecting { background: #2a2a00; color: #ffc107; }
.tc-state-chip.tc-state-auth_pending { background: #2a2a00; color: #ffc107; }
.tc-state-chip.tc-state-error { background: #3a1a1a; color: #f44336; }
.tc-state-chip.tc-state-disconnected { background: #2a2a2a; color: #666; }
.tc-title {
flex: 1;
font-size: 11px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}