- ai.md: Added comprehensive documentation for TUI v1.3 conversion layer with 64+ supported components, CSS styling, and JavaScript interaction capabilities
- oss/tui/: Created complete TUI module with converter.py implementing HTML/CSS/JS to terminal conversion engine, supporting 40+ component types and advanced styling
- oss/tui/plugin.py: Implemented TUI plugin with dual startup architecture accessing WebUI's /tui interface for HTML conversion and terminal rendering
- store/@{NebulaShell}/webui/tui/: Added TUI package with converter, configuration files, and index.html for terminal interface
- store/@{NebulaShell}/webui/core/server.py: Enhanced WebUI server with TUI interface endpoints (/tui/*) for providing special-marked HTML to conversion layer
- store/@{NebulaShell}/webui/main.py: Updated WebUI plugin to support TUI dual launch with automatic homepage redirection and navigation integration
- .gitignore: Updated ignore patterns for better project cleanliness
The update provides a sophisticated terminal interface that automatically converts WebUI content through a powerful transformation layer, enabling seamless dual-mode operation.
270 lines
10 KiB
Python
270 lines
10 KiB
Python
"""WebUI 服务器 - 容器模式"""
|
||
import subprocess
|
||
import os
|
||
import tempfile
|
||
from oss.plugin.types import Response
|
||
from pathlib import Path
|
||
|
||
|
||
class WebUIServer:
|
||
"""WebUI 服务器"""
|
||
|
||
def __init__(self, router, config: dict):
|
||
self.router = router
|
||
self.config = config
|
||
self.frontend_dir = Path(__file__).parent.parent / "frontend"
|
||
|
||
# 页面注册表
|
||
self.pages = {} # path -> content_provider
|
||
self.nav_items = [] # 导航项列表
|
||
|
||
def start(self):
|
||
"""注册默认路由"""
|
||
# 静态资源
|
||
self.router.get("/static/css/main.css", self._handle_css)
|
||
self.router.get("/static/js/main.js", self._handle_js)
|
||
self.router.get("/health", self._handle_health)
|
||
|
||
# TUI 接口 - 供 TUI 转换层访问
|
||
self.router.get("/tui/index.html", self._handle_tui_index)
|
||
self.router.get("/tui/page", self._handle_tui_page)
|
||
self.router.get("/tui/css", self._handle_tui_css)
|
||
self.router.get("/tui/pages", self._handle_tui_pages)
|
||
|
||
def register_page(self, path: str, content_provider, nav_item: dict = None):
|
||
"""供其他插件注册页面"""
|
||
self.pages[path] = content_provider
|
||
if nav_item:
|
||
nav_item['url'] = path
|
||
self.nav_items.append(nav_item)
|
||
|
||
# 注册路由
|
||
self.router.get(path, lambda req: self._render_page(path, req))
|
||
|
||
def _render_page(self, path: str, request):
|
||
"""渲染页面布局 + 内容"""
|
||
provider = self.pages.get(path)
|
||
content = provider() if provider else ""
|
||
|
||
# 排序导航项(首页在前)
|
||
sorted_nav = sorted(self.nav_items, key=lambda x: 0 if x.get('url') == '/' else 1)
|
||
|
||
# 构建导航项 HTML
|
||
nav_html = ""
|
||
icon_map = {
|
||
'🏠': 'ri-home-4-line',
|
||
'📊': 'ri-dashboard-line',
|
||
'📋': 'ri-file-list-3-line',
|
||
'🧩': 'ri-puzzle-line',
|
||
'⚙️': 'ri-settings-3-line',
|
||
'🔌': 'ri-plug-line',
|
||
'📦': 'ri-box-3-line',
|
||
'🌐': 'ri-global-line',
|
||
}
|
||
for item in sorted_nav:
|
||
url = item.get('url', '#')
|
||
is_active = 'active' if url == path else ''
|
||
icon = item.get('icon', 'ri-dashboard-line')
|
||
text = item.get('text', '')
|
||
ri_icon = icon_map.get(icon, icon)
|
||
title = text
|
||
nav_html += f'''
|
||
<a href="{url}" class="nav-item {is_active}" title="{title}">
|
||
<i class="{ri_icon}"></i>
|
||
</a>
|
||
'''
|
||
|
||
page_title = self.config.get("title", "NebulaShell")
|
||
|
||
# 读取 HTML 模板
|
||
template_file = self.frontend_dir / "views" / "layout.html"
|
||
with open(template_file, 'r', encoding='utf-8') as f:
|
||
html_template = f.read()
|
||
|
||
html = html_template.replace('{{ pageTitle }}', page_title)
|
||
html = html.replace('{{ navItems }}', nav_html)
|
||
html = html.replace('{{ content }}', content)
|
||
|
||
return Response(
|
||
status=200,
|
||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||
body=html
|
||
)
|
||
def _default_home_content(self) -> str:
|
||
"""默认首页内容"""
|
||
return """
|
||
<div class="home-content">
|
||
<div class="welcome-banner">
|
||
<h2>👋 欢迎使用 NebulaShell</h2>
|
||
<p>一切皆为插件的轻量级框架</p>
|
||
</div>
|
||
</div>
|
||
"""
|
||
|
||
def _execute_php(self, php_file: str, variables: dict = None) -> str:
|
||
"""执行 PHP 文件"""
|
||
variables = variables or {}
|
||
|
||
# 构建 PHP 变量注入
|
||
php_vars = ""
|
||
for key, value in variables.items():
|
||
if isinstance(value, dict):
|
||
php_vars += f"${key} = {self._php_array(value)};\n"
|
||
elif isinstance(value, list):
|
||
php_vars += f"${key} = {self._php_array_list(value)};\n"
|
||
elif isinstance(value, str):
|
||
php_vars += f"${key} = '{value.replace(chr(39), chr(92) + chr(39))}';\n"
|
||
else:
|
||
php_vars += f"${key} = {str(value).lower() if isinstance(value, bool) else value};\n"
|
||
|
||
with open(php_file, 'r', encoding='utf-8') as f:
|
||
php_content = f.read()
|
||
|
||
# 临时文件必须和 views 在同一目录,这样 __DIR__ 才能正确解析
|
||
views_dir = str(Path(php_file).parent)
|
||
tmp_file = os.path.join(views_dir, '.temp_render.php')
|
||
|
||
try:
|
||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||
f.write(f"<?php\n{php_vars}\n?>\n{php_content}")
|
||
|
||
result = subprocess.run(
|
||
["php", "-f", tmp_file],
|
||
capture_output=True, text=True, timeout=10, cwd=views_dir,
|
||
encoding='utf-8', errors='replace'
|
||
)
|
||
|
||
if result.returncode != 0:
|
||
print(f"[webui] PHP 执行错误: {result.stderr}")
|
||
return f"<div class='error'>PHP Error: {result.stderr}</div>"
|
||
|
||
return result.stdout
|
||
finally:
|
||
try:
|
||
if os.path.exists(tmp_file):
|
||
os.unlink(tmp_file)
|
||
except:
|
||
pass
|
||
|
||
def _php_array(self, py_dict: dict) -> str:
|
||
"""Python Dict -> PHP Array"""
|
||
items = []
|
||
for key, value in py_dict.items():
|
||
if isinstance(value, str):
|
||
items.append(f"'{key}' => '{value.replace(chr(39), chr(92) + chr(39))}'")
|
||
elif isinstance(value, dict):
|
||
items.append(f"'{key}' => {self._php_array(value)}")
|
||
else:
|
||
items.append(f"'{key}' => {value}")
|
||
return "[" + ", ".join(items) + "]"
|
||
|
||
def _php_array_list(self, py_list: list) -> str:
|
||
"""Python List -> PHP Array"""
|
||
items = []
|
||
for item in py_list:
|
||
if isinstance(item, dict):
|
||
items.append(self._php_array(item))
|
||
elif isinstance(item, str):
|
||
items.append(f"'{item.replace(chr(39), chr(92) + chr(39))}'")
|
||
else:
|
||
items.append(str(item))
|
||
return "[" + ", ".join(items) + "]"
|
||
|
||
def _handle_css(self, request):
|
||
css_file = self.frontend_dir / "assets" / "css" / "main.css"
|
||
with open(css_file, 'r', encoding='utf-8') as f:
|
||
css = f.read()
|
||
return Response(status=200, headers={"Content-Type": "text/css; charset=utf-8"}, body=css)
|
||
|
||
def _handle_js(self, request):
|
||
js_file = self.frontend_dir / "assets" / "js" / "main.js"
|
||
with open(js_file, 'r', encoding='utf-8') as f:
|
||
js = f.read()
|
||
return Response(status=200, headers={"Content-Type": "application/javascript; charset=utf-8"}, body=js)
|
||
|
||
def _handle_health(self, request):
|
||
import json
|
||
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps({"status": "ok"}))
|
||
|
||
# ========== 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">
|
||
.tui-page { background-color: #000000; color: #ffffff; }
|
||
.tui-body { font-family: monospace; }
|
||
.bold { font-weight: bold; }
|
||
.underline { text-decoration: underline; }
|
||
</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>
|
||
</nav>
|
||
</div>
|
||
<script type="application/x-tui-keys">
|
||
{"1": {"action": "navigate", "target": "/"}, "2": {"action": "navigate", "target": "/dashboard"}, "3": {"action": "navigate", "target": "/logs"}}
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
return Response(status=200, headers={"Content-Type": "text/html; charset=utf-8"}, body=html)
|
||
|
||
def _handle_tui_page(self, request):
|
||
"""处理 /tui/page 请求 - 获取任意页面的 TUI 版本"""
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
parsed = urlparse(request.path)
|
||
params = parse_qs(parsed.query)
|
||
page_path = params.get('path', ['/'])[0]
|
||
|
||
# 查找已注册的页面
|
||
provider = self.pages.get(page_path)
|
||
if provider:
|
||
content = provider()
|
||
html = f"""<!DOCTYPE html>
|
||
<html class="tui-page" data-tui-source="webui">
|
||
<body class="tui-body">{content}</body>
|
||
</html>"""
|
||
return Response(status=200, headers={"Content-Type": "text/html; charset=utf-8"}, body=html)
|
||
|
||
return Response(status=404, headers={"Content-Type": "text/html"}, body="<html><body>Page not found</body></html>")
|
||
|
||
def _handle_tui_css(self, request):
|
||
"""处理 /tui/css 请求 - 返回终端兼容的 CSS"""
|
||
css = """/* TUI 兼容 CSS */
|
||
.tui-page { background-color: #000000; color: #ffffff; }
|
||
.tui-body { font-family: monospace; }
|
||
.bold { font-weight: bold; }
|
||
.underline { text-decoration: underline; }
|
||
[data-tui-action] { cursor: pointer; }
|
||
"""
|
||
return Response(status=200, headers={"Content-Type": "text/css"}, body=css)
|
||
|
||
def _handle_tui_pages(self, request):
|
||
"""处理 /tui/pages 请求 - 列出所有可用页面"""
|
||
import json
|
||
pages = list(self.pages.keys())
|
||
return Response(
|
||
status=200,
|
||
headers={"Content-Type": "application/json"},
|
||
body=json.dumps({'success': True, 'pages': pages})
|
||
)
|