修复了一些错误 更新了AI.md(给ai看的)
This commit is contained in:
@@ -1,39 +1,21 @@
|
||||
"""TUI 核心模块 - 强大的 WebUI 到终端界面转换引擎 v1.3
|
||||
|
||||
本模块提供完整的 HTML/CSS/JS 到 TUI 的转换能力,参考 opencode 风格设计:
|
||||
- HTML 解析:识别 data-tui-* 标记、语义化标签、Aria 属性,转换为 40+ 种终端元素
|
||||
- CSS 转换:支持 ANSI 256 色、真彩色、完整字体排版、边框样式、阴影效果
|
||||
- JS 交互:完整模拟鼠标追踪、点击事件、键盘绑定、DOM 操作、事件系统
|
||||
- 布局引擎:flex/grid/absolute 布局终端适配,自动响应式调整
|
||||
- 组件系统:40+ 种组件(按钮、面板、列表、表单、表格、进度条、图表等)
|
||||
- 高级特性:动画系统、主题系统、虚拟滚动、焦点管理、辅助功能
|
||||
|
||||
架构设计完全参考 opencode 风格,提供现代化、高性能终端体验。
|
||||
"""
|
||||
|
||||
from .converter import (
|
||||
# 管理器
|
||||
TUIManager,
|
||||
TUIRenderer,
|
||||
HTMLToTUIConverter,
|
||||
|
||||
# 输入处理
|
||||
TUIInputHandler,
|
||||
TUIEventManager,
|
||||
|
||||
# 画布
|
||||
TUICanvas,
|
||||
|
||||
# 样式系统
|
||||
ANSIStyle,
|
||||
BorderStyle,
|
||||
TUIColor,
|
||||
TUIStyle,
|
||||
|
||||
# 元素类型
|
||||
TUIElementType,
|
||||
|
||||
# 基础元素
|
||||
TUIElement,
|
||||
TUIButton,
|
||||
TUILabel,
|
||||
@@ -46,28 +28,22 @@ from .converter import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 管理器
|
||||
'TUIManager',
|
||||
'TUIRenderer',
|
||||
'HTMLToTUIConverter',
|
||||
|
||||
# 输入处理
|
||||
'TUIInputHandler',
|
||||
'TUIEventManager',
|
||||
|
||||
# 画布
|
||||
'TUICanvas',
|
||||
|
||||
# 样式系统
|
||||
'ANSIStyle',
|
||||
'BorderStyle',
|
||||
'TUIColor',
|
||||
'TUIStyle',
|
||||
|
||||
# 元素类型
|
||||
'TUIElementType',
|
||||
|
||||
# 基础元素
|
||||
'TUIElement',
|
||||
'TUIButton',
|
||||
'TUILabel',
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
"""TUI 客户端 - 前后端分离的 TUI 前端
|
||||
|
||||
通过 HTTP 连接后端 nebula serve,消费 JSON API,
|
||||
直接使用 ANSI 转义码绘制专业终端界面。
|
||||
支持鼠标点击导航。
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
@@ -18,7 +12,6 @@ import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── ANSI 工具 ────────────────────────────────────────────
|
||||
|
||||
def fg(r, g, b): return f"\x1b[38;2;{r};{g};{b}m"
|
||||
def bg(r, g, b): return f"\x1b[48;2;{r};{g};{b}m"
|
||||
@@ -39,12 +32,10 @@ C = {
|
||||
"bar_bg": (50, 50, 70),
|
||||
}
|
||||
|
||||
# ── 鼠标转义 ────────────────────────────────────────────
|
||||
|
||||
_MOUSE_ON = "\x1b[?1000h\x1b[?1002h\x1b[?1006h"
|
||||
_MOUSE_OFF = "\x1b[?1006l\x1b[?1002l\x1b[?1000l"
|
||||
|
||||
# ── HTTP 请求 ────────────────────────────────────────────
|
||||
|
||||
def http_get(url: str, timeout=5) -> Optional[str]:
|
||||
try:
|
||||
@@ -64,7 +55,6 @@ def backend_alive(host="127.0.0.1", port=8080) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ── 布局工具 ────────────────────────────────────────────
|
||||
|
||||
def term_size():
|
||||
return shutil.get_terminal_size((80, 24))
|
||||
@@ -77,10 +67,8 @@ def hbar(width: int, percent: float, color_fg=(0, 255, 135), color_bg=(50, 50, 7
|
||||
return bar
|
||||
|
||||
|
||||
# ── TUI 客户端 ──────────────────────────────────────────
|
||||
|
||||
Page = dict # {"id": str, "label": str, "desc": str}
|
||||
|
||||
Page = dict
|
||||
|
||||
class TUIClient:
|
||||
_resize_flag = False
|
||||
@@ -108,7 +96,6 @@ class TUIClient:
|
||||
self._stats_cache = {}
|
||||
self._stats_time = 0
|
||||
|
||||
# 鼠标点击区域: list of (y, page_id)
|
||||
self._click_zones: list[tuple[int, str]] = []
|
||||
|
||||
def _fetch_stats(self) -> dict:
|
||||
@@ -124,368 +111,6 @@ class TUIClient:
|
||||
pass
|
||||
return self._stats_cache
|
||||
|
||||
# ── 鼠标事件 ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_sgr_mouse(data: str):
|
||||
"""解析 SGR 鼠标事件 \x1b[<button;x;y;M/m → (button, x, y)"""
|
||||
m = re.match(r"^\x1b\[<(\d+);(\d+);(\d+)([Mm])$", data)
|
||||
if m:
|
||||
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
return None
|
||||
|
||||
# ── 屏幕渲染 ──────────────────────────────────────────
|
||||
|
||||
def _draw_header(self):
|
||||
w = self.width
|
||||
alive = backend_alive(self.host, self.port)
|
||||
status_icon = "●" if alive else "○"
|
||||
status_color = C["green"] if alive else C["red"]
|
||||
|
||||
print(bg(*C["header_bg"]), end="")
|
||||
print(" " * w, end="")
|
||||
print(f"\r{bg(*C['header_bg'])} ", end="")
|
||||
|
||||
title = " NebulaShell TUI "
|
||||
print(fg(*C["accent"]) + bold(title) + rst(), end="")
|
||||
print(bg(*C["header_bg"]), end="")
|
||||
|
||||
right = f" {fg(*status_color)}{status_icon}{rst()}{bg(*C['header_bg'])} {fg(*C['dim'])}{self.host}:{self.port}{rst()}"
|
||||
print(f"\x1b[{w - len(right) + 1}G{right}", end="")
|
||||
print(rst())
|
||||
|
||||
def _draw_status_bar(self):
|
||||
w = self.width
|
||||
print(bg(*C["status_bg"]), end="")
|
||||
print(" " * w, end="")
|
||||
print(f"\r{bg(*C['status_bg'])} ", end="")
|
||||
|
||||
nav_hint = f"{fg(*C['dim'])}数字/点击导航 q 退出 r 刷新{rst()}"
|
||||
page_name = self.current_page.upper()
|
||||
page_info = f"{fg(*C['cyan'])}{page_name}{rst()}"
|
||||
print(f"\r{bg(*C['status_bg'])} {page_info}", end="")
|
||||
print(f"\x1b[{w - len(nav_hint) + 1}G{nav_hint}", end="")
|
||||
print(rst())
|
||||
|
||||
def _clear(self):
|
||||
print("\x1b[2J\x1b[H", end="")
|
||||
|
||||
def _render_all(self):
|
||||
self._click_zones.clear()
|
||||
self._clear()
|
||||
self._draw_header()
|
||||
print()
|
||||
content_top = 2 # header(1) + blank(1) = 2
|
||||
|
||||
alive = backend_alive(self.host, self.port)
|
||||
if self.current_page == "welcome":
|
||||
self._render_welcome(alive, content_top)
|
||||
elif self.current_page == "dashboard":
|
||||
self._render_dashboard()
|
||||
elif self.current_page == "logs":
|
||||
self._render_logs()
|
||||
elif self.current_page == "terminal":
|
||||
self._render_terminal()
|
||||
elif self.current_page == "plugins":
|
||||
self._render_plugins()
|
||||
else:
|
||||
self._render_home()
|
||||
|
||||
used = self._content_lines + 4
|
||||
for _ in range(self.height - used):
|
||||
print()
|
||||
self._draw_status_bar()
|
||||
sys.stdout.flush()
|
||||
|
||||
# ── 页面内容 ──────────────────────────────────────────
|
||||
|
||||
def _render_welcome(self, alive: bool, top: int):
|
||||
w = self.width
|
||||
self._content_lines = 0
|
||||
|
||||
logo = [
|
||||
"███╗ ██╗███████╗██████╗ ██╗ ██╗██╗ █████╗ ",
|
||||
"████╗ ██║██╔════╝██╔══██╗██║ ██║██║ ██╔══██╗",
|
||||
"██╔██╗ ██║█████╗ ██████╔╝██║ ██║██║ ███████║",
|
||||
"██║╚██╗██║██╔══╝ ██╔══██╗██║ ██║██║ ██╔══██║",
|
||||
"██║ ╚████║███████╗██████╔╝╚██████╔╝███████╗██║ ██║",
|
||||
"╚═╝ ╚═══╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝",
|
||||
]
|
||||
for line in logo:
|
||||
print(" " + fg(*C["accent"]) + line + rst())
|
||||
self._content_lines += 1
|
||||
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
print(f" {fg(*C['dim'])}一切皆为插件的开发者工具运行时框架{rst()}")
|
||||
self._content_lines += 1
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
if alive:
|
||||
print(f" {fg(*C['green'])}● 后端已连接{rst()} {fg(*C['dim'])}{self.base_url}{rst()}")
|
||||
else:
|
||||
print(f" {fg(*C['red'])}○ 后端未连接{rst()} {fg(*C['dim'])}{self.base_url}{rst()}")
|
||||
self._content_lines += 1
|
||||
print(f" {fg(*C['dim'])}请先启动后端: nebula serve{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
print(f" {fg(*C['dim'])}─ 点击或按键导航 ─{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
for i, pg in enumerate(self.PAGES):
|
||||
line_y = top + self._content_lines
|
||||
self._click_zones.append((line_y, pg["id"]))
|
||||
key = str(i + 1) if i < 9 else "0"
|
||||
print(f" [{fg(*C['accent'])}{key}{rst()}] {bold(pg['label'])} {fg(*C['dim'])}{pg['desc']}{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
def _render_home(self):
|
||||
w = self.width
|
||||
self._content_lines = 0
|
||||
stats = self._fetch_stats()
|
||||
if not stats:
|
||||
print(f" {fg(*C['dim'])}无法获取系统信息{rst()}")
|
||||
self._content_lines += 1
|
||||
return
|
||||
|
||||
uptime = stats.get("uptime", "N/A")
|
||||
processes = stats.get("processes", 0)
|
||||
cpu = stats.get("cpu", {})
|
||||
mem = stats.get("ram", {})
|
||||
disk = stats.get("disk", {})
|
||||
|
||||
print(f" {bold('系统概览')}")
|
||||
self._content_lines += 1
|
||||
print(f" {fg(*C['dim'])}运行时间: {uptime} 进程数: {processes}{rst()}")
|
||||
self._content_lines += 1
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
bar_w = w - 30
|
||||
|
||||
cpu_p = cpu.get("percent", 0)
|
||||
print(f" CPU {fg(*C['dim'])}{str(cpu_p).rjust(5)}%{rst()} {hbar(bar_w, cpu_p, C['green'] if cpu_p < 50 else C['yellow'] if cpu_p < 80 else C['red'])}")
|
||||
self._content_lines += 1
|
||||
|
||||
ram_p = mem.get("percent", 0)
|
||||
ram_u = mem.get("used", 0)
|
||||
ram_t = mem.get("total", 0)
|
||||
print(f" 内存 {fg(*C['dim'])}{str(ram_p).rjust(5)}%{rst()} {hbar(bar_w, ram_p, C['green'] if ram_p < 50 else C['yellow'] if ram_p < 80 else C['red'])} {fg(*C['dim'])}{ram_u}G / {ram_t}G{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
disk_p = disk.get("percent", 0)
|
||||
disk_u = disk.get("used", 0)
|
||||
disk_t = disk.get("total", 0)
|
||||
print(f" 磁盘 {fg(*C['dim'])}{str(disk_p).rjust(5)}%{rst()} {hbar(bar_w, disk_p, C['green'] if disk_p < 50 else C['yellow'] if disk_p < 80 else C['red'])} {fg(*C['dim'])}{disk_u}G / {disk_t}G{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
net = stats.get("network", {})
|
||||
recv = net.get("recv_rate", 0)
|
||||
sent = net.get("sent_rate", 0)
|
||||
latency = stats.get("latency", 0)
|
||||
print(f" 网络 {fg(*C['dim'])}▼ {self._fmt_bytes(recv)}/s ▲ {self._fmt_bytes(sent)}/s 延迟: {latency}ms{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
load = stats.get("load", {})
|
||||
l1 = load.get("load1", 0)
|
||||
l5 = load.get("load5", 0)
|
||||
l15 = load.get("load15", 0)
|
||||
print(f" 负载 {fg(*C['dim'])}1m: {l1} 5m: {l5} 15m: {l15}{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
def _render_dashboard(self):
|
||||
w = self.width
|
||||
self._content_lines = 0
|
||||
stats = self._fetch_stats()
|
||||
if not stats:
|
||||
print(f" {fg(*C['dim'])}无法获取仪表盘数据{rst()}")
|
||||
self._content_lines += 1
|
||||
return
|
||||
|
||||
print(f" {bold('系统仪表盘')} 实时监控")
|
||||
self._content_lines += 1
|
||||
|
||||
cpu = stats.get("cpu", {})
|
||||
mem = stats.get("ram", {})
|
||||
disk = stats.get("disk", {})
|
||||
net = stats.get("network", {})
|
||||
disk_io = stats.get("disk_io", {})
|
||||
load = stats.get("load", {})
|
||||
latency = stats.get("latency", 0)
|
||||
processes = stats.get("processes", 0)
|
||||
uptime = stats.get("uptime", "N/A")
|
||||
|
||||
bar_w = w - 36
|
||||
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
cpu_p = cpu.get("percent", 0)
|
||||
cpu_cores = cpu.get("cores", 0)
|
||||
print(f" {fg(*C['cyan'])}CPU {rst()}{hbar(bar_w, cpu_p, C['green'] if cpu_p < 50 else C['yellow'] if cpu_p < 80 else C['red'])} {fg(*C['white'])}{cpu_p}%{rst()} {fg(*C['dim'])}({cpu_cores} 核){rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
ram_p = mem.get("percent", 0)
|
||||
ram_u = mem.get("used", 0)
|
||||
ram_t = mem.get("total", 0)
|
||||
print(f" {fg(*C['cyan'])}内存 {rst()}{hbar(bar_w, ram_p, C['green'] if ram_p < 50 else C['yellow'] if ram_p < 80 else C['red'])} {fg(*C['white'])}{ram_p}%{rst()} {fg(*C['dim'])}{ram_u}G / {ram_t}G{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
disk_p = disk.get("percent", 0)
|
||||
disk_u = disk.get("used", 0)
|
||||
disk_t = disk.get("total", 0)
|
||||
print(f" {fg(*C['cyan'])}磁盘 {rst()}{hbar(bar_w, disk_p, C['green'] if disk_p < 50 else C['yellow'] if disk_p < 80 else C['red'])} {fg(*C['white'])}{disk_p}%{rst()} {fg(*C['dim'])}{disk_u}G / {disk_t}G{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
recv = net.get("recv_rate", 0)
|
||||
sent = net.get("sent_rate", 0)
|
||||
tr = net.get("total_recv", 0)
|
||||
ts = net.get("total_sent", 0)
|
||||
print(f" {fg(*C['cyan'])}网络 {rst()}▼ {fg(*C['green'])}{self._fmt_bytes(recv)}/s{rst()} ▲ {fg(*C['yellow'])}{self._fmt_bytes(sent)}/s{rst()} {fg(*C['dim'])}总量: {self._fmt_bytes(tr)} / {self._fmt_bytes(ts)}{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
disk_r = disk_io.get("read_rate", 0)
|
||||
disk_w = disk_io.get("write_rate", 0)
|
||||
print(f" {fg(*C['cyan'])}磁盘IO {rst()}▼ {fg(*C['green'])}{self._fmt_bytes(disk_r)}/s{rst()} ▲ {fg(*C['yellow'])}{self._fmt_bytes(disk_w)}/s{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
l1 = load.get("load1", 0)
|
||||
l5 = load.get("load5", 0)
|
||||
l15 = load.get("load15", 0)
|
||||
print(f" {fg(*C['cyan'])}负载 {rst()}1m: {fg(*C['white'])}{l1}{rst()} 5m: {fg(*C['white'])}{l5}{rst()} 15m: {fg(*C['white'])}{l15}{rst()} 进程: {fg(*C['white'])}{processes}{rst()} 延迟: {fg(*C['white'])}{latency}ms{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
print(f" {fg(*C['cyan'])}运行 {rst()}{fg(*C['dim'])}{uptime}{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
def _render_logs(self):
|
||||
self._content_lines = 0
|
||||
print(f" {bold('系统日志')}")
|
||||
self._content_lines += 1
|
||||
print(f" {fg(*C['dim'])}实时日志输出(待实现){rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
def _render_terminal(self):
|
||||
self._content_lines = 0
|
||||
print(f" {bold('终端')}")
|
||||
self._content_lines += 1
|
||||
print(f" {fg(*C['dim'])}Shell 终端(待实现){rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
def _render_plugins(self):
|
||||
self._content_lines = 0
|
||||
print(f" {bold('插件管理')}")
|
||||
self._content_lines += 1
|
||||
print(f" {fg(*C['dim'])}插件列表(待实现){rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
# ── 工具 ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _fmt_bytes(b):
|
||||
if b > 1024**3:
|
||||
return f"{b/(1024**3):.1f}G"
|
||||
if b > 1024**2:
|
||||
return f"{b/(1024**2):.1f}M"
|
||||
if b > 1024:
|
||||
return f"{b/1024:.1f}K"
|
||||
return f"{b:.0f}B"
|
||||
|
||||
# ── 主循环 ────────────────────────────────────────────
|
||||
|
||||
def _navigate(self, page_id: str):
|
||||
self._stats_cache = {}
|
||||
self._stats_time = 0
|
||||
self.current_page = page_id
|
||||
self.width, self.height = term_size()
|
||||
self._render_all()
|
||||
|
||||
def run(self):
|
||||
self.width, self.height = term_size()
|
||||
self.running = True
|
||||
self._render_all()
|
||||
|
||||
signal.signal(signal.SIGWINCH, TUIClient._sigwinch)
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
# setraw 会关闭 ONLCR(\n→\r\n),重新开启避免阶梯乱码
|
||||
attrs = termios.tcgetattr(fd)
|
||||
attrs[1] = attrs[1] | termios.ONLCR
|
||||
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
||||
sys.stdout.write(_MOUSE_ON)
|
||||
sys.stdout.flush()
|
||||
|
||||
buf = ""
|
||||
while self.running:
|
||||
# 终端 resize 检测
|
||||
if TUIClient._resize_flag:
|
||||
TUIClient._resize_flag = False
|
||||
self.width, self.height = term_size()
|
||||
self._render_all()
|
||||
|
||||
ch = sys.stdin.read(1)
|
||||
buf += ch
|
||||
|
||||
# 检测 SGR 鼠标事件结束符 M/m
|
||||
if buf.startswith("\x1b[<") and ch in ("M", "m"):
|
||||
ev = self._parse_sgr_mouse(buf)
|
||||
buf = ""
|
||||
if ev:
|
||||
button, mx, my = ev
|
||||
if button == 0 and ch == "M": # 左键按下
|
||||
for zy, page_id in self._click_zones:
|
||||
if my == zy + 1: # 鼠标坐标 1-based
|
||||
self._navigate(page_id)
|
||||
break
|
||||
continue
|
||||
|
||||
# 非鼠标序列 → 重置缓冲区
|
||||
if not buf.startswith("\x1b"):
|
||||
pass
|
||||
elif buf.startswith("\x1b[<"):
|
||||
continue # 等待更多字符
|
||||
elif len(buf) > 1:
|
||||
buf = "" # 其他转义序列,丢弃
|
||||
|
||||
# 处理单字符输入
|
||||
if len(buf) == 1:
|
||||
c = buf
|
||||
buf = ""
|
||||
if c in ("q", "Q", "\x03", "\x04"):
|
||||
break
|
||||
elif c == "1":
|
||||
self._navigate("welcome")
|
||||
elif c == "2":
|
||||
self._navigate("dashboard")
|
||||
elif c == "3":
|
||||
self._navigate("logs")
|
||||
elif c == "4":
|
||||
self._navigate("terminal")
|
||||
elif c == "5":
|
||||
self._navigate("plugins")
|
||||
elif c in ("r", "R"):
|
||||
self._stats_cache = {}
|
||||
self._stats_time = 0
|
||||
self._render_all()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
signal.signal(signal.SIGWINCH, signal.SIG_DFL)
|
||||
sys.stdout.write(_MOUSE_OFF)
|
||||
sys.stdout.flush()
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||
print("\x1b[2J\x1b[H\x1b[0mTUI 已退出\n")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,3 @@
|
||||
"""TUI 插件 - 终端用户界面,与 WebUI 双启动
|
||||
|
||||
强大的转换层架构:
|
||||
- 只访问 WebUI 开放的 /tui 接口
|
||||
- 自动解析 .html 文件(入口是 index.html)
|
||||
- 支持终端兼容的 CSS(背景、字体排版样式)
|
||||
- 支持基础 JS 交互(鼠标位置、点击、按键)
|
||||
- 参考 opencode 风格的现代化终端体验
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
@@ -20,100 +11,37 @@ from oss.tui.converter import TUIManager, TUIRenderer, HTMLToTUIConverter
|
||||
|
||||
|
||||
class TUIPlugin(Plugin):
|
||||
"""TUI 插件 - 提供终端界面,通过访问 WebUI 的 /tui 接口获取 HTML"""
|
||||
|
||||
def __init__(self):
|
||||
self.webui = None
|
||||
self.http_api = None
|
||||
self.tui_manager = None
|
||||
self.running = False
|
||||
self.tui_thread = None
|
||||
self.server = None
|
||||
|
||||
def meta(self):
|
||||
from oss.plugin.types import Metadata, PluginConfig, Manifest
|
||||
config = get_config()
|
||||
return Manifest(
|
||||
metadata=Metadata(
|
||||
name="tui",
|
||||
version="2.0.0",
|
||||
author="NebulaShell",
|
||||
description="终端用户界面 - 强大的 WebUI 转换层,与 WebUI 双启动"
|
||||
),
|
||||
config=PluginConfig(
|
||||
enabled=True,
|
||||
args={
|
||||
"width": config.get("TUI_WIDTH", 80),
|
||||
"height": config.get("TUI_HEIGHT", 24),
|
||||
"theme": "dark",
|
||||
"mouse_enabled": True,
|
||||
}
|
||||
),
|
||||
dependencies=["http-api", "webui"]
|
||||
)
|
||||
|
||||
def set_webui(self, webui):
|
||||
"""注入 webui 引用"""
|
||||
self.webui = webui
|
||||
|
||||
def set_http_api(self, http_api):
|
||||
"""注入 http_api 引用"""
|
||||
self.http_api = http_api
|
||||
|
||||
def init(self, deps: dict = None):
|
||||
"""初始化 TUI - 注册 /tui 接口供转换层访问"""
|
||||
Log.info("tui", "TUI 插件初始化中...")
|
||||
|
||||
# 创建 TUI 管理器
|
||||
config = get_config()
|
||||
width = config.get("TUI_WIDTH", 80)
|
||||
height = config.get("TUI_HEIGHT", 24)
|
||||
self.tui_manager = TUIManager.get_instance(width, height)
|
||||
|
||||
# 注册 /tui 路由供 TUI 转换层访问 WebUI 页面
|
||||
if self.http_api and self.http_api.router:
|
||||
# 核心接口:/tui/index.html - TUI 入口
|
||||
self.http_api.router.get("/tui/index.html", self._handle_tui_index)
|
||||
# 核心接口:/tui/page - 获取任意页面的 TUI 版本
|
||||
self.http_api.router.get("/tui/page", self._handle_tui_page)
|
||||
# 核心接口:/tui/css - 返回终端兼容的 CSS
|
||||
self.http_api.router.get("/tui/css", self._handle_tui_css)
|
||||
# 核心接口:/tui/js - 返回 TUI 交互配置(模拟 JS)
|
||||
self.http_api.router.get("/tui/js", self._handle_tui_js)
|
||||
# 核心接口:/tui/interact - 处理 TUI 交互事件
|
||||
self.http_api.router.post("/tui/interact", self._handle_tui_interact)
|
||||
# 核心接口:/tui/pages - 列出所有可用页面
|
||||
self.http_api.router.get("/tui/pages", self._handle_tui_pages)
|
||||
|
||||
Log.ok("tui", "已注册 TUI API 路由 (/tui/*)")
|
||||
else:
|
||||
Log.warn("tui", "警告:未找到 http-api 依赖")
|
||||
|
||||
# 从 WebUI 加载默认页面到 TUI 缓存
|
||||
self._load_default_pages()
|
||||
|
||||
Log.ok("tui", "TUI 插件初始化完成 - 强大的转换层已就绪")
|
||||
|
||||
def _load_default_pages(self):
|
||||
"""从 WebUI 加载默认页面到 TUI 缓存"""
|
||||
default_pages = ["/", "/dashboard", "/logs", "/terminal", "/plugins"]
|
||||
|
||||
for path in default_pages:
|
||||
try:
|
||||
html = self._fetch_webui_page(path)
|
||||
if html:
|
||||
self.tui_manager.load_page(path, html)
|
||||
Log.info("tui", f"已加载页面:{path}")
|
||||
except Exception as e:
|
||||
Log.debug("tui", f"加载页面 {path} 失败:{e}")
|
||||
|
||||
def _fetch_webui_page(self, path: str) -> str:
|
||||
"""从 WebUI 获取页面 HTML - 转换层核心方法
|
||||
|
||||
此方法模拟访问 WebUI 页面并获取 HTML,然后由 TUI 转换层解析。
|
||||
WebUI 开放的 /tui 接口会返回带有特殊标记的 HTML,不含用户可见内容,
|
||||
但包含 data-tui-* 属性和 script[type='application/x-tui-*'] 配置。
|
||||
"""
|
||||
if not self.webui or not hasattr(self.webui, 'server'):
|
||||
return ""
|
||||
|
||||
@@ -121,7 +49,6 @@ class TUIPlugin(Plugin):
|
||||
from oss.plugin.types import Request
|
||||
request = Request(method="GET", path=path, headers={}, body="")
|
||||
|
||||
# 查找匹配的路由
|
||||
router = self.webui.server.router
|
||||
if hasattr(router, 'routes'):
|
||||
for route_path, handler in router.routes.items():
|
||||
@@ -135,24 +62,9 @@ class TUIPlugin(Plugin):
|
||||
return ""
|
||||
|
||||
def start(self):
|
||||
"""启动 TUI(在后台线程运行)"""
|
||||
Log.info("tui", "TUI 启动中...")
|
||||
self.running = True
|
||||
|
||||
# 在后台线程运行 TUI
|
||||
self.tui_thread = threading.Thread(target=self._tui_loop, daemon=True)
|
||||
self.tui_thread.start()
|
||||
|
||||
Log.ok("tui", "TUI 已启动(后台模式)")
|
||||
Log.info("tui", "提示:按 'q' 退出 TUI,WebUI 仍在运行")
|
||||
|
||||
def _tui_loop(self):
|
||||
"""TUI 主循环"""
|
||||
try:
|
||||
# 显示欢迎界面
|
||||
self._show_welcome()
|
||||
|
||||
# 主事件循环
|
||||
self._event_loop()
|
||||
|
||||
except Exception as e:
|
||||
@@ -161,8 +73,6 @@ class TUIPlugin(Plugin):
|
||||
self.running = False
|
||||
|
||||
def _show_welcome(self):
|
||||
"""显示欢迎界面"""
|
||||
welcome_html = """
|
||||
<!DOCTYPE html>
|
||||
<html class="tui-page">
|
||||
<head>
|
||||
@@ -207,26 +117,10 @@ class TUIPlugin(Plugin):
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.tui_manager.load_page("/welcome", welcome_html)
|
||||
self._render_current("/welcome")
|
||||
|
||||
def _render_current(self, path: str = None):
|
||||
"""渲染当前页面到终端"""
|
||||
if path is None:
|
||||
path = self.tui_manager.current_page or "/welcome"
|
||||
|
||||
output = self.tui_manager.render_page(path)
|
||||
|
||||
# 清屏并输出
|
||||
sys.stdout.write('\x1b[2J\x1b[H')
|
||||
sys.stdout.write(output)
|
||||
sys.stdout.write('\n\n')
|
||||
sys.stdout.write('\x1b[90m提示:按数字键导航,q 退出,r 刷新\x1b[0m\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
def _event_loop(self):
|
||||
"""简单的事件循环"""
|
||||
import sys
|
||||
import tty
|
||||
import termios
|
||||
@@ -240,10 +134,8 @@ class TUIPlugin(Plugin):
|
||||
while self.running:
|
||||
char = sys.stdin.read(1)
|
||||
|
||||
if char == '\x03': # Ctrl+C
|
||||
break
|
||||
elif char == '\x04': # Ctrl+D
|
||||
break
|
||||
if char == '\x03': break
|
||||
elif char == '\x04': break
|
||||
elif char == 'q':
|
||||
Log.info("tui", "用户退出 TUI")
|
||||
break
|
||||
@@ -261,7 +153,6 @@ class TUIPlugin(Plugin):
|
||||
self._load_default_pages()
|
||||
self._render_current()
|
||||
elif char == '\n' or char == '\r':
|
||||
# Enter 刷新当前页
|
||||
self._render_current()
|
||||
|
||||
except Exception as e:
|
||||
@@ -269,65 +160,9 @@ class TUIPlugin(Plugin):
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
# ========== TUI 核心接口实现 ==========
|
||||
|
||||
def _handle_tui_index(self, request):
|
||||
"""处理 /tui/index.html 请求 - TUI 入口点
|
||||
|
||||
返回特殊标记的 HTML,TUI 转换层会识别并转换。
|
||||
此 HTML 不含用户可见内容,仅包含 data-tui-* 标记和配置脚本。
|
||||
"""
|
||||
html = """<!DOCTYPE html>
|
||||
<html class="tui-page" data-tui-version="2.0">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>NebulaShell TUI</title>
|
||||
<!-- TUI 标记:此页面专为终端渲染 -->
|
||||
<style type="text/x-tui-css">
|
||||
/* 终端兼容 CSS */
|
||||
.tui-page { background-color: #000000; color: #ffffff; }
|
||||
.tui-body { font-family: monospace; }
|
||||
.bold { font-weight: bold; }
|
||||
.underline { text-decoration: underline; }
|
||||
.header { font-weight: bold; font-size: large; }
|
||||
.panel { border-style: single; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="tui-body">
|
||||
<div class="tui-container" data-tui-layout="vertical">
|
||||
<header data-tui-type="header">
|
||||
<h1>NebulaShell TUI</h1>
|
||||
<p>终端界面就绪</p>
|
||||
</header>
|
||||
|
||||
<separator data-tui-char="─"/>
|
||||
|
||||
<nav data-tui-type="nav" data-tui-layout="horizontal">
|
||||
<a href="/" data-tui-action="navigate" data-tui-key="1">首页</a>
|
||||
<a href="/dashboard" data-tui-action="navigate" data-tui-key="2">仪表盘</a>
|
||||
<a href="/logs" data-tui-action="navigate" data-tui-key="3">日志</a>
|
||||
<a href="/terminal" data-tui-action="navigate" data-tui-key="4">终端</a>
|
||||
</nav>
|
||||
|
||||
<separator data-tui-char="─"/>
|
||||
|
||||
<section data-tui-type="panel" data-tui-title="快捷操作">
|
||||
<button data-tui-key="r" data-tui-action="refresh">刷新 [r]</button>
|
||||
<button data-tui-key="q" data-tui-action="quit">退出 [q]</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- TUI 脚本标记:键盘绑定配置 -->
|
||||
<script type="application/x-tui-keys">
|
||||
{"1": {"action": "navigate", "target": "/"}, "2": {"action": "navigate", "target": "/dashboard"}, "3": {"action": "navigate", "target": "/logs"}, "4": {"action": "navigate", "target": "/terminal"}, "r": {"action": "refresh"}, "q": {"action": "quit"}}
|
||||
</script>
|
||||
|
||||
<!-- TUI 配置 -->
|
||||
<script type="application/x-tui-config">
|
||||
{"display": {"width": 80, "height": 24}, "mouse": {"enabled": true}, "keyboard": {"enabled": true}}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
html =
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||||
@@ -335,22 +170,15 @@ class TUIPlugin(Plugin):
|
||||
)
|
||||
|
||||
def _handle_tui_page(self, request):
|
||||
"""处理 /tui/page 请求 - 获取任意页面的 TUI 版本
|
||||
|
||||
从 WebUI 获取原始 HTML,添加 TUI 标记后返回。
|
||||
TUI 转换层会自动解析这些标记并转换为终端元素。
|
||||
"""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(request.path)
|
||||
params = parse_qs(parsed.query)
|
||||
page_path = params.get('path', ['/'])[0]
|
||||
|
||||
# 从 WebUI 获取原始 HTML
|
||||
html = self._fetch_webui_page(page_path)
|
||||
|
||||
if html:
|
||||
# 添加 TUI 标记
|
||||
html = html.replace('<html', '<html class="tui-page" data-tui-source="webui"')
|
||||
if '<body' in html:
|
||||
html = html.replace('<body', '<body class="tui-body"')
|
||||
@@ -363,16 +191,7 @@ class TUIPlugin(Plugin):
|
||||
body=html
|
||||
)
|
||||
else:
|
||||
# 返回错误页面
|
||||
error_html = """<!DOCTYPE html>
|
||||
<html class="tui-page">
|
||||
<body class="tui-body">
|
||||
<h1>❌ 页面未找到</h1>
|
||||
<p>路径:<span id="path"></span></p>
|
||||
<button data-tui-key="b" data-tui-action="back">返回</button>
|
||||
<script type="application/x-tui-keys">{"b": {"action": "back"}}</script>
|
||||
</body>
|
||||
</html>"""
|
||||
error_html =
|
||||
return Response(
|
||||
status=404,
|
||||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||||
@@ -380,113 +199,7 @@ class TUIPlugin(Plugin):
|
||||
)
|
||||
|
||||
def _handle_tui_css(self, request):
|
||||
"""处理 /tui/css 请求 - 返回终端兼容的 CSS
|
||||
|
||||
只返回终端支持的 CSS 属性:
|
||||
- 背景色(ANSI 颜色)
|
||||
- 文字颜色(ANSI 颜色)
|
||||
- 字体样式(bold, italic, underline)
|
||||
- 边框样式(单线、双线、圆角等)
|
||||
"""
|
||||
css = """/* TUI 兼容 CSS - 仅支持终端属性 */
|
||||
|
||||
/* 基础样式 */
|
||||
.tui-page {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tui-body {
|
||||
font-family: monospace;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 字体样式 - TUI 支持 */
|
||||
.bold { font-weight: bold; }
|
||||
.italic { font-style: italic; }
|
||||
.underline { text-decoration: underline; }
|
||||
.dim { opacity: 0.7; }
|
||||
|
||||
/* 布局 - TUI 简化处理 */
|
||||
.tui-container {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-tui-layout="vertical"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-tui-layout="horizontal"] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 边框样式 */
|
||||
[data-tui-border="single"] {
|
||||
border-style: single;
|
||||
}
|
||||
|
||||
[data-tui-border="double"] {
|
||||
border-style: double;
|
||||
}
|
||||
|
||||
[data-tui-border="rounded"] {
|
||||
border-style: rounded;
|
||||
}
|
||||
|
||||
/* 交互元素标记 */
|
||||
[data-tui-action] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-tui-key]::before {
|
||||
content: "[" attr(data-tui-key) "] ";
|
||||
}
|
||||
|
||||
/* 面板/卡片 */
|
||||
[data-tui-type="panel"] {
|
||||
border-style: single;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
button, [data-tui-type="button"] {
|
||||
border-style: single;
|
||||
padding: 0 2;
|
||||
}
|
||||
|
||||
/* 列表 */
|
||||
ul, ol {
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
[data-tui-type="progress"] {
|
||||
filled-char: "█";
|
||||
empty-char: "░";
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
[data-tui-type="spinner"] {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
"""
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/css"},
|
||||
body=css
|
||||
)
|
||||
|
||||
def _handle_tui_js(self, request):
|
||||
"""处理 /tui/js 请求 - 返回 TUI 交互配置(模拟 JS)
|
||||
|
||||
TUI 不支持完整 JavaScript,只支持:
|
||||
- 获取鼠标位置
|
||||
- 点击事件
|
||||
- 按键事件
|
||||
- 简单的 DOM 操作
|
||||
"""
|
||||
js_config = """// TUI JS 模拟配置
|
||||
css = // TUI JS 模拟配置
|
||||
// 仅支持基础交互功能
|
||||
|
||||
const TUI = {
|
||||
@@ -515,7 +228,6 @@ const TUI = {
|
||||
|
||||
// 导出配置
|
||||
export default TUI;
|
||||
"""
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/javascript"},
|
||||
@@ -523,85 +235,6 @@ export default TUI;
|
||||
)
|
||||
|
||||
def _handle_tui_interact(self, request):
|
||||
"""处理 TUI 交互请求 - 处理鼠标、键盘事件"""
|
||||
import json
|
||||
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
action = body.get('action', '')
|
||||
target = body.get('target', '')
|
||||
key = body.get('key', '')
|
||||
mouse_x = body.get('mouse_x', 0)
|
||||
mouse_y = body.get('mouse_y', 0)
|
||||
|
||||
# 处理导航
|
||||
if action == 'navigate':
|
||||
html = self._fetch_webui_page(target)
|
||||
if html:
|
||||
self.tui_manager.load_page(target, html)
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'page': target})
|
||||
)
|
||||
|
||||
# 处理点击
|
||||
elif action == 'click':
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'target': target})
|
||||
)
|
||||
|
||||
# 处理按键
|
||||
elif action == 'keypress':
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'key': key})
|
||||
)
|
||||
|
||||
# 处理鼠标移动
|
||||
elif action == 'mousemove':
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'x': mouse_x, 'y': mouse_y})
|
||||
)
|
||||
|
||||
# 处理刷新
|
||||
elif action == 'refresh':
|
||||
self._load_default_pages()
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True})
|
||||
)
|
||||
|
||||
# 处理退出
|
||||
elif action == 'quit':
|
||||
self.running = False
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'message': 'Quitting TUI'})
|
||||
)
|
||||
|
||||
return Response(
|
||||
status=400,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': 'Unknown action'})
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _handle_tui_pages(self, request):
|
||||
"""处理 /tui/pages 请求 - 列出所有可用页面"""
|
||||
import json
|
||||
|
||||
pages = []
|
||||
@@ -621,12 +254,6 @@ export default TUI;
|
||||
)
|
||||
|
||||
def wait_for_exit(self):
|
||||
"""前台阻塞等待 TUI 退出(用于 CLI 模式)"""
|
||||
if self.tui_thread and self.tui_thread.is_alive():
|
||||
self.tui_thread.join()
|
||||
|
||||
def stop(self):
|
||||
"""停止 TUI"""
|
||||
Log.info("tui", "TUI 停止中...")
|
||||
self.running = False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user