first commit
This commit is contained in:
369
terminal/apps/miniprogram/components/terminal-core-view/index.js
Normal file
369
terminal/apps/miniprogram/components/terminal-core-view/index.js
Normal 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 ≈ 20fps,setData 有限流)
|
||||
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);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
123
terminal/apps/miniprogram/utils/wxInputBridge.js
Normal file
123
terminal/apps/miniprogram/utils/wxInputBridge.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* WxInputBridge — 微信小程序 bindinput/bindconfirm 适配为 IInputSource。
|
||||
*
|
||||
* 使用方式(在 terminal-core-view/index.js 中):
|
||||
* const bridge = createWxInputBridge();
|
||||
* // 绑定到 InputBridge
|
||||
* const core_bridge = new InputBridge();
|
||||
* bridge.on('key', p => { const seq = core_bridge.mapKey(...); transport.send(seq); });
|
||||
* bridge.on('input', p => { if (!p.isComposing && p.data) transport.send(p.data); });
|
||||
* bridge.on('paste', p => { transport.send(core_bridge.mapPaste(p.text)); });
|
||||
*
|
||||
* 组件调用(WXML 事件 → 桥接):
|
||||
* Page/Component: {
|
||||
* onInput(e) { bridge.triggerInput(e.detail.value, e.detail.isComposing); },
|
||||
* onConfirm() { bridge.triggerConfirm(); },
|
||||
* onPaste(text){ bridge.triggerPaste(text); },
|
||||
* onFocus() { bridge.triggerFocus(); },
|
||||
* onBlur() { bridge.triggerBlur(); },
|
||||
* }
|
||||
*/
|
||||
|
||||
function createWxInputBridge() {
|
||||
const _listeners = Object.create(null);
|
||||
|
||||
function on(event, cb) {
|
||||
if (!_listeners[event]) _listeners[event] = [];
|
||||
_listeners[event].push(cb);
|
||||
return function off() {
|
||||
const arr = _listeners[event];
|
||||
if (!arr) return;
|
||||
const idx = arr.indexOf(cb);
|
||||
if (idx >= 0) arr.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
function _emit(event, payload) {
|
||||
const arr = _listeners[event];
|
||||
if (!arr) return;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
try { arr[i](payload); } catch(e) { /* isolation */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── WXML 事件驱动 API ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 由 WXML bindinput 调用。
|
||||
* 注意:小程序 bindinput 的 value 是整体输入框值,不是增量;
|
||||
* 调用方应提取增量(新增字符)后传入 data 参数。
|
||||
*/
|
||||
function triggerInput(data, isComposing) {
|
||||
_emit('input', {
|
||||
data: data != null ? String(data) : '',
|
||||
isComposing: !!isComposing,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 WXML bindconfirm 调用(用户点击软键盘"完成/回车")。
|
||||
*/
|
||||
function triggerConfirm() {
|
||||
_emit('key', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 由触控工具栏按键调用(箭头键、Tab、Ctrl+C 等)。
|
||||
* @param {string} key KeyboardEvent.key 等价值(如 "ArrowUp"、"c")
|
||||
* @param {string} code KeyboardEvent.code 等价值(可省略,默认与 key 相同)
|
||||
* @param {{ ctrlKey, altKey, shiftKey, metaKey }} mods 修饰键状态
|
||||
*/
|
||||
function triggerKey(key, code, mods) {
|
||||
const m = mods || {};
|
||||
_emit('key', {
|
||||
key: key,
|
||||
code: code || key,
|
||||
ctrlKey: !!m.ctrlKey,
|
||||
altKey: !!m.altKey,
|
||||
shiftKey: !!m.shiftKey,
|
||||
metaKey: !!m.metaKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 由粘贴操作触发(通过 wx.getClipboardData 读取后调用)。
|
||||
*/
|
||||
function triggerPaste(text) {
|
||||
_emit('paste', { text: String(text || '') });
|
||||
}
|
||||
|
||||
/**
|
||||
* IME 组合开始(候选词输入开始)。
|
||||
*/
|
||||
function triggerCompositionStart(data) {
|
||||
_emit('compositionstart', { data: String(data || '') });
|
||||
}
|
||||
|
||||
/**
|
||||
* IME 组合结束(候选词确认)。
|
||||
*/
|
||||
function triggerCompositionEnd(data) {
|
||||
_emit('compositionend', { data: String(data || '') });
|
||||
}
|
||||
|
||||
return {
|
||||
on: on,
|
||||
// WXML 事件驱动方法
|
||||
triggerInput: triggerInput,
|
||||
triggerConfirm: triggerConfirm,
|
||||
triggerKey: triggerKey,
|
||||
triggerPaste: triggerPaste,
|
||||
triggerCompositionStart: triggerCompositionStart,
|
||||
triggerCompositionEnd: triggerCompositionEnd,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWxInputBridge: createWxInputBridge };
|
||||
112
terminal/apps/miniprogram/utils/wxMeasureAdapter.js
Normal file
112
terminal/apps/miniprogram/utils/wxMeasureAdapter.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* WxMeasureAdapter — 微信小程序 wx.createSelectorQuery 实现 IMeasureAdapter。
|
||||
*
|
||||
* 由于小程序不支持 ResizeObserver,使用 BoundingClientRect 查询容器尺寸,
|
||||
* 并提供 `poll(intervalMs)` 方法做周期性尺寸检测(模拟 resize 通知)。
|
||||
*
|
||||
* 使用方式:
|
||||
* const m = createWxMeasureAdapter(component, '#tc-viewport', 14, 16.8);
|
||||
* m.onResize(() => {
|
||||
* const { widthPx, heightPx } = m.measureContainer();
|
||||
* const { widthPx: cw, heightPx: ch } = m.measureChar();
|
||||
* const cols = Math.max(20, Math.floor(widthPx / cw));
|
||||
* const rows = Math.max(8, Math.floor(heightPx / ch));
|
||||
* // ... resize terminal
|
||||
* });
|
||||
* m.startPoll(500);
|
||||
* // 在 component detached / onUnload 中:
|
||||
* m.stopPoll();
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {object} ctx - 小程序 Component 实例(this),用于 createSelectorQuery 作用域
|
||||
* @param {string} selector - CSS 选择器,如 '#tc-viewport'
|
||||
* @param {number} charWidthPx - 等宽字符宽度像素(由组件在初始化时测量或硬编码)
|
||||
* @param {number} charHeightPx - 行高像素
|
||||
*/
|
||||
function createWxMeasureAdapter(ctx, selector, charWidthPx, charHeightPx) {
|
||||
let _containerW = 0;
|
||||
let _containerH = 0;
|
||||
let _pollTimer = null;
|
||||
const _callbacks = [];
|
||||
|
||||
/** 实现 IMeasureAdapter.measureChar */
|
||||
function measureChar() {
|
||||
return {
|
||||
widthPx: charWidthPx || 8,
|
||||
heightPx: charHeightPx || 16,
|
||||
};
|
||||
}
|
||||
|
||||
/** 实现 IMeasureAdapter.measureContainer */
|
||||
function measureContainer() {
|
||||
return {
|
||||
widthPx: _containerW,
|
||||
heightPx: _containerH,
|
||||
};
|
||||
}
|
||||
|
||||
/** 实现 IMeasureAdapter.onResize */
|
||||
function onResize(cb) {
|
||||
_callbacks.push(cb);
|
||||
return function off() {
|
||||
const idx = _callbacks.indexOf(cb);
|
||||
if (idx >= 0) _callbacks.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** 主动刷新容器尺寸(异步) */
|
||||
function refresh(onDone) {
|
||||
const query = ctx.createSelectorQuery();
|
||||
query.select(selector).boundingClientRect(function(rect) {
|
||||
if (!rect) return;
|
||||
const newW = rect.width || 0;
|
||||
const newH = rect.height || 0;
|
||||
if (newW !== _containerW || newH !== _containerH) {
|
||||
_containerW = newW;
|
||||
_containerH = newH;
|
||||
for (let i = 0; i < _callbacks.length; i++) {
|
||||
try { _callbacks[i](); } catch(e) { /* isolation */ }
|
||||
}
|
||||
}
|
||||
if (typeof onDone === 'function') onDone({ widthPx: _containerW, heightPx: _containerH });
|
||||
}).exec();
|
||||
}
|
||||
|
||||
/** 启动周期性轮询(模拟 ResizeObserver)。intervalMs 建议 300~1000ms */
|
||||
function startPoll(intervalMs) {
|
||||
stopPoll();
|
||||
const ms = intervalMs || 500;
|
||||
// 立刻执行一次
|
||||
refresh();
|
||||
_pollTimer = setInterval(function() { refresh(); }, ms);
|
||||
}
|
||||
|
||||
/** 停止轮询 */
|
||||
function stopPoll() {
|
||||
if (_pollTimer !== null) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性测量(供初始化使用)。
|
||||
* @param {function} cb - (result: { widthPx, heightPx }) => void
|
||||
*/
|
||||
function measureOnce(cb) {
|
||||
refresh(cb);
|
||||
}
|
||||
|
||||
return {
|
||||
measureChar: measureChar,
|
||||
measureContainer: measureContainer,
|
||||
onResize: onResize,
|
||||
refresh: refresh,
|
||||
startPoll: startPoll,
|
||||
stopPoll: stopPoll,
|
||||
measureOnce: measureOnce,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWxMeasureAdapter: createWxMeasureAdapter };
|
||||
201
terminal/apps/miniprogram/utils/wxTransport.js
Normal file
201
terminal/apps/miniprogram/utils/wxTransport.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* WxTransport — 微信小程序 wx.connectSocket 实现的 TerminalTransport。
|
||||
*
|
||||
* 帧协议与 Web 端 GatewayTransport 完全一致(§8.2):
|
||||
* 入站:{ type: 'init'|'stdin'|'resize'|'control', ... }
|
||||
* 出站:{ type: 'stdout'|'stderr'|'control'|'error', ... }
|
||||
*
|
||||
* 使用方式:
|
||||
* const t = new WxTransport({ gatewayUrl, gatewayToken });
|
||||
* const off = t.on(event => { ... });
|
||||
* await t.connect({ host, port, username, password });
|
||||
* t.send("ls -la\r");
|
||||
* t.resize(80, 24);
|
||||
* t.disconnect();
|
||||
*/
|
||||
|
||||
const { sanitizeTerminalOutput } = require('../../../packages/terminal-core/dist-miniprogram/index');
|
||||
|
||||
function buildWsUrl(rawUrl, token) {
|
||||
let base = String(rawUrl || '').trim();
|
||||
if (!base) throw new Error('WxTransport: 网关地址为空');
|
||||
if (base.startsWith('http://')) base = 'ws://' + base.slice(7);
|
||||
if (base.startsWith('https://')) base = 'wss://' + base.slice(8);
|
||||
if (!base.startsWith('ws://') && !base.startsWith('wss://')) {
|
||||
base = 'wss://' + base;
|
||||
}
|
||||
base = base.replace(/\/+$/, '');
|
||||
return base + '/ws/terminal?token=' + encodeURIComponent(String(token || ''));
|
||||
}
|
||||
|
||||
function WxTransport(options) {
|
||||
const cfg = options || {};
|
||||
this._gatewayUrl = cfg.gatewayUrl || '';
|
||||
this._gatewayToken = cfg.gatewayToken || '';
|
||||
|
||||
this._state = 'idle'; // SessionState
|
||||
this._sessionId = '';
|
||||
this._socket = null; // wx.SocketTask
|
||||
this._listeners = []; // Array<(event) => void>
|
||||
this._pingTimer = null;
|
||||
this._pingAt = 0;
|
||||
}
|
||||
|
||||
// ── 接口实现 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 连接网关并发送 init 帧。
|
||||
* 返回 Promise,在 control.connected 帧到达后 resolve,错误时 reject。
|
||||
*/
|
||||
WxTransport.prototype.connect = function(params) {
|
||||
const self = this;
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (self._state === 'connecting' || self._state === 'connected') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
self._state = 'connecting';
|
||||
const sessionId = 'wx_' + Date.now() + '_' + Math.random().toString(36).slice(2);
|
||||
self._sessionId = sessionId;
|
||||
const url = buildWsUrl(self._gatewayUrl, self._gatewayToken);
|
||||
|
||||
const task = wx.connectSocket({
|
||||
url: url,
|
||||
protocols: ['v1.terminal'],
|
||||
success: function() { /* socket created */ },
|
||||
fail: function(err) {
|
||||
self._state = 'error';
|
||||
self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) });
|
||||
reject(new Error('wx.connectSocket fail: ' + JSON.stringify(err)));
|
||||
}
|
||||
});
|
||||
|
||||
self._socket = task;
|
||||
let resolved = false;
|
||||
|
||||
task.onOpen(function() {
|
||||
// 发送 init 帧
|
||||
self._sendFrame({ type: 'init', sessionId: sessionId, params: params });
|
||||
// 启动心跳
|
||||
self._startPing();
|
||||
});
|
||||
|
||||
task.onMessage(function(res) {
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.parse(res.data);
|
||||
} catch(e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'stdout' || frame.type === 'stderr') {
|
||||
const raw = typeof frame.data === 'string' ? frame.data : '';
|
||||
const sanitized = sanitizeTerminalOutput(raw);
|
||||
if (sanitized) self._emit({ type: frame.type, data: sanitized });
|
||||
} else if (frame.type === 'control') {
|
||||
if (frame.action === 'connected') {
|
||||
self._state = 'connected';
|
||||
self._emit({ type: 'control', action: 'connected', data: frame.data });
|
||||
if (!resolved) { resolved = true; resolve(); }
|
||||
} else if (frame.action === 'disconnect') {
|
||||
self._cleanup();
|
||||
self._state = 'disconnected';
|
||||
self._emit({ type: 'control', action: 'disconnect' });
|
||||
} else if (frame.action === 'pong') {
|
||||
self._emit({ type: 'control', action: 'pong' });
|
||||
}
|
||||
} else if (frame.type === 'error') {
|
||||
self._state = 'error';
|
||||
self._emit({ type: 'error', code: frame.code || 'INTERNAL_ERROR', message: frame.message || '' });
|
||||
if (!resolved) { resolved = true; reject(new Error(frame.message)); }
|
||||
}
|
||||
});
|
||||
|
||||
task.onError(function(err) {
|
||||
self._cleanup();
|
||||
self._state = 'error';
|
||||
self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) });
|
||||
if (!resolved) { resolved = true; reject(new Error(JSON.stringify(err))); }
|
||||
});
|
||||
|
||||
task.onClose(function() {
|
||||
self._cleanup();
|
||||
if (self._state !== 'disconnected' && self._state !== 'error') {
|
||||
self._state = 'disconnected';
|
||||
self._emit({ type: 'control', action: 'disconnect' });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
WxTransport.prototype.send = function(data, meta) {
|
||||
if (this._state !== 'connected') return;
|
||||
this._sendFrame({ type: 'stdin', data: data, meta: meta });
|
||||
};
|
||||
|
||||
WxTransport.prototype.resize = function(cols, rows) {
|
||||
if (this._state !== 'connected') return;
|
||||
this._sendFrame({ type: 'resize', cols: cols, rows: rows });
|
||||
};
|
||||
|
||||
WxTransport.prototype.disconnect = function(reason) {
|
||||
this._sendFrame({ type: 'control', action: 'disconnect', reason: reason || 'manual' });
|
||||
this._cleanup();
|
||||
this._state = 'disconnected';
|
||||
};
|
||||
|
||||
WxTransport.prototype.on = function(listener) {
|
||||
const self = this;
|
||||
self._listeners.push(listener);
|
||||
return function() {
|
||||
const idx = self._listeners.indexOf(listener);
|
||||
if (idx >= 0) self._listeners.splice(idx, 1);
|
||||
};
|
||||
};
|
||||
|
||||
WxTransport.prototype.getState = function() {
|
||||
return this._state;
|
||||
};
|
||||
|
||||
// ── 私有方法 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
WxTransport.prototype._emit = function(event) {
|
||||
for (let i = 0; i < this._listeners.length; i++) {
|
||||
try { this._listeners[i](event); } catch(e) { /* 单个监听器异常不影响其他 */ }
|
||||
}
|
||||
};
|
||||
|
||||
WxTransport.prototype._sendFrame = function(frame) {
|
||||
if (!this._socket) return;
|
||||
try {
|
||||
this._socket.send({ data: JSON.stringify(frame) });
|
||||
} catch(e) { /* 忽略发送时 socket 已关闭 */ }
|
||||
};
|
||||
|
||||
WxTransport.prototype._startPing = function() {
|
||||
const self = this;
|
||||
self._clearPing();
|
||||
self._pingTimer = setInterval(function() {
|
||||
if (self._state !== 'connected') return;
|
||||
self._pingAt = Date.now();
|
||||
self._sendFrame({ type: 'control', action: 'ping' });
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
WxTransport.prototype._clearPing = function() {
|
||||
if (this._pingTimer !== null) {
|
||||
clearInterval(this._pingTimer);
|
||||
this._pingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
WxTransport.prototype._cleanup = function() {
|
||||
this._clearPing();
|
||||
if (this._socket) {
|
||||
try { this._socket.close(); } catch(e) { /* ignore */ }
|
||||
this._socket = null;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { WxTransport: WxTransport };
|
||||
Reference in New Issue
Block a user